NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript== // @name XGroups // @description Allows adding x.com users to groups, storing in localStorage, and displaying group tags below avatars // @namespace https://github.com/artbit/xgroups // @author Djordje Ungar // @copyright 2025+ Djordje Ungar // @license MIT // @version 1.0.87 // @source https://github.com/artbit/xgroups // @homepageURL https://github.com/artbit/xgroups // @supportURL https://github.com/artbit/xgroups/issues // @updateURL https://raw.githubusercontent.com/ArtBIT/xgroups/refs/heads/main/dist/xgroups.meta.js // @downloadURL https://raw.githubusercontent.com/ArtBIT/xgroups/refs/heads/main/dist/xgroups.userscript.js // @match https://x.com/* // @icon https://www.google.com/s2/favicons?sz=64&domain=x.com // @tag productivity // @run-at document-idle // @grant GM_getValue // @grant GM_setValue // @grant GM_xmlhttpRequest // @grant GM_notification // ==/UserScript== // ==OpenUserJS== // @author artbit // ==/OpenUserJS== /*jshint esversion: 9 */ (() => { let c = { tweetSelector: 'article[data-testid="tweet"]', usernameSelector: '[data-testid="User-Name"] :nth-child(2) a[href^="/"][role="link"] span', avatarSelector: 'div[data-testid="Tweet-User-Avatar"]' }, l = (...e) => e.filter(Boolean).join(" "), p = { groups: '<svg viewBox="0 0 24 24" aria-label="XGroups" role="img"><g><path d="M7.501 19.917L7.471 21H.472l.029-1.027c.184-6.618 3.736-8.977 7-8.977.963 0 1.95.212 2.87.672-.444.478-.851 1.03-1.212 1.656-.507-.204-1.054-.329-1.658-.329-2.767 0-4.57 2.223-4.938 6.004H7.56c-.023.302-.05.599-.059.917zm8.999-8.921c-3.264 0-6.816 2.358-7 8.977L9.471 21h4.528v-2h-2.438c.367-3.781 2.17-6.004 4.938-6.004 1.089 0 2.022.356 2.784 1.004h2.632c-1.376-2.136-3.446-3.004-5.415-3.004zm0-.996c-.799 0-1.527-.279-2.116-.73C13.548 8.63 13 7.632 13 6.5 13 4.57 14.567 3 16.5 3S20 4.57 20 6.5c0 1.132-.548 2.13-1.384 2.77-.589.451-1.317.73-2.116.73zM15 6.5c0 .827.673 1.5 1.5 1.5S18 7.327 18 6.5 17.327 5 16.5 5 15 5.673 15 6.5zm-11 0C4 4.57 5.567 3 7.5 3S11 4.57 11 6.5 9.433 10 7.5 10 4 8.43 4 6.5zm2 0C6 7.327 6.673 8 7.5 8S9 7.327 9 6.5 8.327 5 7.5 5 6 5.673 6 6.5zM21 21h3v-2h-3v-3h-2v3h-3v2h3v3h2v-3z"></path></g></svg>' }, d = (() => { let o = "https://api.github.com/gists", e = () => GM_getValue("xgroups_gist_token", ""), a = () => ({ Accept: "application/vnd.github.v3+json", Authorization: "token " + e(), "Content-Type": "application/json" }); return { createGist: async e => new Promise((r, n) => { GM_xmlhttpRequest({ url: o, method: "POST", headers: a(), nocache: !0, data: JSON.stringify({ description: "X.com User Group Tagger Data", public: !1, files: { "xgroups_data.json": { content: JSON.stringify(e, null, 2) } } }), onload: e => { let t; try { t = JSON.parse(e.responseText) } catch (e) { n(e) } 200 !== e.status && n(new Error("Failed to create Gist")); e = t.id; return GM_setValue("xgroups_gist_id", e), r(e) } }) }), readGist: async e => new Promise((r, n) => { GM_xmlhttpRequest({ url: o + "/" + e, headers: a(), nocache: !0, onload: e => { let t; try { t = JSON.parse(e.responseText) } catch (e) { n(e) } e = t.files; r(JSON.parse(e["xgroups_data.json"].content)) } }) }), updateGist: async (n, e) => new Promise((t, r) => { GM_xmlhttpRequest({ url: o + "/" + n, method: "PATCH", headers: a(), nocache: !0, data: JSON.stringify({ files: { "xgroups_data.json": { content: JSON.stringify(e, null, 2) } } }), onload: e => { try { JSON.parse(e.responseText) } catch (e) { r(e) } 200 !== e.status && r(new Error("Failed to update Gist")), t(n) } }) }), getToken: e, getGistId: () => GM_getValue("xgroups_gist_id", "") } })(); class u { constructor(e, t) { this.props = e || {}, this.store = t, this.state = {}, this.element = null, this.unsubscribe = null } setState(e) { this.state = { ...this.state, ...e }, this.update() } setProps(e) { this.props = { ...this.props, ...e }, this.update() } render() { return g({ tag: "div", textContent: "Base Component" }) } mount(e) { this.element = this.render(), e.appendChild(this.element), this.store && (this.unsubscribe = this.store.subscribe(() => this.update())) } update() { var e; this.element && (e = this.render(), h(this.element, e)) } unmount() { this.element && (this.element.remove(), this.element = null), this.unsubscribe && this.unsubscribe() } } let g = ({ tag: e = "div", children: t = [], style: r = {}, on: n = {}, ...o }) => { let a = document.createElement(e); return Object.entries(o).forEach(([e, t]) => { "className" === e ? a.className = t : "textContent" === e ? a.textContent = t : "innerHTML" === e ? a.innerHTML = t : a.setAttribute(e, t) }), Object.entries(r).forEach(([e, t]) => { a.style[e] = t }), Object.entries(n).forEach(([e, t]) => { a.addEventListener(e, t) }), t.forEach(e => { e && ("string" == typeof e ? a.appendChild(document.createTextNode(e)) : e instanceof HTMLElement ? a.appendChild(e) : e instanceof u ? e.mount(a) : a.appendChild(g(e))) }), a }, m = e => { setTimeout(e, 1) }, _ = (e = {}) => { GM_notification({ title: e.title || "XGroups", text: e.text || "This is the notification message.", ...e }), GM_log("Notification: " + e.text) }, h = (n, e) => { if (n || e) { if (!n) return e; if (e) if (n.tagName !== e.tagName || 0 === n.childNodes.length && 0 === e.childNodes.length) n.replaceWith(e); else { if (0 < n.childNodes.length || 0 < e.childNodes.length) { var o = Array.from(n.childNodes), a = Array.from(e.childNodes); for (let e = 0; e < Math.max(o.length, a.length); e++) { var s = h(o[e], a[e]); s && n.appendChild(s) } } var i = Array.from(n.attributes), e = Array.from(e.attributes); let t = Object.fromEntries(i.map(e => [e.name, e.value])), r = Object.fromEntries(e.map(e => [e.name, e.value])); Object.keys(t).forEach(e => { e in r || n.removeAttribute(e) }), Object.keys(r).forEach(e => { t[e] !== r[e] && n.setAttribute(e, r[e]) }) } else n.remove() } }, e = i => { let t = "xgroups_groups", r = "xgroups_user_groups", n = "xgroups_data_timestamp", o = { groups: null, userGroups: null, timestamp: null }, c = () => { try { var e = i.getState().groups; JSON.stringify(e) !== JSON.stringify(o.groups) && (GM_setValue(t, JSON.stringify(e)), s(), o.groups = e) } catch (e) { console.error("Failed to save groups:", e), _({ text: "Unable to save group data." }) } }, a = e => (e || "").replace("@", "").toLowerCase(), s = () => { var e = Date.now(); GM_setValue(n, e.toString()) }; let l = () => ({ groups: i.getState().groups, userGroups: Object.fromEntries(Object.entries(i.getState().userGroups).map(([e, t]) => [e, Array.from(t)])), timestamp: parseInt(GM_getValue(n, "0"), 10) }); let p = () => { var e = Object.fromEntries(Object.entries(i.getState().userGroups).map(([e, t]) => [e, Array.from(t)])); try { JSON.stringify(e) !== JSON.stringify(o.userGroups) && (GM_setValue(r, JSON.stringify(e)), s(), o.userGroups = e) } catch (e) { console.error("Failed to save user groups:", e), _({ text: "Unable to save user group data." }) } }; i.setState({ groups: (() => { try { return (JSON.parse(GM_getValue(t, "[]")) || []).map(e => ({ name: e.name, bgColor: e.bgColor || "#777", fgColor: e.fgColor || "#fff", description: e.description || "" })) } catch (e) { return console.error("Failed to load groups:", e), [] } })(), userGroups: (() => { try { var e = JSON.parse(GM_getValue(r, "{}")) || {}; return Object.fromEntries(Object.entries(e).map(([e, t]) => [e, new Set(t)])) } catch (e) { return console.error("Failed to load user groups:", e), {} } })() }); let u = () => i.getState().groups, g = t => i.getState().groups.find(e => e.name === t); return { getGroups: u, getGroup: g, addGroup: (e, t = "", r = "#777", n = "#fff") => { e = { name: e, description: t, bgColor: r, fgColor: n }; return i.setState({ groups: [...i.getState().groups, e] }), c(), e }, removeGroup: t => { let r = i.getState().userGroups; Object.keys(r).forEach(e => { r[e].delete(t), 0 === r[e].size && delete r[e] }), i.setState({ groups: i.getState().groups.filter(e => e.name !== t), userGroups: { ...r } }), c(), p() }, updateGroup: (r, n, e, t, o) => { if (r !== n && g(n)) _({ text: "Group with this name already exists." }); else { var a = u(), s = a.findIndex(e => e.name === r); if (-1 !== s) { e = { ...a[s], name: n, description: e, bgColor: t, fgColor: o }; if (a[s] = e, i.setState({ groups: a }), c(), r !== n) { let t = i.getState().userGroups; Object.keys(t).forEach(e => { t[e].has(r) && (t[e].delete(r), t[e].add(n)) }), i.setState({ userGroups: { ...t } }), p() } } } }, getUserGroups: e => Array.from(i.getState().userGroups[a(e)] || []), getUserLink: e => "https://x.com/" + a(e), getGroupUsers: t => Object.keys(i.getState().userGroups).filter(e => i.getState().userGroups[e].has(t)), addUserToGroup: (e, t) => { e = a(e); var r = i.getState().userGroups; r[e] = r[e] || new Set, r[e].add(t), i.setState({ userGroups: { ...r } }), p() }, removeUserFromGroup: (e, t) => { e = a(e); var r = i.getState().userGroups; r[e] && (r[e].delete(t), 0 === r[e].size && delete r[e], i.setState({ userGroups: { ...r } }), p()) }, normalizeUsername: a, getLocalData: l, importData: r => { try { let t = JSON.parse(r); if (!t.groups || !t.userGroups) throw new Error("Invalid JSON format"); let e = i.getState().groups, n = i.getState().userGroups; var o = t.groups.filter(t => !e.some(e => e.name === t.name)); return i.setState({ groups: [...e, ...o.map(e => ({ name: e.name, bgColor: e.bgColor || "#777", fgColor: e.fgColor || "#fff", description: e.description || "" }))] }), Object.keys(t.userGroups).forEach(e => { let r = a(e); n[r] || (n[r] = new Set), t.userGroups[e].forEach(t => { i.getState().groups.some(e => e.name === t) && n[r].add(t) }), 0 === n[r].size && delete n[r] }), i.setState({ userGroups: { ...n } }), s(), p(), c(), !0 } catch (e) { return console.error("Failed to import data:", e), _({ text: "Invalid JSON file or format." }), !1 } }, syncWithGist: async () => { if (d.getToken() && d.getGistId()) try { return await (async (e, r = 3, n = 1e3) => { for (let t = 0; t < r; t++) try { return await e() } catch (e) { if (t === r - 1) throw e; await new Promise(e => setTimeout(e, n * 2 ** t)) } })(async () => { var e = l(), t = parseInt(e.timestamp || "0", 10), r = await d.readGist(d.getGistId()), n = parseInt(r.timestamp || "0", 10); if (t < n) i.setState({ groups: r.groups, userGroups: Object.fromEntries(Object.entries(r.userGroups).map(([e, t]) => [e, new Set(t)])) }), c(), p(); else if (n < t) return d.updateGist(d.getGistId(), e) }) } catch (e) { console.error("Failed to sync with Gist:", e) } }, createGist: async () => { if (d.getToken()) try { var e = l(), t = await d.createGist(e); return GM_setValue("xgroups_gist_id", t), id } catch (e) { console.error("Failed to create Gist:", e), _({ text: "Failed to create Gist." }) } else _({ text: "Please set your Gist token in localStorage." }) } } }, x = { xgroups: "_xgroups_cwhct_10", "form-element": "_form-element_cwhct_10", "form-input": "_form-input_cwhct_14", "xgroups-svg-btn": "_xgroups-svg-btn_cwhct_26", "group-tags": "_group-tags_cwhct_33", "group-tag": "_group-tag_cwhct_33", "group-tags-btns": "_group-tags-btns_cwhct_46", "group-item": "_group-item_cwhct_61", "modal-overlay": "_modal-overlay_cwhct_73", "modal-window": "_modal-window_cwhct_85", "modal-header": "_modal-header_cwhct_95", "form-title": "_form-title_cwhct_102", "form-subtitle": "_form-subtitle_cwhct_106", "modal-content": "_modal-content_cwhct_111", active: "_active_cwhct_120", "group-btn": "_group-btn_cwhct_132", "text-btn": "_text-btn_cwhct_145", "form-footer": "_form-footer_cwhct_177", "label-bg": "_label-bg_cwhct_235", placeholder: "_placeholder_cwhct_252", "loading-spinner": "_loading-spinner_cwhct_299", "loading-spinner-inner": "_loading-spinner-inner_cwhct_308", "loading-spinner-cutout": "_loading-spinner-cutout_cwhct_317", hidden: "_hidden_cwhct_335" }; class b extends u { render() { let { options: e, ...t } = this.props; return g({ tag: "select", className: x["form-input"], children: e.map(({ label: e, value: t }) => ({ tag: "option", value: t, textContent: e })), ...t }) } } class f extends u { render() { let { type: e, label: t, name: r, value: n = "", ...o } = this.props; return g({ className: x["form-element"], children: [{ tag: "input", type: e, name: r, value: n, className: x["form-input"], ...o }, t && { tag: "div", className: x["label-bg"], textContent: t }, t && { tag: "label", className: x.placeholder, textContent: t, for: r }] }) } } class w extends u { render() { let { svg: e, label: t = "", ariaLabel: r, ...n } = this.props; return g({ ...n, tag: "div", role: "button", className: x["xgroups-svg-btn"], "aria-label": r || t, children: [{ tag: "div", innerHTML: e }, t && { tag: "span", textContent: t }] }) } } class y extends u { render() { var e = this.store.getState().loading; return g({ ...this.props, tag: "div", className: l(x["loading-spinner"], !e && x.hidden), children: [{ tag: "div", className: x["loading-spinner-inner"] }, { tag: "div", className: x["loading-spinner-cutout"] }] }) } } class v extends u { render() { let { title: e, subtitle: t, template: r, onClose: n, active: o } = this.props; return g({ tag: "div", className: l(x.xgroups, x["modal-overlay"], o && x.active), on: { keydown: e => "Escape" === e.key && n() }, children: [{ tag: "div", className: l(x["modal-window"]), children: [{ tag: "div", className: x.xgroups, role: "dialog", "aria-modal": "true", style: { position: "relative" }, children: [{ tag: "div", className: x["modal-header"], children: [{ tag: "h2", className: x["form-title"], textContent: e }, t && "string" == typeof t && { tag: "h3", className: x["form-subtitle"], textContent: t }, t && t instanceof HTMLElement && { tag: "div", className: x["form-subtitle"], children: [t] }] }, { tag: "div", className: x["modal-content"], children: [null == r ? void 0 : r(this.props, this.store)] }, { tag: "button", textContent: "✖️", "aria-label": "Close modal", style: { position: "absolute", top: "10px", right: "10px" }, on: { click: n } }] }] }] }) } mount(e) { super.mount(e), this.props.active || m(() => { this.setProps({ active: !0 }) }), this.element.focus() } unmount() { this.element && (this.setProps({ active: !1 }), setTimeout(super.unmount.bind(this), 200)) } } let t = (s, n) => { let i = (n => { let o = new Map; let a = () => { var e = n.getState().modalsStack; e.length && e.pop().unmount(), n.setState({ modalsStack: e }) }; return n.subscribe(t => { t = t.modalsStack; if (t.length) { let e = t[t.length - 1]; e.props.active || m(() => { e.setProps({ active: !0 }) }) } }), { register: (e, t) => { o.set(e, t) }, open: (e, t = {}) => { var r = o.get(e); if (!r) throw new Error(`Modal ${e} not registered`); t = new v({ ...t, modalName: e, template: r, onClose: () => a() }, n), e = n.getState().modalsStack || []; e.forEach(e => { e.setProps({ active: !1 }) }), e.push(t), t.mount(document.body), n.setState({ modalsStack: e }) }, close: a } })(n), t = !0; var e = g({ tag: "style", id: "xgroups-styles", textContent: ':root {\n --xgroups-background-primary: #0f1419;\n --xgroups-background-secondary: #30383f;\n --xgroups-typography-primary: #eee;\n --xgroups-typography-secondary: #ccc;\n --xgroups-typography-inverted: #000;\n --xgroups-ui-primary: #eff3f4;\n --xgroups-border-radius: 24px;\n}\n._xgroups_cwhct_10 ._form-element_cwhct_10 {\n position: relative;\n margin-top: 28px;\n}\n._xgroups_cwhct_10 ._form-input_cwhct_14 {\n background: var(--xgroups-background-secondary);\n color: var(--xgroups-typography-primary);\n border: 0;\n border-radius: var(--xgroups-border-radius);\n padding: 4px 20px;\n min-height: 50px;\n width: 100%;\n}\n._xgroups_cwhct_10 ._group-circle-btn_cwhct_23:hover {\n background: var(--xgroups-background-secondary);\n}\n._xgroups-svg-btn_cwhct_26 {\n fill: var(--xgroups-typography-secondary);\n transition: fill 200ms;\n}\n._xgroups-svg-btn_cwhct_26:hover {\n fill: var(--xgroups-typography-primary);\n}\n._xgroups_cwhct_10._group-tags_cwhct_33 {\n margin-top: 5px;\n display: flex;\n flex-direction: column;\n align-items: center;\n}\n._xgroups_cwhct_10 ._group-tag_cwhct_33 {\n border-radius: 4px;\n padding: 2px 6px;\n margin-bottom: 2px;\n font-size: 16px;\n cursor: pointer;\n}\n._xgroups_cwhct_10 ._group-tags-btns_cwhct_46 {\n gap: 5px;\n /* add gradient fallof from the top */\n background: linear-gradient(\n to bottom,\n var(--xgroups-background-primary) 0%,\n rgba(0, 0, 0, 0) 100%\n );\n padding-top: 5px;\n margin-top: 5px;\n visibility: hidden;\n}\narticle[data-testid="tweet"]:hover ._group-tags-btns_cwhct_46 {\n visibility: visible;\n}\n._xgroups_cwhct_10 ._group-item_cwhct_61 {\n display: flex;\n gap: 8px;\n justify-content: space-between;\n padding: 0 8px;\n}\n._xgroups_cwhct_10 ._group-item_cwhct_61:hover {\n background: var(--xgroups-background-secondary);\n}\n\n/* MODALS */\n\n._xgroups_cwhct_10._modal-overlay_cwhct_73 {\n position: fixed;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n background: rgba(0, 0, 0, 0.8);\n z-index: 1002;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n._xgroups_cwhct_10 ._modal-window_cwhct_85 {\n color: var(--xgroups-typography-primary);\n background: var(--xgroups-background-primary);\n border: none;\n padding: 15px;\n max-width: 400px;\n width: 100%;\n box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);\n border-radius: var(--xgroups-border-radius);\n}\n._xgroups_cwhct_10 ._modal-header_cwhct_95 {\n display: flex;\n justify-content: space-between;\n align-items: flex-start;\n margin-bottom: 10px;\n flex-direction: column;\n}\n._xgroups_cwhct_10 ._modal-header_cwhct_95 ._form-title_cwhct_102 {\n font-size: 20px;\n margin: 0;\n}\n._xgroups_cwhct_10 ._modal-header_cwhct_95 ._form-subtitle_cwhct_106 {\n font-size: 14px;\n color: #aaa;\n margin: 0;\n}\n._xgroups_cwhct_10 ._modal-content_cwhct_111 {\n display: flex;\n flex-direction: column;\n gap: 10px;\n}\n._xgroups_cwhct_10._modal-overlay_cwhct_73 {\n opacity: 0;\n transition: opacity 200ms ease-in-out;\n}\n._xgroups_cwhct_10._modal-overlay_cwhct_73._active_cwhct_120 {\n opacity: 1;\n}\n._xgroups_cwhct_10._modal-overlay_cwhct_73._active_cwhct_120 ._modal-window_cwhct_85 {\n transform: scale(1);\n}\n._xgroups_cwhct_10 ._modal-window_cwhct_85 {\n transform: scale(0.8);\n transition: transform 200ms ease-in-out;\n}\n\n._xgroups_cwhct_10 input[type="submit"],\n._xgroups_cwhct_10 ._group-btn_cwhct_132 {\n background-color: var(--xgroups-ui-primary);\n color: var(--xgroups-typography-inverted);\n border-radius: 12px;\n border: 0;\n box-sizing: border-box;\n cursor: pointer;\n font-size: 14px;\n margin: 4px;\n padding: 8px;\n outline: 0;\n text-align: center;\n}\n._xgroups_cwhct_10 ._text-btn_cwhct_145 {\n background-color: none;\n color: var(--xgroups-typography-secondary);\n border-radius: 12px;\n border: 0;\n box-sizing: border-box;\n cursor: pointer;\n font-size: 14px;\n margin: 4px;\n padding: 4px;\n outline: 0;\n text-align: center;\n}\n._xgroups_cwhct_10 ._text-btn_cwhct_145:hover {\n color: var(--xgroups-typography-primary);\n text-decoration: underline;\n}\n._xgroups_cwhct_10 form {\n padding: 0px;\n display: flex;\n flex-direction: column;\n gap: 16px;\n background-color: var(--xgroups-background-primary);\n border-radius: var(--xgroups-border-radius);\n box-sizing: border-box;\n}\n._xgroups_cwhct_10 ._form-title_cwhct_102 {\n color: var(--xgroups-typography-primary);\n font-family: sans-serif;\n font-size: 24px;\n font-weight: 600;\n}\n._xgroups_cwhct_10 ._form-footer_cwhct_177 {\n color: var(--xgroups-typography-secondary);\n font-family: sans-serif;\n font-size: 14px;\n font-weight: 400;\n margin-top: 10px;\n}\n._xgroups_cwhct_10 select {\n padding: 12px;\n border-radius: var(--xgroups-border-radius);\n background: var(--xgroups-background-secondary);\n}\n._xgroups_cwhct_10 ._form-subtitle_cwhct_106 {\n color: var(--xgroups-typography-primary);\n font-family: sans-serif;\n font-size: 16px;\n font-weight: 600;\n margin-top: 10px;\n}\n._xgroups_cwhct_10 ._form-element_cwhct_10 {\n min-height: 50px;\n height: auto;\n position: relative;\n width: 100%;\n margin-top: 28px;\n}\n._xgroups_cwhct_10 ._divider_cwhct_203 {\n width: 100%;\n height: 1px;\n background-color: var(--xgroups-background-secondary);\n margin: 20px 0;\n}\n._xgroups_cwhct_10 ._form-input_cwhct_14,\n._xgroups_cwhct_10 input {\n color: var(--xgroups-typography-primary);\n background-color: var(--xgroups-background-secondary);\n border-radius: var(--xgroups-border-radius);\n border: 0;\n box-sizing: border-box;\n font-size: 18px;\n min-height: 50px;\n height: 100%;\n outline: 0;\n padding: 4px 20px;\n width: 100%;\n}\n._xgroups_cwhct_10 input[type="color"] {\n -webkit-appearance: none;\n width: 100%;\n height: 50px;\n}\n._xgroups_cwhct_10 input[type="color"],\n._xgroups_cwhct_10 input[type="color"]::-webkit-color-swatch,\n._xgroups_cwhct_10 input[type="color"]::-webkit-color-swatch-wrapper {\n border: none;\n border-radius: var(--xgroups-border-radius);\n padding: 0;\n}\n._xgroups_cwhct_10 ._label-bg_cwhct_235 {\n background-color: var(--xgroups-background-primary);\n border-radius: 10px;\n padding: 4px 10px;\n height: 20px;\n position: absolute;\n top: -28px;\n left: 20px;\n transform: translateY(0);\n transition: transform 200ms;\n color: transparent;\n}\n._xgroups_cwhct_10 ._form-input_cwhct_14._focus_cwhct_247 ~ ._label-bg_cwhct_235,\n._xgroups_cwhct_10 input:focus ~ ._label-bg_cwhct_235,\n._xgroups_cwhct_10 input:not(:placeholder-shown) ~ ._label-bg_cwhct_235 {\n transform: translateY(14px);\n}\n._xgroups_cwhct_10 ._placeholder_cwhct_252 {\n color: var(--xgroups-typography-secondary);\n position: absolute;\n top: 20px;\n left: 20px;\n pointer-events: none;\n transform-origin: 0 50%;\n transition:\n transform 200ms,\n color 200ms;\n transform: scale(1.2);\n}\n._xgroups_cwhct_10 ._label-bg_cwhct_235,\n._xgroups_cwhct_10 ._placeholder_cwhct_252 {\n font-family: sans-serif;\n font-size: 14px;\n line-height: 14px;\n}\n._xgroups_cwhct_10 ._form-input_cwhct_14._focus_cwhct_247 ~ ._placeholder_cwhct_252,\n._xgroups_cwhct_10 ._form-input_cwhct_14 ._placeholder-shown_cwhct_271,\n._xgroups_cwhct_10 input:focus ~ ._placeholder_cwhct_252,\n._xgroups_cwhct_10 input:not(:placeholder-shown) ~ ._placeholder_cwhct_252 {\n transform: translateY(-34px) translateX(9px) scale(1);\n}\n._xgroups_cwhct_10 input:not(:placeholder-shown) ~ ._placeholder_cwhct_252 {\n color: var(--xgroups-typography-secondary);\n}\n._xgroups_cwhct_10 input:focus ~ ._placeholder_cwhct_252 {\n color: var(--xgroups-typography-tertiary);\n}\n._xgroups_cwhct_10 ._submit_cwhct_282 {\n background-color: var(--xgroups-ui-primary);\n color: var(--xgroups-typography-primary);\n border-radius: 12px;\n border: 0;\n box-sizing: border-box;\n cursor: pointer;\n font-size: 18px;\n height: 50px;\n margin-top: 38px;\n outline: 0;\n text-align: center;\n width: 100%;\n}\n._xgroups_cwhct_10 ._submit_cwhct_282:active {\n background-color: var(--xgroups-ui-secondary);\n}\n._xgroups_cwhct_10 ._loading-spinner_cwhct_299 {\n position: relative;\n display: inline-block;\n top: 6px;\n width: 24px;\n height: 24px;\n margin: 0 auto;\n animation: _spin_cwhct_1 1s linear infinite;\n}\n._xgroups_cwhct_10 ._loading-spinner-inner_cwhct_308 {\n position: absolute;\n width: 50%;\n height: 50%;\n top: 0;\n left: 0;\n background-color: var(--xgroups-ui-primary);\n border-radius: 100% 0 0 0;\n}\n._xgroups_cwhct_10 ._loading-spinner-cutout_cwhct_317 {\n width: 50%;\n height: 50%;\n background-color: var(--xgroups-background-primary);\n position: absolute;\n top: 25%;\n left: 25%;\n border-radius: 50%;\n pointer-events: none;\n}\n@keyframes _spin_cwhct_1 {\n 0% {\n transform: rotate(0deg);\n }\n 100% {\n transform: rotate(360deg);\n }\n}\n._xgroups_cwhct_10 ._hidden_cwhct_335 {\n display: none;\n}\n' }); document.head.appendChild(e), i.register("groupManager", (e, t) => g({ tag: "div", className: x.xgroups, children: [{ style: { display: "flex", alignItems: "flex-start" }, children: [{ tag: "button", title: "Import groups from JSON", textContent: "Import...", className: x["text-btn"], on: { click: () => { i.open("importData", { title: "Import Data", subtitle: "Paste your JSON data" }) } } }, { tag: "button", title: "Export groups to JSON", textContent: "Export", className: x["text-btn"], on: { click: () => { var e = JSON.stringify(s.getLocalData()), e = new Blob([e], { type: "application/json" }), e = URL.createObjectURL(e), t = g({ tag: "a", href: e, download: "xgroups.json", style: { display: "none" } }); document.body.appendChild(t), t.click(), document.body.removeChild(t), URL.revokeObjectURL(e) } } }, { tag: "button", title: "Sync with Gist", textContent: "Sync...", className: x["text-btn"], on: { click: () => { i.open("gistSettings", { title: "Gist Settings" }) } } }] }, { tag: "div", style: { overflowY: "auto", maxHeight: "300px" }, children: [...t.getState().groups.map(e => ({ tag: "div", className: x["group-item"], children: [{ tag: "button", textContent: `${e.name} (${s.getGroupUsers(e.name).length})`, title: e.description, on: { click: () => { i.open("groupUsers", { groupName: e.name, title: "Users in Group: " + e.name, subtitle: g({ tag: "button", textContent: "Edit group...", className: x["text-btn"], on: { click: () => i.open("editGroup", { group: e, title: "Edit Group" }) } }) }) } } }, { tag: "div", style: { display: "inline-flex", gap: "5px" }, children: [{ tag: "button", textContent: "Edit", className: x["text-btn"], on: { click: () => { i.open("editGroup", { group: e, title: "Edit Group" }) } } }, { tag: "button", textContent: "Remove", className: x["text-btn"], title: "Remove group", on: { click: () => { 0 < s.getGroupUsers(e.name).length && !confirm(`Are you sure you want to remove the group "${e.name}"?`) || s.removeGroup(e.name) } } }] }] }))] }, { tag: "button", textContent: "Add Group", className: x["group-btn"], on: { click: () => { i.open("addGroup", { title: "Add Group" }) } } }] })), i.register("gistSettings", (e, t) => g({ tag: "form", className: x.xgroups, on: { submit: async e => { e.preventDefault(); var t = e.target.elements.token.value, r = e.target.elements.gistId.value; if ("cancel" === e.submitter.name) i.close(); else if (t) { if (GM_setValue("xgroups_gist_token", t), r) GM_setValue("xgroups_gist_id", r); else try { var n = await s.createGist(); n && _({ text: "Gist created: https://gist.github.com/" + n, url: "https://gist.github.com/" + n }) } catch (e) { return console.error("Failed to create Gist:", e), void _({ text: "Failed to create Gist. Check your token." }) } await s.syncWithGist(), i.close() } else _({ text: "Please enter a GitHub Personal Access Token." }) } }, children: [new f({ type: "text", label: "GitHub Personal Access Token", name: "token", value: GM_getValue("xgroups_gist_token", "") }, t).render(), new f({ type: "text", label: "Gist ID (leave blank to create new)", name: "gistId", value: GM_getValue("xgroups_gist_id", "") }, t).render(), { tag: "p", children: [{ tag: "a", textContent: "Generate new Personal Access Token?", href: "https://github.com/settings/personal-access-tokens/new", target: "_blank", style: { color: "var(--xgroups-ui-primary)", textDecoration: "underline" } }, { tag: "span", textContent: " (only allow 'gist' scope)" }] }, g({ className: x["form-footer"], children: [{ tag: "button", type: "submit", name: "cancel", className: x["group-btn"], textContent: "Cancel" }, { tag: "button", type: "submit", name: "save", className: x["group-btn"], textContent: "Save" }, { tag: "button", type: "button", name: "sync", className: x["group-btn"], textContent: "Sync Now", on: { click: async () => { t.setState({ loading: !0 }); var e = await s.syncWithGist(); t.setState({ loading: !1 }), e && (_({ text: "Gist updated: https://gist.github.com/" + e, url: "https://gist.github.com/" + e }), i.close()) } } }, { tag: "span", children: [new y({}, t)] }] })] })), i.register("importData", (e, t) => g({ tag: "form", className: x.xgroups, on: { submit: e => { e.preventDefault(); e = e.target.elements.jsonData.value; s.importData(e) ? i.close() : _({ text: "Invalid JSON data." }) } }, children: [{ tag: "textarea", className: x["form-input"], placeholder: "Paste your JSON data here", rows: 20, on: { input: e => { e = e.target.value; s.importData(e) && i.close() } } }, { tag: "button", textContent: "Import", className: x["group-btn"], type: "submit" }] })), i.register("editGroup", ({ group: a }, e) => g({ tag: "form", className: x.xgroups, on: { submit: e => { var t, r, n, o; e.preventDefault(), "cancel" === e.submitter.name ? i.close() : (t = e.target.elements.name.value, r = e.target.elements.description.value, n = e.target.elements.bgColor.value, e = e.target.elements.fgColor.value, t ? (o = a.name, s.updateGroup(o, t, r, n, e), i.close()) : _({ text: "Please enter a group name." })) } }, children: [new f({ type: "text", label: "Group Name", name: "name", value: a.name }, e).render(), new f({ type: "text", label: "Description", name: "description", value: a.description }, e).render(), new f({ type: "color", label: "Background Color", name: "bgColor", value: a.bgColor }, e).render(), new f({ type: "color", label: "Text Color", name: "fgColor", value: a.fgColor }, e).render(), g({ className: x["form-footer"], children: [{ tag: "button", type: "submit", name: "cancel", className: x["group-btn"], textContent: "Cancel" }, { tag: "button", type: "submit", name: "save", className: x["group-btn"], textContent: "Save Changes" }] })] })), i.register("addGroup", (e, t) => g({ tag: "form", className: x.xgroups, on: { submit: e => { e.preventDefault(); var t = e.target.elements.name.value, r = e.target.elements.description.value, n = e.target.elements.bgColor.value, e = e.target.elements.fgColor.value; t ? (s.addGroup(t, r, n, e), i.close()) : _({ text: "Please enter a group name." }) } }, children: [new f({ type: "text", label: "Group Name", name: "name" }, t).render(), new f({ type: "text", label: "Description", name: "description" }, t).render(), new f({ type: "color", label: "Background Color", name: "bgColor", value: "#777777" }, t).render(), new f({ type: "color", label: "Text Color", name: "fgColor", value: "#ffffff" }, t).render(), g({ className: x["form-footer"], children: [{ tag: "button", type: "submit", name: "cancel", className: x["group-btn"], textContent: "Cancel" }, { tag: "button", type: "submit", name: "addGroup", className: x["group-btn"], textContent: "Add Group" }] })] })), i.register("groupUsers", (t, e) => g({ tag: "div", className: x.xgroups, children: [{ tag: "div", className: x["group-users-list"], style: { overflowY: "auto", maxHeight: "300px" }, children: s.getGroupUsers(t.groupName).map(e => ({ tag: "div", className: x["group-item"], children: [{ tag: "a", textContent: "@" + e, href: s.getUserLink(e), target: "_blank" }, { tag: "button", textContent: "Remove", className: x["text-btn"], title: "Remove user from group", on: { click: () => { s.removeUserFromGroup(e, t.groupName) } } }] })) }, { className: x["form-footer"], children: [{ tag: "button", type: "submit", name: "close", className: x["group-btn"], textContent: "Close", on: { click: () => { i.close() } } }] }] })), i.register("assignUser", ({ username: r }, e) => g({ tag: "form", className: x.xgroups, on: { submit: e => { e.preventDefault(); var t = e.target.elements.group.value; "cancel" === e.submitter.name ? i.close() : "NEW" === t ? (e = prompt("Enter new group name:")) && (s.addGroup(e), s.addUserToGroup(r, e), i.close()) : t ? (s.addUserToGroup(r, t), i.close()) : _({ text: "Please select a group." }) } }, children: [new f({ type: "text", label: "Assign User to Group", name: "username", value: r, readOnly: !0, style: { cursor: "not-allowed" } }, e).render(), new b({ name: "group", options: [...e.getState().groups.map(e => ({ label: e.name + " " + e.description, value: e.name })), { label: "Create New", value: "NEW" }], on: { change: e => { "NEW" === e.target.value && i.open("addGroup", { title: "Add New Group" }) } } }, e).render(), g({ className: x["form-footer"], children: [{ tag: "button", type: "submit", name: "cancel", className: x["group-btn"], textContent: "Cancel" }, { tag: "button", type: "submit", name: "assign", className: x["group-btn"], textContent: "Assign" }] })] })), i.register("removeUser", ({ username: r }, n) => g({ tag: "form", className: x.xgroups, on: { submit: e => { e.preventDefault(); var t = e.target.elements.group.value; "cancel" === e.submitter.name ? i.close() : t ? (s.removeUserFromGroup(r, t), i.close()) : _({ text: "Please select a group." }) } }, children: [new f({ type: "text", label: "Remove User from Group", name: "username", value: r, readOnly: !0, style: { cursor: "not-allowed" } }, n).render(), new b({ name: "group", options: s.getUserGroups(r).map(t => { var e = n.getState().groups.find(e => e.name === t); return { label: e.name + " " + e.description, value: e.name } }) }, n).render(), g({ className: x["form-footer"], children: [{ tag: "button", type: "submit", name: "cancel", className: x["group-btn"], textContent: "Cancel" }, { tag: "button", type: "submit", name: "remove", title: "Remove user from group", className: x["group-btn"], textContent: "Remove" }] })] })); class o extends u { render() { let e = this.props.username; var t = s.getUserGroups(e); return g({ className: l(x.xgroups, x["group-tags"]), children: [...t.map(t => { let e = this.store.getState().groups.find(e => e.name === t); return g({ tag: "span", className: x["group-tag"], textContent: e.name, title: e.description, style: { background: e.bgColor, color: e.fgColor }, on: { click: () => i.open("groupUsers", { groupName: e.name, title: "Users in Group: " + e.name, subtitle: g({ tag: "button", textContent: "Edit group...", className: x["text-btn"], on: { click: () => i.open("editGroup", { group: e, title: "Edit Group" }) } }) }) } }) }), { className: x["group-tags-btns"], children: [{ tag: "button", textContent: "+", textContent: "➕", title: "Add user to group", "aria-label": "Add user to group", on: { click: () => i.open("assignUser", { username: e, title: "Add to Group" }) } }, { tag: "button", textContent: "-", textContent: "➖", title: "Remove user from group", "aria-label": "- Remove user from group", on: { click: () => i.open("removeUser", { username: e, title: "Remove User from Group" }) } }] }] }) } } let r = e => { var t, r; e && e.querySelector && (t = e.querySelector(c.usernameSelector) || e.querySelector(c.usernameLinkSelector)) && (t = s.normalizeUsername(t.textContent), e = e.querySelector(c.avatarSelector)) && ((r = e.querySelector("." + x["group-tags"])) && r.remove(), new o({ username: t }, n).mount(e)) }; new w({ title: "Manage XGroups", svg: p.groups, style: { position: "fixed", top: "10px", right: "10px", width: "24px", height: "24px", zIndex: 1001 }, on: { click: () => i.open("groupManager", { title: "Manage Groups" }) } }, n).mount(document.body), s.syncWithGist(); let a = ((t, r) => { let n; return (...e) => { clearTimeout(n), n = setTimeout(() => t(...e), r) } })(() => { t = !1, console.log("Updating group tags..."), document.querySelectorAll(c.tweetSelector).forEach(r), setTimeout(() => { t = !0 }, 1) }, 100); return a(), new MutationObserver(e => { t && e.forEach(e => { Array.from(e.addedNodes).forEach(r) }) }).observe(document.body, { childList: !0, subtree: !0 }), n.subscribe(e => { (e.groups || e.userGroups) && a() }), { updateGroupTags: a } }; class r { constructor() { this.store = (e => { let t = { ...e }, r = new Set; return { getState: () => t, setState: e => { t = { ...t, ...e }, r.forEach(e => e(t)) }, subscribe: e => (r.add(e), () => r.delete(e)) } })({ groups: [], userGroups: {}, modalsStack: [], loading: !1 }), this.dataAPI = e(this.store), this.uiManager = t(this.dataAPI, this.store) } } new r })();