// ==UserScript== // @name Hashtag Bundler for X/Twitter and Bluesky // @namespace https://codymkw.nekoweb.org/ // @version 2.6 // @description Hashtag Bundler v2.6 — Combined bundles with auto names, checkbox-based editor, collapsible UI, export/import, and 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'; //------------------------------------------------------- // Setup Host-Matching //------------------------------------------------------- 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 = () => JSON.parse(GM_getValue(STORAGE_KEY, "{}") || "{}"); const saveData = (v) => GM_setValue(STORAGE_KEY, JSON.stringify(v)); const loadCombined = () => JSON.parse(GM_getValue(COMBINED_KEY, "{}") || "{}"); const saveCombined = (v) => GM_setValue(COMBINED_KEY, JSON.stringify(v)); const loadCollapsed = () => JSON.parse(GM_getValue(COLLAPSED_KEY, "false")); const saveCollapsed = (v) => GM_setValue(COLLAPSED_KEY, JSON.stringify(Boolean(v))); //------------------------------------------------------- // Helper to Create Elements //------------------------------------------------------- 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 initials = sources .map(n => n.split(/[\W_]+/).filter(Boolean) .map(w => w[0].toUpperCase()).join("")) .join("_"); const safe = sources.map(s => s.replace(/[^\w\-]/g, "_").replace(/_+/g, "_") ); const ts = Date.now(); return { key: `Combined_${safe.join("_")}_${ts}`, label: `Combined_${initials}_${String(ts).slice(-5)}` }; }; //------------------------------------------------------- // Confirmation Button Helper — text-only (Option D) //------------------------------------------------------- const confirmButton = (btn, originalText, confirmText) => { btn.textContent = confirmText; setTimeout(() => btn.textContent = originalText, 3000); }; //------------------------------------------------------- // Create the Hashtag Panel //------------------------------------------------------- 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; ` }); // Header ---------------------------------------------------------- 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); // Container -------------------------------------------------------- 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); // Handle collapse persistence 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 ? "▲" : "▼"; }; renderPanel(); //------------------------------------------------------- // Render Bundles inside Panel //------------------------------------------------------- function renderPanel() { container.innerHTML = ""; const data = loadData(); // NEW BUNDLE BUTTON -------------------------------------------------- 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 (space separated):"); if (!tags) return; data[name] = tags.split(/\s+/) .filter(Boolean) .map(t => t.startsWith("#") ? t : "#" + t); saveData(data); renderPanel(); }; 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 wrap = 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; ` }); exportBtn.onclick = () => { const blob = new Blob([JSON.stringify({ bundles: loadData(), combined: loadCombined() }, null, 2)], { type: "application/json" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = STORAGE_KEY + ".json"; a.click(); }; 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; ` }); importBtn.onclick = () => { const f = document.createElement("input"); f.type = "file"; f.accept = "application/json"; f.onchange = e => { const file = e.target.files[0]; if (!file) return; const r = new FileReader(); r.onload = ev => { try { const j = JSON.parse(ev.target.result); saveData({ ...loadData(), ...(j.bundles || {}) }); saveCombined({ ...loadCombined(), ...(j.combined || {}) }); renderPanel(); alert("Import complete."); } catch { alert("Invalid JSON."); } }; r.readAsText(file); }; f.click(); }; wrap.append(exportBtn, importBtn); container.append(wrap); // LIST BUNDLES ------------------------------------------------------- const names = Object.keys(data).sort(); names.forEach(name => { const wrap = el("div", { style: ` background:#262626;border-radius:8px;padding:8px; margin-bottom:8px; ` }); const row = el("div", { style: ` display:flex;justify-content:space-between;align-items:center; cursor:pointer; ` }); const title = 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(title, arrow); wrap.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 tags = el("div", { textContent: data[name].join(" "), style: "word-break:break-word;margin-bottom:8px;" }); const btns = el("div", { style: "display:flex;gap:8px;" }); const mkbtn = (t, bg, fn) => el("button", { textContent: t, style: ` flex:1;padding:6px;border:none;border-radius:6px; background:${bg};color:white;font-weight:700;cursor:pointer; font-size:12px; `, onclick: fn }); // Insert const insertBtn = mkbtn("Insert", "#2196F3", () => { const c = document.querySelector("textarea, div[contenteditable='true']"); if (!c) return alert("Composer not found."); const s = data[name].join(" ") + " "; if (c.tagName === "TEXTAREA") c.value += s; else document.execCommand("insertText", false, s); confirmButton(insertBtn, "Insert", "Inserted!"); }); // Copy const copyBtn = mkbtn("Copy", "#4CAF50", async () => { await navigator.clipboard.writeText(data[name].join(" ")); confirmButton(copyBtn, "Copy", "Copied!"); }); // Edit const editBtn = mkbtn("Edit", "#555", () => { const tags2 = prompt("Edit hashtags:", data[name].join(" ")); if (!tags2) return; data[name] = tags2.split(/\s+/) .filter(Boolean) .map(t => t.startsWith("#") ? t : "#" + t); saveData(data); renderPanel(); }); // Delete const delBtn = mkbtn("Delete", "#c0392b", () => { if (!confirm(`Delete "${name}"?`)) return; delete data[name]; saveData(data); renderPanel(); }); btns.append(insertBtn, copyBtn, editBtn, delBtn); inner.append(tags, btns); wrap.append(inner); row.onclick = () => { const open = inner.style.display === "none"; inner.style.display = open ? "block" : "none"; arrow.style.transform = open ? "rotate(90deg)" : ""; }; container.append(wrap); }); } }; //------------------------------------------------------- // Create Combined Bundle (checkbox modal) //------------------------------------------------------- const openCreateCombinedModal = () => { const data = loadData(); const names = Object.keys(data).sort(); if (!names.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;" })); let order = []; const list = el("div", { style: ` display:flex;flex-direction:column;gap:6px; margin-bottom:10px;max-height:220px;overflow:auto; ` }); names.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;" }); 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("Combined bundle created."); 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); function close() { backdrop.remove(); } }; //------------------------------------------------------- // Edit Combined Bundle (checkbox modal) //------------------------------------------------------- const openEditCombinedModal = (key) => { const combined = loadCombined(); const data = loadData(); if (!combined[key]) return alert("Not found."); const combo = combined[key]; const names = Object.keys(data).sort(); if (!names.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; ` }); names.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;" }); 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() { backdrop.remove(); } }; //------------------------------------------------------- // Combined Bundles Popup (with ⓧ close button) //------------------------------------------------------- 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(); // Title popup.append(el("div", { textContent: "Combined Bundles", style: "font-weight:700;margin-bottom:12px;font-size:15px;" })); // CIRCLE CLOSE BUTTON (X4) const closeBtn = el("div", { html: "❌", // red circle X symbol 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); // Content 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 const insertBtn = mkbtn("Insert", "#2196F3", () => { const c = document.querySelector("textarea, div[contenteditable='true']"); if (!c) return alert("Composer not found."); const s = combo.tags.join(" ") + " "; if (c.tagName === "TEXTAREA") c.value += s; else document.execCommand("insertText", false, s); confirmButton(insertBtn, "Insert", "Inserted!"); }); // Copy const copyBtn = mkbtn("Copy", "#4CAF50", async () => { await navigator.clipboard.writeText(combo.tags.join(" ")); confirmButton(copyBtn, "Copy", "Copied!"); }); const editBtn = mkbtn("Edit", "#f39c12", () => openEditCombinedModal(key)); const delBtn = mkbtn("Delete", "#c0392b", () => { if (!confirm("Delete this combined bundle?")) 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() { backdrop.remove(); } }; //------------------------------------------------------- // Observe for composer — auto show panel //------------------------------------------------------- let panelVisible = false; const observer = new MutationObserver(() => { const c = document.querySelector("textarea, div[contenteditable='true']"); 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("textarea, div[contenteditable='true']")) { createPanel(); panelVisible = true; } })();