Skip to content

Instantly share code, notes, and snippets.

@cxmeel
Last active December 2, 2023 13:34
Show Gist options
  • Save cxmeel/44f8fde9f4d189e8bf06718660bb9bab to your computer and use it in GitHub Desktop.
Save cxmeel/44f8fde9f4d189e8bf06718660bb9bab to your computer and use it in GitHub Desktop.
Hover cards for the Roblox website.

Hover Cards

Displays a popup with additional information when hovering over user or group links on the Roblox website.

Features

  • See what games friends are playing and quickly launch into their servers (if privacy settings allow)
  • Pop open a chat window from a friend's hover card
  • Send friend requests and join groups from hover cards
  • View friends, following and follower counts
  • View group member counts
  • Verification checkmarks for verified groups/users
  • Badges for Roblox admins and Premium subscribers
  • Hexagonal profile pictures for NFT users

Prerequisites

You'll need an extension for your browser to run userscripts. Try one of the following:

⚠️ Warning
This userscript has only been tested in Violentmonkey. I cannot promise that this userscript will function correctly (if at all) in other userscript runners.

Screenshots

image
User hover card
image
Friend hover card (chat button)
image
User hover card (in-game, joinable)
image
User hover card (in-game)
Group hover card
Group hover card
Group hover card - join button
Group hover card (join button)

[0.7.0]

image

Added

  • User hover cards now display the users' primary group below their username.

[0.6.0]

image

Added

  • Status ring around user avatars; for example, an orange ring when the user is in Studio.
  • Quick action on non-friend user hover cards to send them a friend request.
  • Hovering over group names will show a tooltip with the group name—this is useful when the group name is long and gets truncated.
  • Developer mode toggle in extension popup menu—used for testing user presences.

Changed

  • When joining a group from a hover card, its member count will update on success.
  • Swapped out quick action icons for material design ones.
  • Fixed verified badges not displaying on group hover cards.

[0.5.0]

image

Added

  • When viewing a Premium user or Roblox Admin, icons will be displayed next to their name, in addition to the Verified checkmark.

[0.4.0]

Added

  • Loading spinner while hover card content is loading.

Changed

  • Tweaked layout of user hover card headers to be vertical.
  • Fixed message button on friend hover cards being unclickable.

[0.3.0]

Added

  • Display a join button on group hover cards for groups you're not a member of.

Changed

  • Group name/username/display name are now links to the corresponding group/profile.
  • Group icon/avatar are also now links.
  • Chat button will not be visible on non-friend hover cards.

[0.2.0]

Added

  • Chat button to open a chat window on friend hover cards.

Changed

  • CSS tweaks - Centrally aligned user/group stats.

[0.1.0]

Initial release.

// ==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
}
})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment