Greasy Fork is available in English.
Adds a table of smileys and symbols to Neoboards, NeoMail and Guild Boards, featuring options to set favorites and add custom symbols.
// ==UserScript==
// @name Neopets Smileys
// @author Amanda Bynes
// @namespace Amanda Bynes @clraik
// @version 1.2.2
// @description Adds a table of smileys and symbols to Neoboards, NeoMail and Guild Boards, featuring options to set favorites and add custom symbols.
// @match https://www.neopets.com/neoboards/topic.phtml*
// @match https://www.neopets.com/neoboards/create_topic.phtml*
// @match https://www.neopets.com/guilds/guild_board.phtml*
// @match https://www.neopets.com/neomessages.phtml?type=send*
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_xmlhttpRequest
// @connect www.sunnyneo.com
// @connect sunnyneo.com
// @connect images.neopets.com
// ==/UserScript==
(function () {
'use strict';
const CFG = {
SUNNYNEO_URL: 'https://www.sunnyneo.com/avatars/smileys.php',
CACHE_KEY: 'NB_SMILIES_HELPER_CACHE_V3',
CACHE_MAX_AGE_HOURS: 24 * 14,
// favorites + symbols storage
FAVORITES_KEY: 'NB_SMILIES_HELPER_FAVORITES_V1',
SYMBOLS_KEY: 'NB_SMILIES_HELPER_SYMBOLS_V1',
DEFAULT_SYMBOLS: ['♥', '♡'],
// virtual categories
CAT_ALL: 'All',
CAT_FAVORITES: 'Favorites',
CAT_SYMBOLS: 'Symbols',
CELL_GAP_PX: 5,
HEADER_PADDING_PX: 4,
DEFAULT_CATEGORY: 'All',
INSERT_WRAPS_WITH_SPACES: false,
NB_TEXTAREA_SELECTOR: 'textarea[name="message"]',
NB_TITLE_SELECTOR: 'input[name="topic_title"]',
GUILD_TEXTAREA_SELECTOR: 'textarea[name="message_text"]',
GUILD_TITLE_SELECTOR: 'input[name="message_title"]',
NEOMAIL_SUBJECT_SELECTOR: 'input[name="subject"]',
NEOMAIL_PRESET_SELECTOR: 'select[name="message_type"]',
NEOMAIL_IFRAME_ID: 'message_body',
CATEGORY_PRIORITY: { DEFAULT_FIRST: 'Default', LAST_TWO: ['Altador Cup', 'Seasonal'] },
DROPDOWN_ORDER: [
'Altador Cup',
'Battledome',
'Default',
'Items',
'Miscellaneous',
'Neopets',
'Neopians',
'PetPet/Pets',
'Seasonal',
],
};
GM_addStyle(`
#nbSmileyHelper{
--nbsh-gap: ${CFG.CELL_GAP_PX}px;
--nbsh-header-pad: ${CFG.HEADER_PADDING_PX}px;
box-sizing: border-box;
}
#nbSmileyHelper, #nbSmileyHelper *{ box-sizing: border-box; }
#nbSmileyHelper{
margin: 8px 0 6px 0;
width: 100%;
max-width: 100%;
display: block;
clear: both;
border: 1px solid #cfcfcf;
background: #f6f6f6;
border-radius: 0px;
overflow: hidden;
box-shadow: 0 1px 0 rgba(0,0,0,.06);
font-family: verdana;
}
#nbSmileyHelper.nbsh-square{
border-radius: 0 !important;
box-shadow: none !important;
}
#nbSmileyHeader{
display:flex;
align-items:center;
gap:6px;
padding: var(--nbsh-header-pad) calc(var(--nbsh-header-pad) + 1px);
background:#eeeeee;
border-bottom:1px solid #d9d9d9;
flex-wrap: nowrap;
overflow: hidden;
}
#nbSmileyTitle{
font-family: Verdana;
font-weight: 700;
font-size: 9px;
line-height: 1;
white-space: nowrap;
margin-right: 2px;
flex: 0 0 auto;
opacity: .9;
}
#nbSmileyHeader .nbsh-control{
display:flex;
align-items:center;
gap:4px;
min-width: 0;
flex: 0 0 auto;
}
#nbSmileyHeader .nbsh-control.search{
flex: 1 1 auto;
min-width: 120px;
}
#nbSmileyHeader input,
#nbSmileyHeader select{
height:15px;
padding:0px 3px;
font-family: Verdana, Arial, sans-serif;
font-size:10px;
text-
line-height: 15px;
border:1px solid #cfcfcf;
border-radius: 0;
background:#fff;
vertical-align: middle;
}
#nbSmileySearch{ width: 100%; }
#nbSmileyCategory{
width: max-content;
inline-size: max-content;
min-width: 0 !important;
max-width: none !important;
}
@supports not (width: max-content){
#nbSmileyCategory{ width: auto; }
}
#nbSmileyBody{
padding: 6px;
background:#f6f6f6;
width: 100% !important;
}
/* NeoMail only: slight right breathing room */
#nbSmileyHelper.nbsh-neomail #nbSmileyBody{ padding-right: 10px; }
#nbSmileyGrid{
width: 100% !important;
display: grid;
grid-template-columns: repeat(auto-fill, 30px);
justify-content: start;
grid-auto-rows: 30px;
align-content: start;
gap: 4px;
padding: 2px;
min-height: 34px;
max-height: 100px;
overflow-y: auto;
overflow-x: hidden;
}
.nbSmileyCell{
display:flex;
align-items:center;
justify-content:center;
padding: 4px;
border-radius: 0;
background:#ffffff;
border:1px solid #e6e6e6;
cursor:pointer;
user-select:none;
}
.nbSmileyCell:hover{ border-color:#c9c9c9; }
.nbSmileyCell img{ display:block; image-rendering:auto; }
@media (max-width: 720px){
#nbSmileyHeader{ gap:4px; }
#nbSmileyTitle{ display:none; }
}
`);
function nowMs() { return Date.now(); }
function alpha(a, b) { return String(a).localeCompare(String(b), undefined, { sensitivity: 'base' }); }
function normalizeCode(code) {
if (!code) return '';
return code.trim().replace(/\s+/g, ' ');
}
function normalizeSymbol(sym) {
if (!sym) return '';
return String(sym).replace(/\s+/g, ' ').trim();
}
function buildInsertText(codeOrSymbol) {
const c = normalizeCode(codeOrSymbol);
if (!c) return '';
return CFG.INSERT_WRAPS_WITH_SPACES ? ` ${c} ` : c;
}
function catKey(s) {
return String(s || '')
.toLowerCase()
.replace(/\(pet\)\s*/g, '')
.replace(/\./g, '')
.replace(/\s+/g, ' ')
.trim();
}
function buildCategoryOrder(categories) {
const def = CFG.CATEGORY_PRIORITY.DEFAULT_FIRST;
const lastTwo = CFG.CATEGORY_PRIORITY.LAST_TWO;
const set = new Set(categories);
set.delete(CFG.CAT_ALL);
const ordered = [];
if (set.has(def)) { ordered.push(def); set.delete(def); }
const tail = [];
for (const t of lastTwo) { if (set.has(t)) { tail.push(t); set.delete(t); } }
const mid = Array.from(set).sort(alpha);
ordered.push(...mid, ...tail);
const orderIndex = Object.create(null);
ordered.forEach((c, i) => { orderIndex[c] = i; });
return { ordered, orderIndex };
}
function getCache() {
const raw = GM_getValue(CFG.CACHE_KEY, null);
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
if (!parsed || !parsed.ts || !Array.isArray(parsed.items) || !Array.isArray(parsed.categories)) return null;
const ageHrs = (nowMs() - parsed.ts) / 36e5;
if (ageHrs > CFG.CACHE_MAX_AGE_HOURS) return null;
return parsed;
} catch { return null; }
}
function setCache(items, categories, catOrder) {
GM_setValue(CFG.CACHE_KEY, JSON.stringify({ ts: nowMs(), items, categories, catOrder }));
}
// favorites + symbols persistence
function loadFavorites() {
const raw = GM_getValue(CFG.FAVORITES_KEY, null);
if (!raw) return [];
try {
const arr = JSON.parse(raw);
return Array.isArray(arr) ? arr.filter(Boolean) : [];
} catch { return []; }
}
function saveFavorites(arr) {
const clean = Array.from(new Set((arr || []).filter(Boolean)));
GM_setValue(CFG.FAVORITES_KEY, JSON.stringify(clean));
return clean;
}
function loadSymbols() {
const raw = GM_getValue(CFG.SYMBOLS_KEY, null);
if (!raw) return CFG.DEFAULT_SYMBOLS.slice();
try {
const arr = JSON.parse(raw);
if (!Array.isArray(arr)) return CFG.DEFAULT_SYMBOLS.slice();
const clean = arr.map(normalizeSymbol).filter(Boolean);
return clean.length ? clean : CFG.DEFAULT_SYMBOLS.slice();
} catch {
return CFG.DEFAULT_SYMBOLS.slice();
}
}
function saveSymbols(arr) {
const clean = Array.from(new Set((arr || []).map(normalizeSymbol).filter(Boolean)));
GM_setValue(CFG.SYMBOLS_KEY, JSON.stringify(clean));
return clean;
}
function idForItem(it) {
if (!it) return '';
if (it.kind === 'symbol') return `sym:${it.symbol}`;
return `code:${it.code}`;
}
function fetchHTML(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
onload: (resp) => resolve(resp.responseText),
onerror: (e) => reject(e),
ontimeout: (e) => reject(e),
});
});
}
function parseSunnyNeoSmileys(html) {
const doc = new DOMParser().parseFromString(html, 'text/html');
const tables = Array.from(doc.querySelectorAll('table.norm.center'));
const items = [];
const cats = new Set();
for (const t of tables) {
const th = t.querySelector('th');
const category = (th ? th.textContent : 'Other').trim() || 'Other';
const rows = Array.from(t.querySelectorAll('tr')).slice(1);
for (const r of rows) {
const img = r.querySelector('img');
const tds = r.querySelectorAll('td');
if (!img || !tds || tds.length < 2) continue;
const code = normalizeCode(tds[1].textContent || '');
if (!code || code.toLowerCase() === '-removed-') continue;
const src = img.getAttribute('src') || '';
const absSrc = src.startsWith('http') ? src : (src.startsWith('//') ? `https:${src}` : src);
items.push({ kind: 'smiley', category, code, img: absSrc });
cats.add(category);
}
}
const seen = new Set();
const deduped = [];
for (const it of items) {
if (seen.has(it.code)) continue;
seen.add(it.code);
deduped.push(it);
}
const baseCats = Array.from(cats);
const { ordered, orderIndex } = buildCategoryOrder(baseCats);
const categories = [CFG.CAT_ALL, ...ordered];
return { items: deduped, categories, catOrder: { ordered, orderIndex } };
}
async function loadSmileyData() {
const cached = getCache();
if (cached) return cached;
const html = await fetchHTML(CFG.SUNNYNEO_URL);
const parsed = parseSunnyNeoSmileys(html);
setCache(parsed.items, parsed.categories, parsed.catOrder);
return { ts: nowMs(), items: parsed.items, categories: parsed.categories, catOrder: parsed.catOrder };
}
function insertAtCursor(field, text) {
if (!field) return;
const start = field.selectionStart ?? field.value.length;
const end = field.selectionEnd ?? field.value.length;
field.value = field.value.slice(0, start) + text + field.value.slice(end);
const newPos = start + text.length;
try { field.setSelectionRange(newPos, newPos); } catch {}
field.focus();
try {
if (field.tagName === 'TEXTAREA') {
const f = field.form;
if (f && typeof window.textCounter === 'function' && f.remLen) {
const max = (window.NeoboardPens && window.NeoboardPens.maxPostLength)
? window.NeoboardPens.maxPostLength
: (field.maxLength || 500);
window.textCounter(field, f.remLen, max);
}
}
} catch {}
}
// ---------- NeoMail Advanced iframe caret preservation (Chrome-safe) ----------
function getNeomailIframe() {
return document.getElementById(CFG.NEOMAIL_IFRAME_ID) || document.querySelector(`iframe[name="${CFG.NEOMAIL_IFRAME_ID}"]`);
}
function installNeomailCaretTracker(iframe, state) {
if (!iframe || iframe._nbshCaretInstalled) return;
iframe._nbshCaretInstalled = true;
const tryBind = () => {
let doc;
try { doc = iframe.contentDocument || iframe.contentWindow?.document; } catch { return false; }
if (!doc || !doc.body) return false;
const save = () => {
try {
const sel = doc.getSelection?.();
if (sel && sel.rangeCount) iframe._nbshSavedRange = sel.getRangeAt(0).cloneRange();
} catch {}
};
const markFocused = () => { state.lastFocused = iframe; };
doc.addEventListener('keyup', () => { markFocused(); save(); }, true);
doc.addEventListener('mouseup', () => { markFocused(); save(); }, true);
doc.addEventListener('selectionchange', () => { markFocused(); save(); }, true);
doc.addEventListener('focus', () => { markFocused(); save(); }, true);
doc.body.addEventListener('input', () => { markFocused(); save(); }, true);
doc.addEventListener('mousedown', markFocused, true);
doc.addEventListener('click', markFocused, true);
save();
return true;
};
let tries = 0;
const timer = setInterval(() => {
tries++;
if (tryBind() || tries > 60) clearInterval(timer);
}, 100);
}
function restoreNeomailCaret(iframe) {
if (!iframe) return false;
let doc;
try { doc = iframe.contentDocument || iframe.contentWindow?.document; } catch { return false; }
if (!doc || !doc.body) return false;
try { iframe.contentWindow?.focus(); doc.body.focus(); } catch {}
try {
const sel = doc.getSelection?.();
if (!sel) return false;
sel.removeAllRanges();
if (iframe._nbshSavedRange) {
sel.addRange(iframe._nbshSavedRange);
return true;
}
const r = doc.createRange();
r.selectNodeContents(doc.body);
r.collapse(false);
sel.addRange(r);
return true;
} catch { return false; }
}
function insertIntoNeomailRTE(iframe, text) {
if (!iframe) return false;
restoreNeomailCaret(iframe);
let doc;
try { doc = iframe.contentDocument || iframe.contentWindow?.document; } catch { return false; }
if (!doc || !doc.body) return false;
try {
const sel = doc.getSelection?.();
if (!sel || !sel.rangeCount) return false;
const range = sel.getRangeAt(0);
range.deleteContents();
const node = doc.createTextNode(text);
range.insertNode(node);
range.setStartAfter(node);
range.setEndAfter(node);
sel.removeAllRanges();
sel.addRange(range);
iframe._nbshSavedRange = range.cloneRange();
try { if (typeof window.updateRTE === 'function') window.updateRTE(iframe.id); } catch {}
return true;
} catch { return false; }
}
// ---------------------------------------------------------------------------
function alreadyInjected() { return !!document.getElementById('nbSmileyHelper'); }
function pageType() {
const p = location.pathname;
const q = location.search || '';
if (p.includes('/neoboards/')) return 'neoboards';
if (p.includes('/guilds/guild_board.phtml')) return 'guild';
if (p.includes('/neomessages.phtml') && q.includes('type=send')) return 'neomail';
return 'other';
}
// NeoMail: align panel LEFT edge to the real editor/inputs (not the TD edge)
function alignPanelToReference(panel, refEl) {
if (!panel || !refEl) return;
try {
const r = refEl.getBoundingClientRect();
const parent = panel.parentElement || refEl.parentElement;
if (!parent) return;
const pr = parent.getBoundingClientRect();
const left = Math.round(r.left - pr.left);
const width = Math.round(r.width);
if (width > 0) panel.style.width = `${width}px`;
panel.style.marginLeft = `${Math.max(0, left)}px`;
panel.style.marginRight = '0px';
panel.style.maxWidth = 'none';
} catch {}
}
function getNeomailWidthReference() {
const iframe = getNeomailIframe();
if (iframe) return iframe;
const plain = document.getElementById(CFG.NEOMAIL_IFRAME_ID);
if (plain && plain.tagName === 'TEXTAREA') return plain;
const subj = document.querySelector(CFG.NEOMAIL_SUBJECT_SELECTOR);
return subj || null;
}
function resolveTargetField(state, type) {
const last = state.lastFocused;
if (last && document.contains(last)) return last;
if (type === 'neomail') {
const iframe = state.neomailIframe || getNeomailIframe();
if (iframe && document.contains(iframe)) return iframe;
const plain = document.getElementById(CFG.NEOMAIL_IFRAME_ID);
if (plain && plain.tagName === 'TEXTAREA') return plain;
const subj = document.querySelector(CFG.NEOMAIL_SUBJECT_SELECTOR);
if (subj) return subj;
}
if (type === 'guild') {
return document.querySelector(CFG.GUILD_TEXTAREA_SELECTOR)
|| document.querySelector(CFG.GUILD_TITLE_SELECTOR)
|| null;
}
return document.querySelector(CFG.NB_TEXTAREA_SELECTOR)
|| document.querySelector(CFG.NB_TITLE_SELECTOR)
|| null;
}
function findAnchorInfo(type) {
if (type === 'neoboards') {
const container = document.querySelector('.topicReplyContainer') || document.querySelector('#boardCreateTopic');
if (!container) return null;
const remainder = container.querySelector('.topicReplyRemainder, .topicCreateRemainder');
const inputWrap = container.querySelector('.topicReplyInput, .topicCreateInput');
if (remainder && inputWrap) {
const ta = inputWrap.querySelector(CFG.NB_TEXTAREA_SELECTOR) || container.querySelector(CFG.NB_TEXTAREA_SELECTOR);
return { anchor: remainder, mode: 'before', textarea: ta || null };
}
const ta2 = container.querySelector(CFG.NB_TEXTAREA_SELECTOR);
if (ta2) return { anchor: ta2, mode: 'after', textarea: ta2 };
return null;
}
if (type === 'guild') {
// IMPORTANT: Insert inside the SAME LEFT-ALIGNED cell as Subject/Message fields.
// This keeps it lined up and avoids being centered by the toolbar row.
const titleInput = document.querySelector(CFG.GUILD_TITLE_SELECTOR);
if (titleInput) {
const fieldsTable = titleInput.closest('table') || titleInput;
return { anchor: fieldsTable, mode: 'before' }; // below toolbar (next row), aligned with fields
}
// Fallback only if we can't find the subject/message area for some reason
const boldImg = document.querySelector('img[src*="postFormatting/bold.gif"]');
const toolbarTable = boldImg ? boldImg.closest('table') : null;
if (toolbarTable) return { anchor: toolbarTable, mode: 'after' };
return null;
}
if (type === 'neomail') {
const preset = document.querySelector(CFG.NEOMAIL_PRESET_SELECTOR);
if (preset) return { anchor: preset, mode: 'after' };
const subj = document.querySelector(CFG.NEOMAIL_SUBJECT_SELECTOR);
if (subj) return { anchor: subj, mode: 'after' };
return null;
}
return null;
}
// context menu (right-click)
function ensureContextMenu() {
let menu = document.getElementById('nbshCtxMenu');
if (menu) return menu;
menu = document.createElement('div');
menu.id = 'nbshCtxMenu';
menu.style.position = 'fixed';
menu.style.zIndex = '999999';
menu.style.display = 'none';
menu.style.background = '#fff';
menu.style.border = '1px solid #cfcfcf';
menu.style.boxShadow = '0 1px 0 rgba(0,0,0,.06)';
menu.style.fontFamily = 'Verdana, Arial, sans-serif';
menu.style.fontSize = '11px';
menu.style.color = '#000';
menu.style.padding = '2px 0';
menu.style.minWidth = '160px';
document.body.appendChild(menu);
const hide = () => { menu.style.display = 'none'; menu.innerHTML = ''; };
document.addEventListener('click', hide, true);
document.addEventListener('scroll', hide, true);
window.addEventListener('blur', hide, true);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hide(); }, true);
menu._nbshHide = hide;
return menu;
}
function showContextMenu(x, y, entries) {
const menu = ensureContextMenu();
menu.innerHTML = '';
for (const ent of entries) {
if (!ent) continue;
if (ent.type === 'sep') {
const hr = document.createElement('div');
hr.style.borderTop = '1px solid #e6e6e6';
hr.style.margin = '2px 0';
menu.appendChild(hr);
continue;
}
const item = document.createElement('div');
item.textContent = ent.label;
item.style.padding = '4px 8px';
item.style.cursor = 'pointer';
item.addEventListener('mouseenter', () => { item.style.background = '#f6f6f6'; });
item.addEventListener('mouseleave', () => { item.style.background = '#fff'; });
item.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); });
item.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
try { ent.onClick && ent.onClick(); } finally {
if (menu._nbshHide) menu._nbshHide();
}
});
menu.appendChild(item);
}
const pad = 6;
menu.style.left = `${Math.max(pad, Math.min(x, window.innerWidth - pad))}px`;
menu.style.top = `${Math.max(pad, Math.min(y, window.innerHeight - pad))}px`;
menu.style.display = 'block';
const r = menu.getBoundingClientRect();
const nx = Math.min(r.left, window.innerWidth - r.width - pad);
const ny = Math.min(r.top, window.innerHeight - r.height - pad);
menu.style.left = `${Math.max(pad, nx)}px`;
menu.style.top = `${Math.max(pad, ny)}px`;
}
function rebuildFavIndex(state) {
const idx = Object.create(null);
state.favorites.forEach((id, i) => { idx[id] = i; });
state.favIndex = idx;
state.favSet = new Set(state.favorites);
}
function toggleFavorite(state, item) {
const id = idForItem(item);
if (!id) return;
const cur = state.favorites.slice();
const i = cur.indexOf(id);
if (i >= 0) cur.splice(i, 1);
else cur.unshift(id);
state.favorites = saveFavorites(cur);
rebuildFavIndex(state);
renderGrid(state);
}
function removeSymbol(state, symRaw) {
const sym = normalizeSymbol(symRaw);
if (!sym) return;
state.symbols = saveSymbols(state.symbols.filter(s => normalizeSymbol(s) !== sym));
// remove from favorites if present
const favId = `sym:${sym}`;
if (state.favSet.has(favId)) {
state.favorites = saveFavorites(state.favorites.filter(x => x !== favId));
rebuildFavIndex(state);
}
renderGrid(state);
}
function populateCategories(state) {
const { category } = state.els;
const catsRaw = (state.data && state.data.categories) ? state.data.categories : [CFG.CAT_ALL];
const map = new Map();
for (const c of catsRaw) {
if (c === CFG.CAT_ALL) continue;
map.set(catKey(c), c);
}
category.innerHTML = '';
// All
const optAll = document.createElement('option');
optAll.value = CFG.CAT_ALL;
optAll.textContent = 'Category: All';
category.appendChild(optAll);
// Favorites
const optFav = document.createElement('option');
optFav.value = CFG.CAT_FAVORITES;
optFav.textContent = 'Favorites';
category.appendChild(optFav);
// Normal sunnyneo categories in preferred order
for (const label of CFG.DROPDOWN_ORDER) {
let actual =
map.get(catKey(label)) ||
(label === 'PetPet/Pets' ? map.get(catKey('(Pet) Petpets')) : null) ||
(label === 'Miscellaneous' ? (map.get(catKey('Misc.')) || map.get(catKey('Misc'))) : null);
if (!actual) continue;
const opt = document.createElement('option');
opt.value = actual;
if (label === 'PetPet/Pets') opt.textContent = 'PetPet/Pets';
else if (label === 'Miscellaneous') opt.textContent = 'Miscellaneous';
else opt.textContent = label;
category.appendChild(opt);
}
// Symbols
const optSym = document.createElement('option');
optSym.value = CFG.CAT_SYMBOLS;
optSym.textContent = 'Symbols';
category.appendChild(optSym);
category.value = CFG.CAT_ALL;
state.filters.category = CFG.CAT_ALL;
}
// FIXED: All allows BOTH smileys + symbols
function matchesFilter(item, filters, state) {
if (!item) return false;
const q = filters.q || '';
// Category filtering
if (filters.category && filters.category !== CFG.CAT_ALL) {
if (filters.category === CFG.CAT_FAVORITES) {
const id = idForItem(item);
if (!state.favSet.has(id)) return false;
} else if (filters.category === CFG.CAT_SYMBOLS) {
if (item.kind !== 'symbol') return false;
} else {
// normal sunnyneo category
if (item.kind !== 'smiley') return false;
if (item.category !== filters.category) return false;
}
}
// else: Category = All → allow BOTH smileys + symbols
// Search filtering
if (q) {
const hay = item.kind === 'symbol'
? `${item.symbol} ${CFG.CAT_SYMBOLS}`.toLowerCase()
: `${item.code} ${item.category}`.toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
}
// FIXED: favorites first everywhere; symbols included in All; symbols grouped last (unless favorited)
function sortItemsForDisplay(state, arr) {
const filters = state.filters;
const orderIndex = state.data?.catOrder?.orderIndex || {};
const favIndex = state.favIndex || Object.create(null);
const byFav = (a, b) => {
const ai = favIndex[idForItem(a)];
const bi = favIndex[idForItem(b)];
const aFav = (ai !== undefined);
const bFav = (bi !== undefined);
if (aFav && bFav) return ai - bi;
if (aFav && !bFav) return -1;
if (!aFav && bFav) return 1;
return 0;
};
const labelFor = (it) => (it.kind === 'symbol' ? it.symbol : it.code);
const catIdx = (it) => {
// Put Symbols group after normal categories in All view (unless favorited, which always goes first)
if (it.kind === 'symbol') return 9998;
return (orderIndex[it.category] ?? 9997);
};
if (filters.category === CFG.CAT_SYMBOLS) {
return arr.slice().sort((a, b) => alpha(a.symbol, b.symbol));
}
if (filters.category === CFG.CAT_FAVORITES) {
return arr.slice().sort((a, b) => {
const d = byFav(a, b);
if (d) return d;
return alpha(labelFor(a), labelFor(b));
});
}
if (filters.category === CFG.CAT_ALL) {
return arr.slice().sort((a, b) => {
const dFav = byFav(a, b);
if (dFav) return dFav;
const ai = catIdx(a);
const bi = catIdx(b);
if (ai !== bi) return ai - bi;
return alpha(labelFor(a), labelFor(b));
});
}
// Specific sunnyneo category: favorites first within the filtered set, then alpha
return arr.slice().sort((a, b) => {
const dFav = byFav(a, b);
if (dFav) return dFav;
return alpha(a.code, b.code);
});
}
function renderGrid(state) {
const { grid } = state.els;
const { items } = state.data || { items: [] };
const filters = state.filters;
grid.innerHTML = '';
const symbolItems = state.symbols.map(sym => ({
kind: 'symbol',
category: CFG.CAT_SYMBOLS,
symbol: sym
}));
// FIXED: All + normal categories include symbols in pool
let pool = [];
if (filters.category === CFG.CAT_SYMBOLS) pool = symbolItems;
else if (filters.category === CFG.CAT_FAVORITES) pool = items.concat(symbolItems);
else pool = items.concat(symbolItems);
const filtered = pool.filter(it => matchesFilter(it, filters, state));
const sorted = sortItemsForDisplay(state, filtered);
const attachCellBehavior = (cell, item) => {
cell.addEventListener('mousedown', (e) => { e.preventDefault(); });
cell.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
const id = idForItem(item);
const isFav = state.favSet.has(id);
const entries = [
{
label: isFav ? 'Remove Favorite' : 'Set Favorite',
onClick: () => toggleFavorite(state, item),
},
];
if (item.kind === 'symbol') {
entries.push({ type: 'sep' });
entries.push({
label: 'Delete Symbol',
onClick: () => removeSymbol(state, item.symbol),
});
}
showContextMenu(e.clientX, e.clientY, entries);
});
cell.addEventListener('click', () => {
const field = resolveTargetField(state, state.pageType);
if (!field) return;
const text = (item.kind === 'symbol')
? buildInsertText(item.symbol)
: buildInsertText(item.code);
if (state.pageType === 'neomail') {
const iframe = state.neomailIframe || getNeomailIframe();
const plain = document.getElementById(CFG.NEOMAIL_IFRAME_ID);
if (field === iframe && iframe && iframe.tagName === 'IFRAME') {
insertIntoNeomailRTE(iframe, text);
return;
}
if (plain && plain.tagName === 'TEXTAREA') {
insertAtCursor(plain, text);
return;
}
if (field && (field.tagName === 'INPUT' || field.tagName === 'TEXTAREA')) {
insertAtCursor(field, text);
}
return;
}
insertAtCursor(field, text);
});
};
for (const it of sorted) {
const cell = document.createElement('div');
cell.className = 'nbSmileyCell';
cell.title = (it.kind === 'symbol') ? it.symbol : it.code;
if (it.kind === 'symbol') {
const span = document.createElement('span');
span.textContent = it.symbol;
cell.appendChild(span);
} else {
const img = document.createElement('img');
img.src = it.img;
img.alt = it.code;
cell.appendChild(img);
}
attachCellBehavior(cell, it);
grid.appendChild(cell);
}
// Symbols: add one blank "+" slot
if (filters.category === CFG.CAT_SYMBOLS) {
const addCell = document.createElement('div');
addCell.className = 'nbSmileyCell';
addCell.title = 'Add Symbol';
const span = document.createElement('span');
span.textContent = '+';
addCell.appendChild(span);
addCell.addEventListener('mousedown', (e) => { e.preventDefault(); });
addCell.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
});
addCell.addEventListener('click', () => {
const raw = window.prompt('Paste a symbol to add:', '');
const sym = normalizeSymbol(raw);
if (!sym) return;
const next = state.symbols.slice();
if (!next.includes(sym)) next.push(sym);
state.symbols = saveSymbols(next);
renderGrid(state);
});
grid.appendChild(addCell);
}
if (!sorted.length && filters.category !== CFG.CAT_SYMBOLS) {
const empty = document.createElement('div');
empty.style.padding = '8px';
empty.style.fontSize = '12px';
empty.style.color = '#666';
empty.textContent = 'No smilies match your filters.';
grid.appendChild(empty);
}
if (!sorted.length && filters.category === CFG.CAT_SYMBOLS && !state.symbols.length) {
const empty = document.createElement('div');
empty.style.padding = '8px';
empty.style.fontSize = '12px';
empty.style.color = '#666';
empty.textContent = 'No symbols yet. Click + to add one.';
grid.appendChild(empty);
}
}
function injectPanel(anchorInfo, data, type) {
const { anchor, mode, textarea } = anchorInfo;
if (!anchor || alreadyInjected()) return;
const panel = document.createElement('div');
panel.id = 'nbSmileyHelper';
if (type === 'guild' || type === 'neomail') panel.classList.add('nbsh-square');
if (type === 'neomail') panel.classList.add('nbsh-neomail');
const header = document.createElement('div');
header.id = 'nbSmileyHeader';
const title = document.createElement('div');
title.id = 'nbSmileyTitle';
title.textContent = 'Smileys';
const searchWrap = document.createElement('div');
searchWrap.className = 'nbsh-control search';
const search = document.createElement('input');
search.id = 'nbSmileySearch';
search.type = 'text';
search.placeholder = 'Search';
searchWrap.appendChild(search);
const categoryWrap = document.createElement('div');
categoryWrap.className = 'nbsh-control';
const category = document.createElement('select');
category.id = 'nbSmileyCategory';
categoryWrap.appendChild(category);
header.appendChild(title);
header.appendChild(searchWrap);
header.appendChild(categoryWrap);
const body = document.createElement('div');
body.id = 'nbSmileyBody';
const grid = document.createElement('div');
grid.id = 'nbSmileyGrid';
body.appendChild(grid);
panel.appendChild(header);
panel.appendChild(body);
if (mode === 'before') anchor.insertAdjacentElement('beforebegin', panel);
else if (mode === 'after') anchor.insertAdjacentElement('afterend', panel);
else anchor.appendChild(panel);
const favorites = loadFavorites();
const symbols = loadSymbols();
const state = {
panel,
data,
els: { search, category, grid },
filters: { q: '', category: CFG.DEFAULT_CATEGORY },
lastFocused: null,
pageType: type,
neomailIframe: null,
favorites,
favSet: new Set(),
favIndex: Object.create(null),
symbols,
};
rebuildFavIndex(state);
if (type === 'neoboards') {
const titleInput = document.querySelector(CFG.NB_TITLE_SELECTOR);
const messageBox = textarea || document.querySelector(CFG.NB_TEXTAREA_SELECTOR);
state.lastFocused = messageBox || titleInput || null;
if (titleInput) {
titleInput.addEventListener('focus', () => { state.lastFocused = titleInput; });
titleInput.addEventListener('click', () => { state.lastFocused = titleInput; });
}
if (messageBox) {
messageBox.addEventListener('focus', () => { state.lastFocused = messageBox; });
messageBox.addEventListener('click', () => { state.lastFocused = messageBox; });
}
}
if (type === 'guild') {
const titleInput = document.querySelector(CFG.GUILD_TITLE_SELECTOR);
const messageBox = document.querySelector(CFG.GUILD_TEXTAREA_SELECTOR);
state.lastFocused = messageBox || titleInput || null;
if (titleInput) {
titleInput.addEventListener('focus', () => { state.lastFocused = titleInput; });
titleInput.addEventListener('click', () => { state.lastFocused = titleInput; });
}
if (messageBox) {
messageBox.addEventListener('focus', () => { state.lastFocused = messageBox; });
messageBox.addEventListener('click', () => { state.lastFocused = messageBox; });
}
try {
const wStr = (messageBox && messageBox.style && messageBox.style.width) ? messageBox.style.width
: (titleInput && titleInput.style && titleInput.style.width) ? titleInput.style.width
: '';
const w = parseInt(String(wStr).replace('px', ''), 10);
if (w) panel.style.width = `${w}px`;
} catch {}
}
if (type === 'neomail') {
const subj = document.querySelector(CFG.NEOMAIL_SUBJECT_SELECTOR);
if (subj) {
subj.addEventListener('focus', () => { state.lastFocused = subj; });
subj.addEventListener('click', () => { state.lastFocused = subj; });
}
const iframe = getNeomailIframe();
const plain = document.getElementById(CFG.NEOMAIL_IFRAME_ID);
if (iframe && iframe.tagName === 'IFRAME') {
state.neomailIframe = iframe;
state.lastFocused = iframe;
installNeomailCaretTracker(iframe, state);
iframe.addEventListener('mousedown', () => { state.lastFocused = iframe; });
iframe.addEventListener('click', () => { state.lastFocused = iframe; });
} else if (plain && plain.tagName === 'TEXTAREA') {
state.lastFocused = plain;
plain.addEventListener('focus', () => { state.lastFocused = plain; });
plain.addEventListener('click', () => { state.lastFocused = plain; });
}
const ref = getNeomailWidthReference();
if (ref) alignPanelToReference(panel, ref);
}
panel._nbState = state;
populateCategories(state);
renderGrid(state);
search.addEventListener('input', () => {
state.filters.q = (search.value || '').trim().toLowerCase();
renderGrid(state);
});
category.addEventListener('change', () => {
state.filters.category = category.value;
renderGrid(state);
});
}
let booted = false;
async function boot() {
if (booted) return;
booted = true;
const type = pageType();
if (type === 'other') return;
let data;
try { data = await loadSmileyData(); } catch { return; }
const anchorInfo = findAnchorInfo(type);
if (anchorInfo) injectPanel(anchorInfo, data, type);
const mo = new MutationObserver(() => {
const existing = document.getElementById('nbSmileyHelper');
const t = pageType();
if (t === 'other') return;
if (!existing) {
const ai = findAnchorInfo(t);
if (ai) injectPanel(ai, data, t);
return;
}
if (existing && existing._nbState) {
const state = existing._nbState;
if (state.pageType === 'neomail') {
const iframe = getNeomailIframe();
if (iframe && iframe.tagName === 'IFRAME') {
state.neomailIframe = iframe;
installNeomailCaretTracker(iframe, state);
}
const ref = getNeomailWidthReference();
if (ref) alignPanelToReference(existing, ref);
}
}
});
mo.observe(document.body, { childList: true, subtree: true });
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
else boot();
})();