|
// ==UserScript== |
|
// @name Hover Cards - Roblox |
|
// @description Display a popup with information when hovering over user or group links. |
|
// @namespace gg.cxm.apps.roblox.hover-cards |
|
// @author cxmeel (https://cxm.gg) |
|
// @version 0.7.1 |
|
// |
|
// @icon https://icons.duckduckgo.com/ip3/roblox.com.ico |
|
// @downloadURL https://gist.github.com/cxmeel/44f8fde9f4d189e8bf06718660bb9bab/raw/roblox-hovercards.user.js |
|
// @updateURL https://gist.github.com/cxmeel/44f8fde9f4d189e8bf06718660bb9bab/raw/roblox-hovercards.user.js |
|
// @supportURL https://cxm.gg |
|
// @homepageURL https://cxm.gg |
|
// |
|
// @match https://www.roblox.com/* |
|
// @match https://web.roblox.com/* |
|
// |
|
// @require https://gist.github.com/cxmeel/d4f96eaac2de81f4b2821a495a0635cd/raw/ca70e07fbe4206b502a8708cf3c7cd155d9fc601/refetch.util.user.js |
|
// @require https://gist.github.com/cxmeel/ca26412961e5d0b991ecd3f27c1a3f21/raw/dba5a0d333975b594cb055ad550d0319aa6f59c3/create.user.js |
|
// @require https://unpkg.com/@popperjs/core@2 |
|
// |
|
// @resource PREMIUM_ICON data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTAgMkMwIDAuODk1NDMxIDAuODk1NDMxIDAgMiAwVjBIMTRWMEMxNS4xMDQ2IDAgMTYgMC44OTU0MzEgMTYgMlYyVjE0VjE0QzE2IDE1LjEwNDYgMTUuMTA0NiAxNiAxNCAxNlYxNkg4VjE0SDE0VjJIMlYxNkgwVjJWMloiIGZpbGw9ImN1cnJlbnRDb2xvciIvPgo8cGF0aCBkPSJNNiAxNlY2SDEwVjEwSDhWMTJIMTJWNEg0VjE2SDZaIiBmaWxsPSJjdXJyZW50Q29sb3IiLz4KPC9zdmc+ |
|
// @resource VERIFIED_ICON data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScyOCcgaGVpZ2h0PScyOCcgdmlld0JveD0nMCAwIDI4IDI4JyBmaWxsPSdub25lJz48ZyBjbGlwLXBhdGg9J3VybCgjY2xpcDBfOF80NiknPjxyZWN0IHg9JzUuODg4MTgnIHdpZHRoPScyMi44OScgaGVpZ2h0PScyMi44OScgdHJhbnNmb3JtPSdyb3RhdGUoMTUgNS44ODgxOCAwKScgZmlsbD0nIzAwNjZGRicvPjxwYXRoIGZpbGwtcnVsZT0nZXZlbm9kZCcgY2xpcC1ydWxlPSdldmVub2RkJyBkPSdNMjAuNTQzIDguNzUwOEwyMC41NDkgOC43NTY4QzIxLjE1IDkuMzU3OCAyMS4xNSAxMC4zMzE4IDIwLjU0OSAxMC45MzI4TDExLjgxNyAxOS42NjQ4TDcuNDUgMTUuMjk2OEM2Ljg1IDE0LjY5NTggNi44NSAxMy43MjE4IDcuNDUgMTMuMTIxOEw3LjQ1NyAxMy4xMTQ4QzguMDU4IDEyLjUxMzggOS4wMzEgMTIuNTEzOCA5LjYzMyAxMy4xMTQ4TDExLjgxNyAxNS4yOTk4TDE4LjM2NyA4Ljc1MDhDMTguOTY4IDguMTQ5OCAxOS45NDIgOC4xNDk4IDIwLjU0MyA4Ljc1MDhaJyBmaWxsPSd3aGl0ZScvPjwvZz48ZGVmcz48Y2xpcFBhdGggaWQ9J2NsaXAwXzhfNDYnPjxyZWN0IHdpZHRoPScyOCcgaGVpZ2h0PScyOCcgZmlsbD0nd2hpdGUnLz48L2NsaXBQYXRoPjwvZGVmcz48L3N2Zz4= |
|
// @resource LOADING_ICON data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBmaWxsPSJjdXJyZW50Q29sb3IiIGQ9Ik0xMiAyQTEwIDEwIDAgMSAwIDIyIDEyQTEwIDEwIDAgMCAwIDEyIDJabTAgMThhOCA4IDAgMSAxIDgtOEE4IDggMCAwIDEgMTIgMjBaIiBvcGFjaXR5PSIuNSIvPjxwYXRoIGZpbGw9ImN1cnJlbnRDb2xvciIgZD0iTTIwIDEyaDJBMTAgMTAgMCAwIDAgMTIgMlY0QTggOCAwIDAgMSAyMCAxMloiPjxhbmltYXRlVHJhbnNmb3JtIGF0dHJpYnV0ZU5hbWU9InRyYW5zZm9ybSIgZHVyPSIxcyIgZnJvbT0iMCAxMiAxMiIgcmVwZWF0Q291bnQ9ImluZGVmaW5pdGUiIHRvPSIzNjAgMTIgMTIiIHR5cGU9InJvdGF0ZSIvPjwvcGF0aD48L3N2Zz4= |
|
// @resource ADMIN_ICON data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTIuOTE5IDBMMCAxMS4wNDE1TDcuNDc3NjkgMTMuMDcyMkM3LjE2MDQxIDEyLjI3OTIgNyAxMS40MzY1IDcgMTAuNTVWOC42NzkzMkw0Ljk3NyA4LjEyOTc1TDUuNzkzIDUuMDQ0MjVMOC44Mzg1IDUuODcxMjVMOC42NTc5MyA2LjU1M0wxMiA0Ljg4MTk3TDEzLjE1MjQgNS40NTgxOEwxMy44MTMyIDIuOTU4NUwyLjkxOSAwWiIgZmlsbD0iY3VycmVudENvbG9yIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNOS4xMzcgMTQuMDEzQzkuODk1IDE1LjA1NSAxMC44NDkzIDE1LjcxNzMgMTIgMTZDMTMuMTUgMTUuNzE2NyAxNC4xMDQzIDE1LjA1NCAxNC44NjMgMTQuMDEyQzE1LjYyMTcgMTIuOTcgMTYuMDAwNyAxMS44MTYgMTYgMTAuNTVWOEwxMiA2TDggOFYxMC41NUM4IDExLjgxNjcgOC4zNzkgMTIuOTcxIDkuMTM3IDE0LjAxM1pNMTQgMTAuNTVMMTQgMTAuNTUxMUMxNC4wMDA0IDExLjM4NTUgMTMuNzYwNCAxMi4xMjg0IDEzLjI0NjIgMTIuODM0OEMxMi44NjcgMTMuMzU1NiAxMi40NTQ5IDEzLjY5MTYgMTEuOTk5MyAxMy44OTVDMTEuNTQ0MSAxMy42OTIgMTEuMTMyNiAxMy4zNTY1IDEwLjc1NDMgMTIuODM2NUMxMC4yNDA0IDEyLjEzIDEwIDExLjM4NjEgMTAgMTAuNTVWOS4yMzYwN0wxMiA4LjIzNjA3TDE0IDkuMjM2MDdMMTQgMTAuNTVaIiBmaWxsPSJjdXJyZW50Q29sb3IiLz4KPC9zdmc+Cg== |
|
// @resource CHAT_ICON data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEzIDFIMi4zMzMzM0MxLjYgMSAxLjAwNjY3IDEuNiAxLjAwNjY3IDIuMzMzMzNMMSAxNC4zMzMzTDMuNjY2NjcgMTEuNjY2N0gxM0MxMy43MzMzIDExLjY2NjcgMTQuMzMzMyAxMS4wNjY3IDE0LjMzMzMgMTAuMzMzM1YyLjMzMzMzQzE0LjMzMzMgMS42IDEzLjczMzMgMSAxMyAxWk00LjMzMzMzIDUuNjY2NjdIMTFDMTEuMzY2NyA1LjY2NjY3IDExLjY2NjcgNS45NjY2NyAxMS42NjY3IDYuMzMzMzNDMTEuNjY2NyA2LjcgMTEuMzY2NyA3IDExIDdINC4zMzMzM0MzLjk2NjY3IDcgMy42NjY2NyA2LjcgMy42NjY2NyA2LjMzMzMzQzMuNjY2NjcgNS45NjY2NyAzLjk2NjY3IDUuNjY2NjcgNC4zMzMzMyA1LjY2NjY3Wk04LjMzMzMzIDlINC4zMzMzM0MzLjk2NjY3IDkgMy42NjY2NyA4LjcgMy42NjY2NyA4LjMzMzMzQzMuNjY2NjcgNy45NjY2NyAzLjk2NjY3IDcuNjY2NjcgNC4zMzMzMyA3LjY2NjY3SDguMzMzMzNDOC43IDcuNjY2NjcgOSA3Ljk2NjY3IDkgOC4zMzMzM0M5IDguNyA4LjcgOSA4LjMzMzMzIDlaTTExIDVINC4zMzMzM0MzLjk2NjY3IDUgMy42NjY2NyA0LjcgMy42NjY2NyA0LjMzMzMzQzMuNjY2NjcgMy45NjY2NyAzLjk2NjY3IDMuNjY2NjcgNC4zMzMzMyAzLjY2NjY3SDExQzExLjM2NjcgMy42NjY2NyAxMS42NjY3IDMuOTY2NjcgMTEuNjY2NyA0LjMzMzMzQzExLjY2NjcgNC43IDExLjM2NjcgNSAxMSA1WiIgZmlsbD0iY3VycmVudENvbG9yIi8+Cjwvc3ZnPgo= |
|
// @resource JOIN_GROUP_ICON data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTggNS4zMzMzM0M4IDYuODA2NjcgNi44MDY2NyA4IDUuMzMzMzMgOEMzLjg2IDggMi42NjY2NyA2LjgwNjY3IDIuNjY2NjcgNS4zMzMzM0MyLjY2NjY3IDMuODYgMy44NiAyLjY2NjY3IDUuMzMzMzMgMi42NjY2N0M2LjgwNjY3IDIuNjY2NjcgOCAzLjg2IDggNS4zMzMzM1oiIGZpbGw9ImN1cnJlbnRDb2xvciIvPgo8cGF0aCBkPSJNMTQgNC42NjY2N0MxNC4zNjY3IDQuNjY2NjcgMTQuNjY2NyA0Ljk2NjY3IDE0LjY2NjcgNS4zMzMzM1Y2SDE1LjMzMzNDMTUuNyA2IDE2IDYuMyAxNiA2LjY2NjY3QzE2IDcuMDMzMzMgMTUuNyA3LjMzMzMzIDE1LjMzMzMgNy4zMzMzM0gxNC42NjY3VjhDMTQuNjY2NyA4LjM2NjY3IDE0LjM2NjcgOC42NjY2NyAxNCA4LjY2NjY3QzEzLjYzMzMgOC42NjY2NyAxMy4zMzMzIDguMzY2NjcgMTMuMzMzMyA4VjcuMzMzMzNIMTIuNjY2N0MxMi4zIDcuMzMzMzMgMTIgNy4wMzMzMyAxMiA2LjY2NjY3QzEyIDYuMyAxMi4zIDYgMTIuNjY2NyA2SDEzLjMzMzNWNS4zMzMzM0MxMy4zMzMzIDQuOTY2NjcgMTMuNjMzMyA0LjY2NjY3IDE0IDQuNjY2NjdaIiBmaWxsPSJjdXJyZW50Q29sb3IiLz4KPHBhdGggZD0iTTUuMzMzMzMgOC42NjY2N0MzLjU1MzMzIDguNjY2NjcgMCA5LjU2IDAgMTEuMzMzM1YxMy4zMzMzSDEwLjY2NjdWMTEuMzMzM0MxMC42NjY3IDkuNTYgNy4xMTMzMyA4LjY2NjY3IDUuMzMzMzMgOC42NjY2N1oiIGZpbGw9ImN1cnJlbnRDb2xvciIvPgo8cGF0aCBkPSJNOS4zMzMzMyA1LjMzMzMzQzkuMzMzMzMgNC4zMjY2NyA4Ljk1MzMzIDMuNDA2NjcgOC4zNCAyLjdDOS42NDY2NyAyLjg2NjY3IDEwLjY2NjcgMy45NzMzMyAxMC42NjY3IDUuMzMzMzNDMTAuNjY2NyA2LjY5MzMzIDkuNjQ2NjcgNy44IDguMzQgNy45NjY2N0M4Ljk1MzMzIDcuMjYgOS4zMzMzMyA2LjM0IDkuMzMzMzMgNS4zMzMzM1oiIGZpbGw9ImN1cnJlbnRDb2xvciIvPgo8cGF0aCBkPSJNMTEuMDIgOS4yMkMxMS42MTMzIDkuNzczMzMgMTIgMTAuNDY2NyAxMiAxMS4zMzMzVjEzLjMzMzNIMTMuMzMzM1YxMS4zMzMzQzEzLjMzMzMgMTAuMzY2NyAxMi4yNzMzIDkuNjYgMTEuMDIgOS4yMloiIGZpbGw9ImN1cnJlbnRDb2xvciIvPgo8L3N2Zz4= |
|
// @resource ADD_FRIEND_ICON data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEwIDhDMTEuNDczMyA4IDEyLjY2NjcgNi44MDY2NyAxMi42NjY3IDUuMzMzMzNDMTIuNjY2NyAzLjg2IDExLjQ3MzMgMi42NjY2NyAxMCAyLjY2NjY3QzguNTI2NjcgMi42NjY2NyA3LjMzMzMzIDMuODYgNy4zMzMzMyA1LjMzMzMzQzcuMzMzMzMgNi44MDY2NyA4LjUyNjY3IDggMTAgOFpNNCA2LjY2NjY3VjUuMzMzMzNDNCA0Ljk2NjY3IDMuNyA0LjY2NjY3IDMuMzMzMzMgNC42NjY2N0MyLjk2NjY3IDQuNjY2NjcgMi42NjY2NyA0Ljk2NjY3IDIuNjY2NjcgNS4zMzMzM1Y2LjY2NjY3SDEuMzMzMzNDMC45NjY2NjYgNi42NjY2NyAwLjY2NjY2NiA2Ljk2NjY3IDAuNjY2NjY2IDcuMzMzMzNDMC42NjY2NjYgNy43IDAuOTY2NjY2IDggMS4zMzMzMyA4SDIuNjY2NjdWOS4zMzMzM0MyLjY2NjY3IDkuNyAyLjk2NjY3IDEwIDMuMzMzMzMgMTBDMy43IDEwIDQgOS43IDQgOS4zMzMzM1Y4SDUuMzMzMzNDNS43IDggNiA3LjcgNiA3LjMzMzMzQzYgNi45NjY2NyA1LjcgNi42NjY2NyA1LjMzMzMzIDYuNjY2NjdINFpNMTAgOS4zMzMzM0M4LjIyIDkuMzMzMzMgNC42NjY2NyAxMC4yMjY3IDQuNjY2NjcgMTJWMTIuNjY2N0M0LjY2NjY3IDEzLjAzMzMgNC45NjY2NyAxMy4zMzMzIDUuMzMzMzMgMTMuMzMzM0gxNC42NjY3QzE1LjAzMzMgMTMuMzMzMyAxNS4zMzMzIDEzLjAzMzMgMTUuMzMzMyAxMi42NjY3VjEyQzE1LjMzMzMgMTAuMjI2NyAxMS43OCA5LjMzMzMzIDEwIDkuMzMzMzNaIiBmaWxsPSJjdXJyZW50Q29sb3IiLz4KPC9zdmc+Cg== |
|
// |
|
// @connect users.roblox.com |
|
// @connect groups.roblox.com |
|
// @connect friends.roblox.com |
|
// @connect thumbnails.roblox.com |
|
// @connect presence.roblox.com |
|
// @connect games.roblox.com |
|
// @connect auth.roblox.com |
|
// @connect premiumfeatures.roblox.com |
|
// @connect accountinformation.roblox.com |
|
// |
|
// @grant GM_getValue |
|
// @grant GM_setValue |
|
// @grant GM_deleteValue |
|
// @grant GM_listValues |
|
// @grant GM_registerMenuCommand |
|
// @grant GM_addStyle |
|
// @grant GM_getResourceURL |
|
// @grant GM_getResourceText |
|
// |
|
// @noframes |
|
// ==/UserScript== |
|
|
|
// Dev Mode Data // |
|
const DEVELOPER_DATA = { |
|
ENABLED: GM_getValue("__DEV_MODE__", false), |
|
|
|
TEST_USER_IDS: { |
|
"13953438": true, |
|
"357945637": false, |
|
"575209311": true, |
|
"1886856675": true, |
|
}, |
|
|
|
PRESENCE: { |
|
"userPresenceType": 2, |
|
"placeId": 920587237, |
|
"rootPlaceId": 920587237, |
|
"gameId": "24abb705-70a4-4c3e-891f-a57437377e6f", |
|
"universeId": 383310974, |
|
}, |
|
} |
|
|
|
// Constants // |
|
const minutes = (n = 1) => 1000 * 60 * n |
|
|
|
const API = { |
|
users: refetch.build({ baseUrl: "https://users.roblox.com", cachePrefix: "users", cache: minutes(5) }), |
|
groups: refetch.build({ baseUrl: "https://groups.roblox.com", cachePrefix: "groups", cache: minutes(10) }), |
|
friends: refetch.build({ baseUrl: "https://friends.roblox.com", cachePrefix: "friends", cache: minutes(5) }), |
|
thumbnails: refetch.build({ baseUrl: "https://thumbnails.roblox.com", cache: false }), |
|
presence: refetch.build({ baseUrl: "https://presence.roblox.com", cachePrefix: "presence", cache: 1000 * 15 }), |
|
games: refetch.build({ baseUrl: "https://games.roblox.com", cachePrefix: "games", cache: minutes(5) }), |
|
auth: refetch.build({ baseUrl: "https://auth.roblox.com", cache: false }), // to fetch csrf tokens |
|
premiumFeatures: refetch.build({ baseUrl: "https://premiumfeatures.roblox.com", cachePrefix: "premium", cache: minutes(10) }), |
|
accountInformation: refetch.build({ baseUrl: "https://accountinformation.roblox.com", cachePrefix: "accountInformation", cache: minutes(10) }) |
|
} |
|
|
|
const THUMBNAIL_TYPE = { |
|
HEADSHOT: "/v1/users/avatar-headshot?userIds={{id}}&size={{size}}&format=Png&isCircular=false", |
|
BUST: "/v1/users/avatar-bust?userIds={{id}}&size={{size}}&format=Png&isCircular=false", |
|
AVATAR: "/v1/users/avatar?userIds={{id}}&size={{size}}&format=Png&isCircular=false", |
|
GROUP_ICON: "/v1/groups/icons?groupIds={{id}}&size={{size}}&format=Png&isCircular=false", |
|
GAME_ICON: "/v1/games/icons?universeIds={{id}}&returnPolicy=PlaceHolder&size={{size}}&format=Png&isCircular=false", |
|
} |
|
|
|
const PATHNAME_MATCHER_MAP = { |
|
USER: [ /^\/users\/(\d+)/i ], |
|
GROUP: [ /^\/groups\/(\d+)/i ], |
|
} |
|
|
|
const NUMBER_FORMATTER = new Intl.NumberFormat(navigator.language, { |
|
notation: "compact", |
|
compactDisplay: "short", |
|
maximumFractionDigits: 2, |
|
}) |
|
|
|
// Helper methods // |
|
const sleep = (timeout = 1000) => new Promise((resolve) => setTimeout(resolve, timeout)) |
|
const clearElementChildren = (element = HTMLElement) => element.children.forEach((child) => element.removeChild(child)) |
|
|
|
async function fetchThumbnail(id = 1, type = THUMBNAIL_TYPE.HEADSHOT, size = "150x150") { |
|
const requestPath = type.replaceAll("{{id}}", id).replaceAll("{{size}}", size) |
|
const cacheHash = `@refetch/thumbnails/${btoa(requestPath)}` |
|
const cachedValue = GM_getValue(cacheHash), now = Date.now() |
|
|
|
if (cachedValue?.expiry > now) { |
|
return cachedValue.value |
|
} |
|
|
|
for (let iterations = 0; iterations < 5; iterations++) { |
|
const { data: [data] } = await API.thumbnails(requestPath) |
|
|
|
if ((["Completed", "Blocked"]).includes(data.state)) { |
|
GM_setValue(cacheHash, { value: data.imageUrl, expiry: now + 1000 * 60 * 10 }) |
|
return data.imageUrl |
|
} |
|
|
|
await sleep(2500) |
|
} |
|
|
|
throw new Error("Unable to fetch thumbnail") |
|
} |
|
|
|
async function fetchAuthenticatedUserId() { |
|
const pageMetaUser = document.querySelector("meta[data-userid]") |
|
|
|
if (pageMetaUser) { |
|
return parseInt(pageMetaUser.getAttribute("data-userid")) |
|
} |
|
|
|
const { id } = await API.users("/v1/users/authenticated", { |
|
cache: 1000 * 60 * 1, |
|
}) |
|
|
|
return id |
|
} |
|
|
|
async function fetchCSRFToken() { |
|
const pageMetaToken = document.querySelector('meta[name="csrf-token"]') |
|
|
|
if (pageMetaToken) { |
|
return pageMetaToken.getAttribute("data-token") |
|
} |
|
|
|
let result |
|
|
|
try { |
|
result = await API.auth("/v2/logout", { |
|
method: "POST", |
|
fullResponse: true, |
|
}) |
|
|
|
return result.headers.get("x-csrf-token") |
|
} catch (_) { |
|
return result?.headers?.get("x-csrf-token") |
|
} |
|
} |
|
|
|
async function isFriendsWith(targetUserId = 1, currentUserId) { |
|
const checkAgainstUserId = currentUserId || await fetchAuthenticatedUserId() |
|
|
|
const { data: [data] } = await API.friends(`/v1/users/${checkAgainstUserId}/friends/statuses?userIds=${targetUserId}`, { |
|
cache: 1000 * 15, |
|
}) |
|
|
|
return data?.status === "Friends" |
|
} |
|
|
|
// Popover container // |
|
const popoverContainer = Create("div", { |
|
id: "hovercard-container", |
|
style: "display: none;", |
|
$init: async (self) => { |
|
while (!document.body) await sleep(1000) |
|
document.body.append(self) |
|
}, |
|
}) |
|
|
|
// Popover styles // |
|
GM_addStyle(` |
|
div.popover.people-info-card-container { |
|
display: none !important; |
|
} |
|
|
|
cx-hovercard { |
|
z-index: 1000; |
|
max-width: 320px; |
|
} |
|
|
|
cx-hovercard * { |
|
font-size: 14px; |
|
font-weight: bold; |
|
box-sizing: border-box; |
|
} |
|
|
|
.dark-theme cx-hovercard * { |
|
font-weight: normal; |
|
} |
|
|
|
cx-hovercard > div { |
|
background: #dee1e3; |
|
color: #393b3d; |
|
border-radius: 8px; |
|
box-shadow: 0 0 8px 0 rgba(0, 0, 0, .3); |
|
} |
|
|
|
.dark-theme cx-hovercard > div { |
|
background: #191b1d; |
|
color: #fafafa; |
|
} |
|
|
|
cx-hovercard > div > .arrow, cx-hovercard > div > .arrow::before { |
|
position: absolute; |
|
width: 8px; |
|
height: 8px; |
|
background: inherit; |
|
} |
|
|
|
cx-hovercard > div > .arrow { |
|
visibility: hidden; |
|
} |
|
|
|
cx-hovercard > div > .arrow::before { |
|
visibility: visible; |
|
content: ""; |
|
transform: rotate(45deg); |
|
} |
|
|
|
cx-hovercard[data-popper-placement^="top"] > div > .arrow { |
|
bottom: -4px; |
|
} |
|
|
|
cx-hovercard[data-popper-placement^="bottom"] > div > .arrow { |
|
top: -4px; |
|
} |
|
|
|
cx-hovercard[data-popper-placement^="left"] > div > .arrow { |
|
right: -4px; |
|
} |
|
|
|
cx-hovercard[data-popper-placement^="right"] > div > .arrow { |
|
left: -4px; |
|
} |
|
|
|
cx-hovercard img.avatar { |
|
display: flex; |
|
width: 48px; |
|
height: 48px; |
|
} |
|
|
|
cx-hovercard img.avatar.user { |
|
border-radius: 100%; |
|
} |
|
|
|
cx-hovercard img.avatar.small { |
|
width: 24px; |
|
height: 24px; |
|
} |
|
|
|
cx-hovercard img.avatar.in-game { |
|
outline: 2px solid #02b757; |
|
} |
|
|
|
cx-hovercard img.avatar.online { |
|
outline: 2px solid #00a2ff; |
|
} |
|
|
|
cx-hovercard img.avatar.in-studio { |
|
outline: 2px solid #f68802; |
|
} |
|
|
|
cx-hovercard img.avatar.invisible { |
|
outline: 2px solid #808080; |
|
} |
|
|
|
cx-hovercard img.thumbnail { |
|
border-radius: 4px; |
|
width: 72px; |
|
height: 72px; |
|
} |
|
|
|
cx-hovercard header { |
|
display: flex; |
|
flex-direction: column; |
|
gap: 1em; |
|
padding: 1em; |
|
align-items: center; |
|
max-width: 100%; |
|
flex-wrap: nowrap; |
|
flex-grow: 1; |
|
} |
|
|
|
cx-hovercard[type="GROUP"] header { |
|
flex-direction: row; |
|
} |
|
|
|
cx-hovercard .header-actions { |
|
display: flex; |
|
align-items: center; |
|
justify-content: flex-end; |
|
gap: 1em; |
|
} |
|
|
|
cx-hovercard .full-width { |
|
width: 100%; |
|
} |
|
|
|
cx-hovercard .row { |
|
display: flex; |
|
flex-direction: row; |
|
} |
|
|
|
cx-hovercard .column { |
|
display: flex; |
|
flex-direction: column; |
|
} |
|
|
|
cx-hovercard .text-overflow { |
|
overflow: hidden; |
|
text-overflow: ellipsis; |
|
white-space: nowrap; |
|
} |
|
|
|
cx-hovercard .text-column { |
|
display: flex; |
|
flex-direction: column; |
|
gap: 0; |
|
justify-content: center; |
|
overflow: hidden; |
|
} |
|
|
|
cx-hovercard .text-column.row { |
|
flex-direction: row; |
|
gap: 0.25ch; |
|
} |
|
|
|
cx-hovercard .caption:not(strong) { |
|
font-weight: normal !important; |
|
} |
|
|
|
.dark-theme cx-hovercard .caption { |
|
opacity: 60%; |
|
font-weight: lighter; |
|
} |
|
|
|
cx-hovercard strong.caption { |
|
font-size: 12px; |
|
} |
|
|
|
.dark-theme strong.caption { |
|
opacity: 60%; |
|
} |
|
|
|
cx-hovercard .icon { |
|
display: inline-flex; |
|
width: 1em; |
|
height: 1em; |
|
background: unset !important; |
|
background-repeat: no-repeat; |
|
background-size: contain !important; |
|
fill: currentColor; |
|
color: inherit; |
|
align-items: center; |
|
font-size: inherit; |
|
} |
|
|
|
cx-hovercard .icon svg { |
|
font-size: inherit; |
|
width: 1em; |
|
height: 1em; |
|
fill: currentColor; |
|
color: inherit; |
|
shape-rendering: auto; |
|
} |
|
|
|
cx-hovercard footer { |
|
display: flex; |
|
flex-direction: column; |
|
max-width: 100%; |
|
width: 100%; |
|
flex-grow: 1; |
|
} |
|
|
|
cx-hovercard .stats { |
|
display: flex; |
|
flex-wrap: wrap; |
|
width: 100%; |
|
justify-content: space-evenly; |
|
gap: 0.5em; |
|
padding: 0 1em 1em; |
|
} |
|
|
|
cx-hovercard .link:hover, cx-hovercard .link-target:hover .link:not(.ungroup) { |
|
text-decoration: underline; |
|
} |
|
|
|
cx-hovercard .text-link, cx-hovercard .icon-link { |
|
opacity: 80%; |
|
} |
|
|
|
cx-hovercard .text-link:hover, cx-hovercard .icon-link:hover { |
|
opacity: initial; |
|
} |
|
|
|
cx-hovercard .icon-link { |
|
font-size: 24px; |
|
} |
|
|
|
cx-hovercard .disabled, cx-hovercard *[disabled] { |
|
opacity: 50%; |
|
pointer-events: none; |
|
} |
|
|
|
cx-hovercard .current-activity { |
|
display: flex; |
|
padding: 1em 1em; |
|
margin-bottom: 1em; |
|
background-color: #c5cace; |
|
flex-direction: column; |
|
gap: 1em; |
|
} |
|
|
|
.dark-theme cx-hovercard .current-activity { |
|
background-color: #212426; |
|
} |
|
|
|
cx-hovercard strong, cx-hovercard strong:hover, cx-hovercard .strong { |
|
font-weight: 700 !important; |
|
font-size: inherit !important; |
|
margin: 0; |
|
} |
|
|
|
cx-hovercard .current-activity .activity { |
|
display: flex; |
|
flex-direction: row; |
|
gap: 1em; |
|
flex-wrap: no-wrap; |
|
} |
|
|
|
cx-hovercard .button { |
|
display: flex; |
|
width: 100%; |
|
padding: 0.5em; |
|
background: #393b3d; |
|
color: #fafafa; |
|
border-radius: 4px; |
|
align-items: center; |
|
justify-content: center; |
|
font-weight: bold; |
|
cursor: pointer; |
|
} |
|
|
|
cx-hovercard .button:hover { |
|
background: #28292a; |
|
} |
|
|
|
.dark-theme cx-hovercard .button { |
|
background: #ffffff; |
|
color: #1a1a1a; |
|
} |
|
|
|
.dark-theme cx-hovercard .button:hover { |
|
background: #ddd7d7; |
|
color: #000000; |
|
} |
|
|
|
cx-hovercard .button.primary { |
|
background-color: #00b06f; |
|
color: #ffffff; |
|
} |
|
|
|
cx-hovercard .button.primary:hover { |
|
background-color: #009065; |
|
color: #ffffff; |
|
} |
|
|
|
cx-hovercard main.loading { |
|
display: flex; |
|
padding: 1em; |
|
} |
|
|
|
cx-hovercard main.loading .icon, section.loading .icon { |
|
font-size: 24px; |
|
} |
|
|
|
cx-hovercard .with-icon { |
|
display: inline-flex; |
|
align-items: center; |
|
flex-direction: row; |
|
gap: 0.5ch; |
|
} |
|
|
|
cx-hovercard section.primary-group { |
|
display: flex; |
|
flex-direction: row; |
|
gap: 1em; |
|
align-items: center; |
|
justify-content: flex-start; |
|
width: 100%; |
|
border-top: 1px solid #c5cace; |
|
padding-top: 1em; |
|
} |
|
|
|
.dark-theme cx-hovercard section.primary-group { |
|
border-top: 1px solid #212426; |
|
} |
|
|
|
cx-hovercard section.primary-group > a { |
|
display: flex; |
|
flex-direction: row; |
|
gap: 1em; |
|
align-items: center; |
|
justify-content: flex-start; |
|
width: 100%; |
|
} |
|
`) |
|
|
|
const PRESENCE_CLASS_MAP = { |
|
[1]: "online", |
|
[2]: "in-game", |
|
[3]: "in-studio", |
|
[4]: "invisible", |
|
} |
|
|
|
// Popover handler // |
|
class PopoverHandler { |
|
static REGISTERED_TRIGGERS = [] |
|
static TRIGGER_EVENT_NAMES = { |
|
SHOW: [ "mouseenter", "focus" ], |
|
HIDE: [ "mouseleave", "blur" ], |
|
} |
|
|
|
#target = HTMLElement |
|
#hoverTarget = HTMLElement |
|
#type = "USER" |
|
#id = 1 |
|
#popover = HTMLElement |
|
#popper = null |
|
#visible = false |
|
#hovered = false |
|
|
|
constructor(target, { type, id, hoverTarget }) { |
|
if (PopoverHandler.REGISTERED_TRIGGERS.includes(target)) return |
|
PopoverHandler.REGISTERED_TRIGGERS.push(target) |
|
|
|
this.#target = target |
|
this.#hoverTarget = hoverTarget |
|
this.#type = type |
|
this.#id = id |
|
this.#hovered = false |
|
|
|
this.#registerHoverEvents(this.#target) |
|
this.#initPopoverElement().then((popover) => this.#registerHoverEvents(popover)) |
|
} |
|
|
|
#registerHoverEvents(target = HTMLElement) { |
|
PopoverHandler.TRIGGER_EVENT_NAMES.SHOW.forEach( |
|
(eventName) => target.addEventListener(eventName, () => { |
|
this.#hovered = true |
|
this.show() |
|
}) |
|
) |
|
|
|
PopoverHandler.TRIGGER_EVENT_NAMES.HIDE.forEach( |
|
(eventName) => target.addEventListener(eventName, () => { |
|
this.#hovered = false |
|
|
|
setTimeout(() => { |
|
if (this.#hovered) return |
|
this.hide() |
|
}, 100) |
|
}) |
|
) |
|
} |
|
|
|
show() { |
|
if (this.#visible) return |
|
|
|
document.body.append(this.#popover) |
|
|
|
const popper = Popper.createPopper(this.#hoverTarget, this.#popover, { |
|
modifiers: [ |
|
{ name: "offset", options: { offset: [ 0, 8 ] } }, |
|
], |
|
onFirstUpdate: (state) => { |
|
this.#popover.classList.remove("top", "right", "bottom", "left") |
|
this.#popover.classList.add(state.placement) |
|
}, |
|
}) |
|
|
|
this.#popper = popper |
|
this.#visible = true |
|
} |
|
|
|
hide() { |
|
this.#visible = false |
|
this.#popper?.destroy() |
|
popoverContainer.append(this.#popover) |
|
} |
|
|
|
#createUserPrimaryGroupPanel = async () => { |
|
const panel = Create("section", { |
|
className: ["primary-group", "loading"], |
|
}, [ |
|
Create("span", { |
|
className: ["icon"], |
|
$html: GM_getResourceText("LOADING_ICON"), |
|
}), |
|
]) |
|
|
|
API.groups(`/v1/users/${this.#id}/groups/primary/role`).then(async (primaryGroup) => { |
|
panel.classList.remove("loading") |
|
clearElementChildren(panel) |
|
|
|
const groupIconUrl = await fetchThumbnail(primaryGroup.group.id, THUMBNAIL_TYPE.GROUP_ICON, "150x150") |
|
|
|
const contents = Create("a", { |
|
className: ["link"], |
|
href: `/groups/${primaryGroup.group.id}`, |
|
}, [ |
|
Create("img", { |
|
className: ["group", "avatar", "small"], |
|
src: groupIconUrl, |
|
}), |
|
|
|
Create("span", { |
|
className: ["with-icon", "full-width", "text-overflow"], |
|
}, [ |
|
primaryGroup.group.name, |
|
primaryGroup.group.hasVerifiedBadge && Create("span", { |
|
className: ["icon"], |
|
$html: GM_getResourceText("VERIFIED_ICON"), |
|
}), |
|
]), |
|
]) |
|
|
|
panel.append(contents) |
|
}).catch(() => { |
|
panel.remove() |
|
}) |
|
|
|
return panel |
|
} |
|
|
|
#initUserCardPopover = async () => { |
|
const userInfo = await API.users(`/v1/users/${this.#id}`) |
|
const isMe = parseInt(this.#id) === await fetchAuthenticatedUserId() |
|
const isFriend = isMe ? false : await isFriendsWith(this.#id) |
|
|
|
const { userPresences: [ presenceInfo ] } = await API.presence(`/v1/presence/users?_=${this.#id}`, { |
|
method: "POST", |
|
body: JSON.stringify({ userIds: [ this.#id ] }), |
|
}) |
|
|
|
if (DEVELOPER_DATA.ENABLED) { |
|
let hideJoinButton = false |
|
|
|
if (presenceInfo?.userPresenceType !== 2 && (Object.keys(DEVELOPER_DATA.TEST_USER_IDS)).includes(this.#id)) { |
|
if (DEVELOPER_DATA.TEST_USER_IDS[this.#id] === false) { |
|
hideJoinButton = true |
|
} |
|
|
|
for (const [key, value] of Object.entries(DEVELOPER_DATA.PRESENCE)) { |
|
presenceInfo[key] = value |
|
} |
|
|
|
if (hideJoinButton) { |
|
delete presenceInfo.gameId |
|
} |
|
} |
|
} |
|
|
|
const userIsOnline = presenceInfo?.userPresenceType === 2 && !!presenceInfo?.rootPlaceId |
|
const userIsJoinable = !!presenceInfo?.gameId |
|
const jQueryEnabled = !!jQuery |
|
|
|
const content = Create("div", {}, [ |
|
Create("header", {}, [ |
|
Create("div", { |
|
className: ["row", "full-width"], |
|
}, [ |
|
Create("a", { |
|
href: `/users/${this.#id}/profile`, |
|
}, [ |
|
Create("img", { |
|
className: ["user", "avatar", "loading"], |
|
$init: async (self) => { |
|
const avatarHeadshot = await fetchThumbnail(this.#id, THUMBNAIL_TYPE.HEADSHOT, "48x48") |
|
|
|
if (presenceInfo?.userPresenceType) { |
|
self.classList.add(PRESENCE_CLASS_MAP[presenceInfo.userPresenceType]) |
|
} |
|
|
|
self.src = avatarHeadshot |
|
self.classList.remove("loading") |
|
}, |
|
}), |
|
]), |
|
|
|
Create("div", { |
|
className: ["full-width", "header-actions"], |
|
}, [ |
|
(jQueryEnabled && isFriend) && Create("a", { |
|
"className": ["icon", "icon-link"], |
|
"title": `Chat with ${userInfo.displayName}`, |
|
"$html": GM_getResourceText("CHAT_ICON"), |
|
"$on:click": () => $(document).triggerHandler("Roblox.Chat.StartChat", { |
|
userId: this.#id, |
|
}), |
|
}), |
|
|
|
(!isFriend && !isMe) && Create("a", { |
|
"className": ["icon", "icon-link"], |
|
"title": `Send ${userInfo.displayName} friend request`, |
|
"$html": GM_getResourceText("ADD_FRIEND_ICON"), |
|
"$on:click": async (self) => { |
|
self.classList.add("disabled") |
|
self.innerHTML = GM_getResourceText("LOADING_ICON") |
|
|
|
try { |
|
const csrfToken = await fetchCSRFToken() |
|
const { success } = await API.friends(`/v1/users/${this.#id}/request-friendship`, { |
|
method: "POST", |
|
headers: { "x-csrf-token": csrfToken }, |
|
body: JSON.stringify({ friendshipOriginSourceType: 4 }), |
|
}) |
|
|
|
if (success) { |
|
return self.remove() |
|
} |
|
} catch (err) { |
|
console.error(`Failed to send friend request: ${err}`) |
|
} |
|
|
|
location.href = `/users/${this.#id}/profile` |
|
}, |
|
}), |
|
]), |
|
]), |
|
|
|
Create("div", { |
|
className: ["row", "full-width"], |
|
}, [ |
|
Create("a", { |
|
className: ["text-column", "full-width", "link"], |
|
href: `/users/${this.#id}/profile`, |
|
}, [ |
|
Create("strong", { |
|
className: ["text-overflow", "with-icon"], |
|
$init: (self) => { |
|
if (userInfo.hasVerifiedBadge) { |
|
Create("span", { |
|
className: ["icon"], |
|
title: "Verified", |
|
$html: GM_getResourceText("VERIFIED_ICON"), |
|
$init: async (icon) => self.append(icon), |
|
}) |
|
} |
|
|
|
API.accountInformation(`/v1/users/${this.#id}/roblox-badges`).then((badges) => { |
|
const isRobloxAdmin = badges.filter((badge) => badge.id === 1).length |
|
if (!isRobloxAdmin) return |
|
|
|
Create("span", { |
|
className: ["icon"], |
|
title: "Roblox Admin", |
|
$html: GM_getResourceText("ADMIN_ICON"), |
|
$init: async (icon) => self.append(icon), |
|
}) |
|
}) |
|
|
|
API.premiumFeatures(`/v1/users/${this.#id}/validate-membership`, { json: false }).then((premiumStatus) => { |
|
const isPremium = premiumStatus === "true" |
|
if (!isPremium) return |
|
|
|
Create("span", { |
|
className: ["icon"], |
|
title: "Premium", |
|
$html: GM_getResourceText("PREMIUM_ICON"), |
|
$init: async (icon) => self.append(icon), |
|
}) |
|
}) |
|
}, |
|
}, [ userInfo.displayName ]), |
|
|
|
Create("span", { |
|
className: ["caption", "text-overflow"], |
|
}, [ `@${userInfo.name}` ]) |
|
]), |
|
]), |
|
|
|
await this.#createUserPrimaryGroupPanel(), |
|
]), |
|
|
|
userIsOnline && Create("main", { |
|
className: ["current-activity"], |
|
}, [ |
|
Create("strong", { className: ["caption"] }, [ "IN AN EXPERIENCE" ]), |
|
|
|
Create("a", { |
|
className: ["activity", "link-target"], |
|
href: `/games/${presenceInfo.rootPlaceId}`, |
|
}, [ |
|
Create("img", { |
|
className: ["thumbnail", "loading"], |
|
$init: async (self) => { |
|
const gameIcon = await fetchThumbnail(presenceInfo.universeId, THUMBNAIL_TYPE.GAME_ICON, "128x128") |
|
|
|
self.src = gameIcon |
|
self.classList.remove("loading") |
|
} |
|
}), |
|
|
|
Create("div", { |
|
className: ["text-column"], |
|
$init: async (self) => { |
|
const { data: [ gameInfo ] } = await API.games(`/v1/games?universeIds=${presenceInfo.universeId}`) |
|
const isCreatorUser = gameInfo.creator.type === "User" |
|
|
|
self.append( |
|
Create("strong", { |
|
className: ["link"], |
|
}, [ gameInfo.name ]), |
|
|
|
Create("a", { |
|
className: ["caption", "link", "ungroup", "with-icon"], |
|
href: `${isCreatorUser ? "/users" : "/groups"}/${gameInfo.creator.id}`, |
|
}, [ |
|
`${isCreatorUser ? "@" : ""}${gameInfo.creator.name}`, |
|
|
|
gameInfo.creator.hasVerifiedBadge && Create("span", { |
|
className: ["icon"], |
|
title: "Verified", |
|
$html: GM_getResourceText("VERIFIED_ICON"), |
|
}) |
|
]) |
|
) |
|
}, |
|
}), |
|
]), |
|
|
|
userIsJoinable && Create("a", { |
|
className: ["button", "primary"], |
|
"$on:click": () => { |
|
Roblox.GameLauncher.followPlayerIntoGame(this.#id) |
|
} |
|
}, [ "Join" ]), |
|
]), |
|
|
|
Create("footer", {}, [ |
|
Create("section", { |
|
className: ["stats"], |
|
}, [ |
|
Create("a", { |
|
className: ["text-column", "row", "link"], |
|
href: `/users/${this.#id}/friends#!/friends`, |
|
}, [ |
|
Create("span", { |
|
$init: async (self) => { |
|
const { count } = await API.friends(`/v1/users/${this.#id}/friends/count`) |
|
self.textContent = NUMBER_FORMATTER.format(count) |
|
}, |
|
}, [ "0" ]), |
|
|
|
Create("span", { className: ["caption"] }, [ "friends" ]), |
|
]), |
|
|
|
Create("a", { |
|
className: ["text-column", "row", "link"], |
|
href: `/users/${this.#id}/friends#!/followers`, |
|
}, [ |
|
Create("span", { |
|
$init: async (self) => { |
|
const { count } = await API.friends(`/v1/users/${this.#id}/followers/count`) |
|
self.textContent = NUMBER_FORMATTER.format(count) |
|
}, |
|
}, [ "0" ]), |
|
|
|
Create("span", { className: ["caption"] }, [ "followers" ]), |
|
]), |
|
|
|
Create("a", { |
|
className: ["text-column", "row", "link"], |
|
href: `/users/${this.#id}/friends#!/following`, |
|
}, [ |
|
Create("span", { |
|
$init: async (self) => { |
|
const { count } = await API.friends(`/v1/users/${this.#id}/followings/count`) |
|
self.textContent = NUMBER_FORMATTER.format(count) |
|
}, |
|
}, [ "0" ]), |
|
|
|
Create("span", { className: ["caption"] }, [ "following" ]), |
|
]), |
|
]), |
|
]), |
|
]) |
|
|
|
return content |
|
} |
|
|
|
#initGroupCardPopover = async () => { |
|
const groupInfo = await API.groups(`/v1/groups/${this.#id}`) |
|
|
|
const memberCountLabel = Create("span", {}, [ |
|
NUMBER_FORMATTER.format(groupInfo.memberCount) |
|
]) |
|
|
|
const content = Create("div", {}, [ |
|
Create("header", {}, [ |
|
Create("a", { |
|
href: `/groups/${this.#id}`, |
|
}, [ |
|
Create("img", { |
|
className: ["avatar", "loading"], |
|
$init: async (self) => { |
|
const groupIcon = await fetchThumbnail(this.#id, THUMBNAIL_TYPE.GROUP_ICON, "150x150") |
|
|
|
self.src = groupIcon |
|
self.classList.remove("loading") |
|
}, |
|
}), |
|
]), |
|
|
|
Create("div", { |
|
className: ["text-column", "full-width"], |
|
}, [ |
|
Create("a", { |
|
className: ["link"], |
|
href: `/groups/${this.#id}`, |
|
title: groupInfo.name, |
|
}, [ |
|
Create("strong", { |
|
className: ["text-overflow", "with-icon"], |
|
$init: (self) => { |
|
if (groupInfo.hasVerifiedBadge) { |
|
Create("span", { |
|
className: ["group", "icon"], |
|
title: "Verified", |
|
$html: GM_getResourceText("VERIFIED_ICON"), |
|
$init: async (icon) => self.append(icon), |
|
}) |
|
} |
|
}, |
|
}, [ |
|
groupInfo.name, |
|
]), |
|
]), |
|
|
|
Create("a", { |
|
className: ["caption", "link", "text-overflow", "with-icon"], |
|
href: `/users/${groupInfo.owner.userId}/profile`, |
|
title: `@${groupInfo.owner.username}`, |
|
$init: (self) => { |
|
if (groupInfo.owner.hasVerifiedBadge) { |
|
Create("span", { |
|
className: ["icon"], |
|
title: "Verified", |
|
$html: GM_getResourceText("VERIFIED_ICON"), |
|
$init: async (icon) => self.append(icon), |
|
}) |
|
} |
|
} |
|
}, [ |
|
`by @${groupInfo.owner.username}`, |
|
]), |
|
]), |
|
|
|
Create("div", {}, [ |
|
Create("a", { |
|
className: ["icon", "icon-link", "disabled"], |
|
title: `Join ${groupInfo.name}`, |
|
$html: GM_getResourceText("LOADING_ICON"), |
|
|
|
$init: async (self) => { |
|
const membershipInfo = await API.groups(`/v1/groups/${this.#id}/membership`, { cache: false }) |
|
const activeRank = membershipInfo?.userRole?.role?.rank |
|
const isInGroup = (activeRank ?? 0) > 0 |
|
|
|
if (isInGroup || activeRank === null) return self.remove() |
|
|
|
self.innerHTML = GM_getResourceText("JOIN_GROUP_ICON") |
|
self.classList.remove("disabled") |
|
|
|
self.addEventListener("click", async () => { |
|
self.classList.add("disabled") |
|
self.innerHTML = GM_getResourceText("LOADING_ICON") |
|
|
|
try { |
|
const csrfToken = await fetchCSRFToken() |
|
|
|
await API.groups(`/v1/groups/${this.#id}/users`, { |
|
method: "POST", |
|
headers: { "x-csrf-token": csrfToken }, |
|
body: JSON.stringify({ sessionId: "x", redemptionToken: "x", captchaId: "x", captchaToken: "x", captchaProvider: "x", challengeId: "x" }), |
|
}) |
|
|
|
memberCountLabel.textContent = NUMBER_FORMATTER.format(groupInfo.memberCount + 1) |
|
self.remove() |
|
} catch (_) { |
|
location.href = `/groups/${this.#id}` |
|
} |
|
}) |
|
}, |
|
}), |
|
]), |
|
]), |
|
|
|
Create("main", {}, [ |
|
Create("section", { |
|
className: ["stats"], |
|
}, [ |
|
Create("a", { |
|
className: ["text-column", "row", "link"], |
|
href: `/groups/${this.#id}`, |
|
}, [ |
|
memberCountLabel, |
|
|
|
Create("span", { |
|
className: ["caption"], |
|
}, [ "members" ]), |
|
]), |
|
]), |
|
]), |
|
]) |
|
|
|
return content |
|
} |
|
|
|
#initPopoverElement = async () => { |
|
const popoverComponent = ({ |
|
USER: () => this.#initUserCardPopover(), |
|
GROUP: () => this.#initGroupCardPopover(), |
|
})[this.#type] |
|
|
|
const popover = Create("cx-hovercard", { |
|
type: this.#type, |
|
id: this.#id, |
|
}, [ |
|
Create("div", {}, [ |
|
// arrow |
|
Create("div", { |
|
className: "arrow", |
|
"data-popper-arrow": "", |
|
}), |
|
|
|
// content |
|
Create("div", { |
|
$init: async (self) => { |
|
const loadingSpinner = Create("main", { |
|
className: ["loading"], |
|
}, [ |
|
Create("span", { |
|
className: ["icon"], |
|
$html: GM_getResourceText("LOADING_ICON"), |
|
}), |
|
]) |
|
|
|
self.append(loadingSpinner) |
|
|
|
const component = await popoverComponent() |
|
|
|
self.append(component) |
|
loadingSpinner.remove() |
|
|
|
this?.#popper?.update() |
|
}, |
|
}), |
|
]), |
|
]) |
|
|
|
popoverContainer.append(popover) |
|
this.#popover = popover |
|
|
|
return popover |
|
} |
|
} |
|
|
|
// Main // |
|
GM_registerMenuCommand( |
|
"Support the developer", |
|
() => window.open("/groups/9536808/group#!/store") |
|
) |
|
|
|
GM_registerMenuCommand( |
|
"Clear cached remote content", |
|
() => GM_listValues().filter((key) => key.startsWith("@refetch/")).forEach(GM_deleteValue) |
|
) |
|
|
|
GM_registerMenuCommand( |
|
`${DEVELOPER_DATA.ENABLED ? "Dis" : "En"}able developer mode`, |
|
() => { |
|
GM_setValue("__DEV_MODE__", !DEVELOPER_DATA.ENABLED) |
|
location.reload() |
|
} |
|
) |
|
|
|
window.addEventListener("mouseover", ({ target }) => { |
|
if (target.closest("cx-hovercard") || target.closest("#navigation-container")) return |
|
|
|
const targetElement = target.closest("a[href]") |
|
if (!targetElement) return |
|
|
|
const targetUrl = new URL(targetElement.href) |
|
let didMatch = false |
|
|
|
for (const [type, matchers] of Object.entries(PATHNAME_MATCHER_MAP)) { |
|
for (const matcher of matchers) { |
|
const match = targetUrl.pathname.match(matcher) |
|
if (match?.[1] === undefined) continue |
|
|
|
new PopoverHandler(targetElement, { |
|
type, |
|
id: match[1], |
|
hoverTarget: target, |
|
}) |
|
|
|
didMatch = true |
|
break |
|
} |
|
|
|
if (didMatch) break |
|
} |
|
}) |