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
})();