// ==UserScript== // @name Hashtag Bundler for X/Twitter and Bluesky // @namespace https://codymkw.nekoweb.org/ // @version 2.6.2 // @description Hashtag Bundler v2.6.2 — fixed duplication on Twitter; insert always appends; combined bundles, checkbox editor, collapse, export/import, improved UX. // @match https://twitter.com/* // @match https://x.com/* // @match https://bsky.app/* // @grant GM_getValue // @grant GM_setValue // @license MIT // @downloadURL none // ==/UserScript== (function () { 'use strict'; //------------------------------------------------------- // Host / Storage setup //------------------------------------------------------- let PANEL_ID, STORAGE_KEY; const host = location.hostname; if (host === "twitter.com" || host === "x.com") { PANEL_ID = "twitter-hashtag-panel"; STORAGE_KEY = "twitterHashtagBundles"; } else if (host === "bsky.app") { PANEL_ID = "bluesky-hashtag-panel"; STORAGE_KEY = "blueskyHashtagBundles"; } else return; const COMBINED_KEY = STORAGE_KEY + "_combined"; const COLLAPSED_KEY = STORAGE_KEY + "_collapsed"; //------------------------------------------------------- // Storage helpers //------------------------------------------------------- const loadData = () => { try { return JSON.parse(GM_getValue(STORAGE_KEY, "{}") || "{}"); } catch { return {}; } }; const saveData = (v) => GM_setValue(STORAGE_KEY, JSON.stringify(v)); const loadCombined = () => { try { return JSON.parse(GM_getValue(COMBINED_KEY, "{}") || "{}"); } catch { return {}; } }; const saveCombined = (v) => GM_setValue(COMBINED_KEY, JSON.stringify(v)); const loadCollapsed = () => { try { return JSON.parse(GM_getValue(COLLAPSED_KEY, "false")); } catch { return false; } }; const saveCollapsed = (v) => GM_setValue(COLLAPSED_KEY, JSON.stringify(Boolean(v))); //------------------------------------------------------- // DOM helper //------------------------------------------------------- const el = (tag, props = {}, kids = []) => { const e = document.createElement(tag); for (const k in props) { if (k === "style" && typeof props[k] === "object") Object.assign(e.style, props[k]); else if (k === "html") e.innerHTML = props[k]; else e[k] = props[k]; } kids.forEach(c => e.appendChild(c)); return e; }; //------------------------------------------------------- // Auto Combined Name — Option C1 //------------------------------------------------------- const makeAutoName = (sources) => { const getInitials = (s) => { const parts = s.split(/[\W_]+/).filter(Boolean); if (!parts.length) return s.slice(0, 3).toUpperCase(); return parts.map(p => p[0].toUpperCase()).slice(0, 4).join(''); }; const safeSourceKey = (s) => s.replace(/[^\w\-]/g, "_").replace(/_+/g, "_"); const initials = sources.map(getInitials).join("_"); const safeNames = sources.map(safeSourceKey); const ts = Date.now(); const shortTs = String(ts).slice(-5); return { key: `Combined_${safeNames.join("_")}_${ts}`, label: `Combined_${initials}_${shortTs}` }; }; //------------------------------------------------------- // Insert helper: ensure insertion is appended to end for contenteditable // - For contenteditable: move caret to end, focus, execCommand('insertText') -> NO InputEvent dispatch // - If execCommand fails on contenteditable: fallback to textContent += text (no event) // - For textarea: append to .value and dispatch 'input' event (kept) //------------------------------------------------------- function getComposerTarget() { // 1) Twitter structure B: div[data-testid="tweetTextarea_0"] -> inner contenteditable div let node = document.querySelector('div[data-testid="tweetTextarea_0"]'); if (node) { // try find inner contenteditable div const inner = node.querySelector('div[contenteditable="true"], [contenteditable="true"]'); if (inner) return { type: 'contenteditable', node: inner }; // fallback: maybe node itself is contenteditable if (node.getAttribute && node.getAttribute('contenteditable') === 'true') return { type: 'contenteditable', node }; } // 2) generic contenteditable const generic = document.querySelector('div[contenteditable="true"], [contenteditable="true"]'); if (generic) return { type: 'contenteditable', node: generic }; // 3) textarea fallback const ta = document.querySelector('textarea'); if (ta) return { type: 'textarea', node: ta }; return null; } function moveCaretToEnd(contentEditableNode) { try { const range = document.createRange(); range.selectNodeContents(contentEditableNode); range.collapse(false); // move to end const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); } catch (err) { // ignore } } function insertTextAtEnd(text) { const target = getComposerTarget(); if (!target) return false; if (target.type === 'textarea') { // append to textarea value and dispatch input const ta = target.node; try { ta.focus(); } catch {} ta.value = ta.value + text; try { ta.dispatchEvent(new Event('input', { bubbles: true })); } catch {} return true; } else if (target.type === 'contenteditable') { const node = target.node; // move caret to end first (forces append) moveCaretToEnd(node); try { node.focus(); } catch (e) {} try { // execCommand insertText (v2.3 style) const success = document.execCommand('insertText', false, text); // do NOT dispatch InputEvent afterwards (prevents duplication on Twitter) if (success) return true; // if execCommand returned false, fallback to changing textContent (no event) node.textContent = node.textContent + text; return true; } catch (err) { // fallback: append textContent (no event) try { node.textContent = node.textContent + text; return true; } catch (ee) { return false; } } } return false; } //------------------------------------------------------- // Simple confirm text helper (text-only) - resets after 3s //------------------------------------------------------- const confirmButton = (btn, originalText, confirmText) => { try { btn.textContent = confirmText; } catch {} setTimeout(() => { try { btn.textContent = originalText; } catch {} }, 3000); }; //------------------------------------------------------- // Panel creation & render (v2.6.2) //------------------------------------------------------- const createPanel = () => { if (document.getElementById(PANEL_ID)) return; const panel = el('div', { id: PANEL_ID, style: ` position:fixed;right:20px;bottom:20px;width:320px; background:#1f1f1f;color:white;border-radius:12px;padding:10px; font-family:sans-serif;box-shadow:0 4px 14px rgba(0,0,0,0.5); z-index:999999; ` }); const header = el('div', { style: 'display:flex;justify-content:space-between;align-items:center;cursor:pointer;margin-bottom:8px;' }); const title = el('span', { textContent: 'Hashtag Bundles', style: 'font-weight:700;' }); const arrow = el('span', { textContent: '▼', style: 'font-size:14px;' }); header.append(title, arrow); const container = el('div', { id: 'bundle-container', style: 'max-height:380px;overflow-y:auto;padding-right:6px;' }); panel.append(header, container); document.body.append(panel); let collapsed = loadCollapsed(); container.style.display = collapsed ? 'none' : 'block'; arrow.textContent = collapsed ? '▲' : '▼'; header.onclick = () => { collapsed = !collapsed; saveCollapsed(collapsed); container.style.display = collapsed ? 'none' : 'block'; arrow.textContent = collapsed ? '▲' : '▼'; }; render(); function render() { container.innerHTML = ''; const data = loadData(); // New Bundle const newBtn = el('button', { textContent: '➕ New Bundle', style: 'width:100%;padding:8px;margin-bottom:8px;border:none;border-radius:6px;background:#5865F2;color:white;font-weight:700;cursor:pointer;' }); newBtn.onclick = () => { const name = prompt('Bundle name?'); if (!name) return; const tags = prompt('Enter hashtags separated by spaces:'); if (!tags) return; data[name] = tags.split(/\s+/).filter(Boolean).map(t => t.startsWith('#') ? t : '#' + t); saveData(data); render(); }; container.append(newBtn); // Combine const combineBtn = el('button', { textContent: '🔗 Combine Bundles', style: 'width:100%;padding:8px;margin-bottom:8px;border:none;border-radius:6px;background:#9b59b6;color:white;font-weight:700;cursor:pointer;' }); combineBtn.onclick = openCreateCombinedModal; container.append(combineBtn); // Show combined const showCombined = el('button', { textContent: '📂 Show Combined Bundles', style: 'width:100%;padding:8px;margin-bottom:8px;border:none;border-radius:6px;background:#8e44ad;color:white;font-weight:700;cursor:pointer;' }); showCombined.onclick = openCombinedPopup; container.append(showCombined); // Export / Import const ioWrap = el('div', { style: 'display:flex;gap:8px;margin:10px 0;' }); const exportBtn = el('button', { textContent: 'Export', style: 'flex:1;padding:8px;border:none;border-radius:6px;background:#4CAF50;color:white;font-weight:700;cursor:pointer;' }); const importBtn = el('button', { textContent: 'Import', style: 'flex:1;padding:8px;border:none;border-radius:6px;background:#2196F3;color:white;font-weight:700;cursor:pointer;' }); exportBtn.onclick = () => { const payload = { bundles: loadData(), combined: loadCombined() }; const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = STORAGE_KEY + '.json'; a.click(); }; importBtn.onclick = () => { const input = document.createElement('input'); input.type = 'file'; input.accept = 'application/json'; input.onchange = (e) => { const f = e.target.files?.[0]; if (!f) return; const r = new FileReader(); r.onload = (ev) => { try { const j = JSON.parse(ev.target.result); saveData({ ...loadData(), ...(j.bundles || {}) }); saveCombined({ ...loadCombined(), ...(j.combined || {}) }); render(); alert('Import OK.'); } catch { alert('Invalid JSON.'); } }; r.readAsText(f); }; input.click(); }; ioWrap.append(exportBtn, importBtn); container.append(ioWrap); // list bundles const names = Object.keys(data).sort((a, b) => a.localeCompare(b)); if (!names.length) container.append(el('div', { textContent: 'No bundles yet.', style: 'color:#999;font-size:13px;padding:6px;' })); names.forEach(name => { const entry = el('div', { style: 'background:#262626;border-radius:8px;margin-bottom:8px;padding:8px;' }); const row = el('div', { style: 'display:flex;justify-content:space-between;align-items:center;cursor:pointer;' }); const nameEl = el('span', { textContent: name, style: 'font-weight:700;font-size:14px;' }); const arrow = el('span', { textContent: '▶', style: 'font-size:13px;transition:0.18s;' }); row.append(nameEl, arrow); entry.append(row); const inner = el('div', { style: 'display:none;margin-top:8px;border-top:1px solid #444;padding-top:8px;font-size:12px;color:#ccc;' }); const tagText = el('div', { textContent: data[name].join(' '), style: 'margin-bottom:8px;word-break:break-word;' }); const controls = el('div', { style: 'display:flex;gap:8px;' }); const mkbtn = (txt, bg, fn) => el('button', { textContent: txt, style: `flex:1;padding:6px;border:none;border-radius:6px;background:${bg};color:white;font-size:12px;font-weight:700;cursor:pointer;`, onclick: fn }); // Insert (uses robust insertTextAtEnd) const insertBtn = mkbtn('Insert', '#2196F3', () => { const text = data[name].join(' ') + ' '; const ok = insertTextAtEnd(text); if (!ok) return alert('Insert failed (composer not found).'); confirmButton(insertBtn, 'Insert', 'Inserted!'); }); // Copy const copyBtn = mkbtn('Copy', '#4CAF50', async () => { await navigator.clipboard.writeText(data[name].join(' ')).catch(() => alert('Copy error')); confirmButton(copyBtn, 'Copy', 'Copied!'); }); // Edit const editBtn = mkbtn('Edit', '#555', () => { const tags = prompt('Edit hashtags:', data[name].join(' ')); if (!tags) return; data[name] = tags.split(/\s+/).filter(Boolean).map(t => t.startsWith('#') ? t : '#' + t); saveData(data); render(); }); // Delete const delBtn = mkbtn('Delete', '#c0392b', () => { if (!confirm(`Delete "${name}"?`)) return; delete data[name]; saveData(data); render(); }); controls.append(insertBtn, copyBtn, editBtn, delBtn); inner.append(tagText, controls); entry.append(inner); row.onclick = () => { const show = inner.style.display === "none"; inner.style.display = show ? "block" : "none"; arrow.style.transform = show ? "rotate(90deg)" : "rotate(0deg)"; }; container.append(entry); }); } }; //------------------------------------------------------- // Create Combined Modal (checkbox based) //------------------------------------------------------- const openCreateCombinedModal = () => { const data = loadData(); const bundles = Object.keys(data).sort(); if (!bundles.length) return alert('No bundles available.'); if (document.getElementById('combine-backdrop')) return; const backdrop = el('div', { id: 'combine-backdrop', style: 'position:fixed;left:0;top:0;width:100%;height:100%;background:rgba(0,0,0,0.4);z-index:999999;' }); document.body.append(backdrop); const modal = el('div', { style: 'position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);background:#1f1f1f;color:white;width:360px;padding:14px;border-radius:12px;z-index:1000000;box-shadow:0 6px 24px rgba(0,0,0,0.6);' }); backdrop.append(modal); backdrop.onclick = (e) => { if (e.target === backdrop) close(); }; modal.onclick = (e) => e.stopPropagation(); modal.append(el('div', { textContent: 'Combine Bundles', style: 'font-weight:700;margin-bottom:10px;font-size:15px;' })); modal.append(el('div', { textContent: 'Select bundles (order of checking determines tag order):', style: 'color:#ccc;margin-bottom:8px;font-size:13px;' })); let order = []; const list = el('div', { style: 'display:flex;flex-direction:column;gap:6px;margin-bottom:10px;max-height:220px;overflow:auto;' }); bundles.forEach(n => { const row = el('div', { style: 'display:flex;align-items:center;gap:8px;background:#262626;padding:8px;border-radius:6px;cursor:pointer;' }); const cb = el('input', { type: 'checkbox' }); cb.onclick = (e) => e.stopPropagation(); cb.onchange = () => { if (cb.checked) order.push(n); else order = order.filter(x => x !== n); updatePreview(); }; const lbl = el('span', { textContent: n, style: 'font-weight:600;cursor:pointer;' }); lbl.onclick = () => { cb.checked = !cb.checked; cb.onchange(); }; row.append(cb, lbl); list.append(row); }); modal.append(list); modal.append(el('div', { textContent: 'Preview:', style: 'color:#ccc;margin-bottom:6px;' })); const preview = el('div', { style: 'padding:8px;background:#262626;border-radius:6px;color:#ddd;min-height:28px;margin-bottom:12px;word-break:break-word;' }); modal.append(preview); function updatePreview() { const tags = [...new Set(order.flatMap(n => data[n] || []))]; preview.textContent = tags.join(' ') || '(none)'; } const createBtn = el('button', { textContent: 'Create Combined Bundle', style: 'width:100%;padding:8px;background:#27ae60;color:white;border:none;border-radius:6px;font-weight:700;cursor:pointer;margin-bottom:8px;' }); createBtn.onclick = () => { if (!order.length) return alert('Select at least one bundle.'); const auto = makeAutoName(order); const tags = [...new Set(order.flatMap(n => data[n] || []))]; const combined = loadCombined(); combined[auto.key] = { label: auto.label, fullKey: auto.key, sources: [...order], tags }; saveCombined(combined); alert('Created combined bundle.'); close(); }; const cancelBtn = el('button', { textContent: 'Cancel', style: 'width:100%;padding:8px;background:#7f8c8d;color:white;border:none;border-radius:6px;font-weight:700;cursor:pointer;' }); cancelBtn.onclick = close; modal.append(createBtn, cancelBtn); updatePreview(); function close() { const b = document.getElementById('combine-backdrop'); if (b) b.remove(); } }; //------------------------------------------------------- // Edit Combined (E1 checkbox edit) //------------------------------------------------------- const openEditCombinedModal = (key) => { const combined = loadCombined(); const data = loadData(); const combo = combined[key]; if (!combo) return alert('Not found.'); const bundles = Object.keys(data).sort(); if (!bundles.length) return alert('No bundles exist.'); if (document.getElementById('edit-backdrop')) return; const backdrop = el('div', { id: 'edit-backdrop', style: 'position:fixed;left:0;top:0;width:100%;height:100%;background:rgba(0,0,0,0.4);z-index:999999;' }); document.body.append(backdrop); const modal = el('div', { style: 'position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);background:#1f1f1f;color:white;width:360px;padding:14px;border-radius:12px;z-index:1000000;box-shadow:0 6px 24px rgba(0,0,0,0.6);' }); backdrop.append(modal); backdrop.onclick = (e) => { if (e.target === backdrop) close(); }; modal.onclick = (e) => e.stopPropagation(); modal.append(el('div', { textContent: 'Edit Combined Bundle', style: 'font-weight:700;margin-bottom:10px;font-size:15px;' })); modal.append(el('div', { textContent: `Current label: ${combo.label}`, style: 'color:#bbb;margin-bottom:10px;font-size:13px;' })); let order = [...combo.sources]; const list = el('div', { style: 'display:flex;flex-direction:column;gap:6px;margin-bottom:10px;max-height:220px;overflow:auto;' }); bundles.forEach(n => { const row = el('div', { style: 'display:flex;align-items:center;gap:8px;background:#262626;padding:8px;border-radius:6px;cursor:pointer;' }); const cb = el('input', { type: 'checkbox' }); cb.checked = order.includes(n); cb.onclick = (e) => e.stopPropagation(); cb.onchange = () => { if (cb.checked) { if (!order.includes(n)) order.push(n); } else { order = order.filter(x => x !== n); } updatePreview(); }; const lbl = el('span', { textContent: n, style: 'font-weight:600;cursor:pointer;' }); lbl.onclick = () => { cb.checked = !cb.checked; cb.onchange(); }; row.append(cb, lbl); list.append(row); }); modal.append(list); modal.append(el('div', { textContent: 'Preview:', style: 'color:#ccc;margin-bottom:6px;' })); const preview = el('div', { style: 'padding:8px;background:#262626;border-radius:6px;color:#ddd;min-height:28px;margin-bottom:12px;word-break:break-word;' }); modal.append(preview); function updatePreview() { const tags = [...new Set(order.flatMap(n => data[n] || []))]; preview.textContent = tags.join(' ') || '(none)'; } updatePreview(); const saveBtn = el('button', { textContent: 'Save Changes', style: 'width:100%;padding:8px;background:#27ae60;color:white;border:none;border-radius:6px;font-weight:700;cursor:pointer;margin-bottom:8px;' }); saveBtn.onclick = () => { if (!order.length) return alert('Select at least one bundle.'); const oldKey = key; const oldSources = combo.sources.join('|'); const newSources = order.join('|'); let newKey = key; let newLabel = combo.label; if (oldSources !== newSources) { const auto = makeAutoName(order); newKey = auto.key; newLabel = auto.label; delete combined[oldKey]; } const newTags = [...new Set(order.flatMap(n => data[n] || []))]; combined[newKey] = { label: newLabel, fullKey: newKey, sources: [...order], tags: newTags }; saveCombined(combined); alert('Updated.'); close(); }; const cancelBtn = el('button', { textContent: 'Cancel', style: 'width:100%;padding:8px;background:#7f8c8d;color:white;border:none;border-radius:6px;font-weight:700;cursor:pointer;' }); cancelBtn.onclick = close; modal.append(saveBtn, cancelBtn); function close() { const b = document.getElementById('edit-backdrop'); if (b) b.remove(); } }; //------------------------------------------------------- // Combined Bundles Popup with circle close (ⓧ) //------------------------------------------------------- const openCombinedPopup = () => { const combined = loadCombined(); const keys = Object.keys(combined).sort((a, b) => { const la = (combined[a].label || a).toLowerCase(); const lb = (combined[b].label || b).toLowerCase(); return la.localeCompare(lb); }); if (!keys.length) return alert('No combined bundles exist.'); if (document.getElementById('combined-backdrop')) return; const backdrop = el('div', { id: 'combined-backdrop', style: 'position:fixed;left:0;top:0;width:100%;height:100%;background:rgba(0,0,0,0.4);z-index:999999;' }); document.body.append(backdrop); const popup = el('div', { id: 'combined-popup', style: 'position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);background:#1f1f1f;color:white;width:420px;padding:18px;border-radius:12px;z-index:1000000;max-height:560px;overflow-y:auto;box-shadow:0 6px 24px rgba(0,0,0,0.6);' }); backdrop.append(popup); backdrop.onclick = (e) => { if (e.target === backdrop) close(); }; popup.onclick = (e) => e.stopPropagation(); popup.append(el('div', { textContent: 'Combined Bundles', style: 'font-weight:700;margin-bottom:12px;font-size:15px;' })); // circle close button (22px) const closeBtn = el('div', { html: '❌', style: 'position:absolute;right:12px;top:12px;font-size:22px;cursor:pointer;color:#fff;opacity:0.85;user-select:none;' }); closeBtn.onclick = () => close(); closeBtn.onmouseenter = () => closeBtn.style.opacity = '1'; closeBtn.onmouseleave = () => closeBtn.style.opacity = '0.85'; popup.append(closeBtn); keys.forEach(key => { const combo = combined[key]; const wrap = el('div', { style: 'background:#262626;border-radius:8px;padding:10px;margin-bottom:10px;' }); const name = el('div', { textContent: combo.label, style: 'font-weight:700;font-size:14px;margin-bottom:4px;' }); const src = el('div', { textContent: 'from: ' + combo.sources.join(', '), style: 'color:#bbb;font-size:12px;margin-bottom:8px;' }); const tags = el('div', { textContent: combo.tags.join(' '), style: 'color:#ddd;margin-bottom:8px;word-break:break-word;' }); const btns = el('div', { style: 'display:flex;gap:8px;' }); const mkbtn = (t, bg, fn) => el('button', { textContent: t, onclick: fn, style: `flex:1;padding:8px;border:none;border-radius:6px;background:${bg};color:white;font-weight:700;cursor:pointer;` }); // Insert uses the robust append method const insertBtn = mkbtn('Insert', '#2196F3', () => { const s = combo.tags.join(' ') + ' '; const ok = insertTextAtEnd(s); if (!ok) return alert('Insert failed (composer not found).'); confirmButton(insertBtn, 'Insert', 'Inserted!'); }); const copyBtn = mkbtn('Copy', '#4CAF50', async () => { await navigator.clipboard.writeText(combo.tags.join(' ')).catch(() => alert('Copy error')); confirmButton(copyBtn, 'Copy', 'Copied!'); }); const editBtn = mkbtn('Edit', '#f39c12', () => openEditCombinedModal(key)); const delBtn = mkbtn('Delete', '#c0392b', () => { if (!confirm(`Delete "${combo.label}"?`)) return; const all = loadCombined(); delete all[key]; saveCombined(all); wrap.remove(); }); btns.append(insertBtn, copyBtn, editBtn, delBtn); wrap.append(name, src, tags, btns); popup.append(wrap); }); function close() { const b = document.getElementById('combined-backdrop'); if (b) b.remove(); } }; //------------------------------------------------------- // Auto-show panel when composer present //------------------------------------------------------- let panelVisible = false; const observer = new MutationObserver(() => { const c = document.querySelector('div[contenteditable="true"], textarea'); if (c && !panelVisible) { createPanel(); panelVisible = true; } else if (!c && panelVisible) { const p = document.getElementById(PANEL_ID); if (p) p.remove(); panelVisible = false; } }); observer.observe(document.body, { childList: true, subtree: true }); if (document.querySelector('div[contenteditable="true"], textarea')) { createPanel(); panelVisible = true; } })();