Adds a floating action button (FAB) at the bottom right to access the account menu/alerts and notifications without scrolling back to the top. Supports JAV-Forum, SimpCity, and SocialMediaGirls.
// ==UserScript==
// @name XenForo Forum FAB Menu (Bottom Right)
// @namespace http://tampermonkey.net/
// @version 1.6
// @description Adds a floating action button (FAB) at the bottom right to access the account menu/alerts and notifications without scrolling back to the top. Supports JAV-Forum, SimpCity, and SocialMediaGirls.
// @author Gemini 3 Pro
// @match https://jav-forum.com/*
// @match https://www.jav-forum.com/*
// @match https://simpcity.su/*
// @match https://simpcity.li/*
// @match https://simpcity.cr/*
// @match https://forums.socialmediagirls.com/*
// @grant GM_addStyle
// @run-at document-idle
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// ================= CONFIGURATION & SELECTORS =================
const HOST = window.location.hostname;
const FADE_DELAY = 2000;
const FADED_OPACITY = '0.3';
let SITE_CONFIG = {
trigger: '', // Element to click/read badge from
avatarSrc: '', // Element to copy visual style from
badgeAttr: 'data-badge'
};
// Detect Site and apply selectors
if (HOST.includes('jav-forum')) {
// JAV-Forum: The user link contains both the avatar and the menu trigger
SITE_CONFIG.trigger = '.p-navgroup-link--user';
SITE_CONFIG.avatarSrc = '.p-navgroup-link--user img';
} else if (HOST.includes('simpcity') || HOST.includes('socialmediagirls')) {
// SimpCity & SMG:
// Trigger = The Alerts/Bell icon (as requested for notifications)
// Avatar = The User icon (for visual appearance)
SITE_CONFIG.trigger = 'a[href*="/account/alerts"]';
SITE_CONFIG.avatarSrc = '.p-navgroup-link--user .avatar';
}
// =============================================================
let fabButton = null;
let triggerElement = null; // The actual element we click/read badge
let avatarSourceElement = null; // The element we copy the image/look from
let fadeTimer = null;
// 1. Inject CSS Styles
const css = `
/* FAB Container Styles */
#jf-fab-container {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 9990;
cursor: pointer;
transition: transform 0.2s, opacity 0.5s ease-in-out;
-webkit-tap-highlight-color: transparent;
opacity: 1;
}
#jf-fab-container.jf-fab-faded {
opacity: ${FADED_OPACITY};
}
#jf-fab-container:hover,
#jf-fab-container:active {
opacity: 1 !important;
}
#jf-fab-container:active {
transform: scale(0.95);
}
/* Avatar Circle Styles */
.jf-fab-avatar {
width: 56px;
height: 56px;
border-radius: 50%;
overflow: hidden;
box-shadow: 0 4px 10px rgba(0,0,0,0.3);
border: 2px solid #fff;
background-color: #eee;
display: flex;
align-items: center;
justify-content: center;
}
/* Support for Image Avatars */
.jf-fab-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Support for XenForo Dynamic/Text Avatars (SimpCity/SMG) */
.jf-fab-avatar .avatar {
width: 100% !important;
height: 100% !important;
font-size: 28px !important; /* Adjust font size for the FAB */
line-height: 56px !important;
border-radius: 0 !important; /* Container is already rounded */
margin: 0 !important;
}
/* Red Notification Badge */
.jf-fab-badge {
position: absolute;
top: -5px;
right: -5px;
background-color: #E53935;
color: white;
border-radius: 10px;
padding: 2px 6px;
font-size: 11px;
font-weight: bold;
border: 2px solid #fff;
min-width: 18px;
text-align: center;
display: none;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
}
.jf-fab-badge.has-unread {
display: block;
}
/* Force Menu Position for all sites */
body.fab-mode .menu--structural {
position: fixed !important;
top: auto !important;
bottom: 90px !important;
right: 20px !important;
left: auto !important;
transform: none !important;
max-height: 80vh;
overflow-y: auto;
z-index: 9999 !important;
transform-origin: bottom right !important;
animation: fabMenuFadeIn 0.2s ease-out;
}
@keyframes fabMenuFadeIn {
from { opacity: 0; transform: translateY(10px) scale(0.95); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
`;
GM_addStyle(css);
// 2. Initialize
function init() {
// Find the main trigger (for badge and click)
triggerElement = document.querySelector(SITE_CONFIG.trigger);
// Find the avatar visual source. If distinct selector not found, fallback to trigger
avatarSourceElement = document.querySelector(SITE_CONFIG.avatarSrc) || triggerElement;
if (!triggerElement) {
console.log("FAB Menu: Not logged in or selectors changed.");
return;
}
createFab();
syncData();
setupObservers();
scheduleFade();
}
function scheduleFade() {
if (fadeTimer) clearTimeout(fadeTimer);
fadeTimer = setTimeout(() => {
if (fabButton && !document.body.classList.contains('fab-mode')) {
fabButton.classList.add('jf-fab-faded');
}
}, FADE_DELAY);
}
function wakeUp() {
if (fadeTimer) clearTimeout(fadeTimer);
if (fabButton) fabButton.classList.remove('jf-fab-faded');
}
// 3. Create FAB
function createFab() {
const container = document.createElement('div');
container.id = 'jf-fab-container';
const avatarDiv = document.createElement('div');
avatarDiv.className = 'jf-fab-avatar';
// Logic to clone the avatar (Image or Dynamic Span)
updateAvatarVisuals(avatarDiv);
const badgeSpan = document.createElement('span');
badgeSpan.className = 'jf-fab-badge';
badgeSpan.innerText = '0';
container.appendChild(avatarDiv);
container.appendChild(badgeSpan);
document.body.appendChild(container);
fabButton = container;
fabButton.addEventListener('click', handleFabClick);
fabButton.addEventListener('mouseenter', wakeUp);
fabButton.addEventListener('mouseleave', scheduleFade);
}
// Helper to copy/clone avatar into FAB
function updateAvatarVisuals(containerDiv) {
containerDiv.innerHTML = ''; // Clear current
if (!avatarSourceElement) return;
// check if it's an IMG tag
if (avatarSourceElement.tagName === 'IMG') {
const img = document.createElement('img');
img.src = avatarSourceElement.src;
containerDiv.appendChild(img);
}
// check if it's a dynamic SPAN (SimpCity/SMG) or IMG wrapped in specific class
else {
// Clone the node to keep background colors/text
const clone = avatarSourceElement.cloneNode(true);
// Remove ID to avoid conflicts
clone.removeAttribute('id');
containerDiv.appendChild(clone);
}
}
// 4. Handle FAB Click
function handleFabClick(e) {
e.preventDefault();
e.stopPropagation();
wakeUp();
const isMenuOpen = triggerElement.classList.contains('is-menuOpen');
if (isMenuOpen) {
// Close logic
document.body.classList.remove('fab-mode');
// Trigger a click on body or the trigger to close XF menu
triggerElement.click();
scheduleFade();
} else {
// Open logic
document.body.classList.add('fab-mode');
triggerElement.click();
setTimeout(() => {
const closer = () => {
document.body.classList.remove('fab-mode');
scheduleFade();
};
// Listen for the next click anywhere to close (XenForo handles the actual close)
document.addEventListener('click', closer, { once: true });
}, 50);
}
}
// 5. Sync Data
function syncData() {
if (!fabButton || !triggerElement) return;
// 1. Sync Badge Count
const count = triggerElement.getAttribute(SITE_CONFIG.badgeAttr);
const badgeEl = fabButton.querySelector('.jf-fab-badge');
if (count && parseInt(count) > 0) {
badgeEl.innerText = count;
badgeEl.classList.add('has-unread');
wakeUp();
scheduleFade();
} else {
badgeEl.classList.remove('has-unread');
}
// 2. Sync Avatar (Optional, if user changes pfp via ajax, rare but possible)
// Only re-render if we suspect a change (checking src or content)
// For simplicity in this lightweight script, we assume avatar doesn't change rapidly without page reload.
}
// 6. Setup Observers
function setupObservers() {
// Observe the Trigger for Badge changes
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' || mutation.type === 'childList') {
syncData();
}
});
});
observer.observe(triggerElement, {
attributes: true,
childList: true,
subtree: true,
attributeFilter: [SITE_CONFIG.badgeAttr, 'class']
});
}
init();
})();