Sort by User: group your Civitai notifications by creator
Hello, everyone!
If you follow a lot of creators, you know how it goes: I get around 200 notifications a day, mostly new LoRAs and new versions, and the notification panel lists them strictly by time. A creator who uploads five styles in an afternoon ends up scattered across the whole list, between everyone else's uploads. I like to go through the links per user instead of time so i asked my sidekick to write a small userscript that does exactly that. Here it is, in case it helps you too.
Sort by User
Sort by User is a userscript: a small piece of JavaScript that the Tampermonkey browser extension runs on Civitai. It does the following:
Adds a Sort by User button next to Hide Read in the notifications panel.
Puts each creator's notifications together: new LoRAs, new versions, comments, follows.
Puts all new followers together, so a run of "has followed you!" notifications doesn't break up the list.
Keeps the list newest-first: each creator's group sits where their newest notification would be.
Leaves everything else in its place: milestones, announcements and other notifications that don't come from a user keep their spot in the timeline.
One more click switches back to the normal order.
Normal order:

With Sort by User on:

In the normal order, only two of freckledvixon's notifications are in view, with another creator's update between them; the rest are hours older and further down the list. With Sort by User on, all six sit together, from 36 minutes to 10 hours ago, followed by the three updates from the creator of the Lelith Hesperax model.
The script
// ==UserScript==
// @name CivitAI - Sort Notifications By User
// @namespace https://github.com/Silhouette052
// @version 1.4
// @description Adds a toggle next to "Hide Read" on Civitai's notifications panel that clusters notifications by author (comments/updates) instead of pure chronological order. Notifications with no identifiable author (milestones, announcements, etc.) stay in their normal chronological slot.
// @author Silhouette052
// @license MIT
// @match https://civitai.red/*
// @match https://civitai.com/*
// @grant none
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
const DEBUG = false;
const log = (...args) => DEBUG && console.log('[Civitai Notification Sorter]', ...args);
// Put all "has followed you!" notifications in one group, where the
// newest one is. Set to false to leave each follow in its place.
const GROUP_FOLLOWS = true;
/* ------------------------------------------------------------------
* Optional: remember the toggle across page loads
* To use it, remove the "// " at the start of each line below, then
* put "// " in front of "let sortEnabled = false;" after them. It
* keeps one value, true or false, in the site's local storage
* (civitai.red and civitai.com each remember their own).
* ------------------------------------------------------------------ */
// const STORAGE_KEY = 'civitai-user-sort-enabled';
// function loadSortEnabled() {
// try { return localStorage.getItem(STORAGE_KEY) === 'true'; } catch (e) { return false; }
// }
// let sortEnabled = loadSortEnabled();
// // Saved from a capture listener so a page handler can't swallow the
// // click, and after a timeout so the button has flipped sortEnabled.
// document.addEventListener('click', (event) => {
// if (!event.target.closest?.('[data-civitai-user-sort-toggle]')) return;
// setTimeout(() => {
// try { localStorage.setItem(STORAGE_KEY, String(sortEnabled)); } catch (e) { /* storage blocked */ }
// });
// }, true);
let sortEnabled = false;
/* ------------------------------------------------------------------
* DOM helpers
* ------------------------------------------------------------------ */
function findHideReadSwitch() {
// Found by its label text. If Civitai's markup changes and this stops
// finding it, inspect the switch in DevTools and adjust the
// .mantine-Switch-root / text match below.
const switches = Array.from(document.querySelectorAll('.mantine-Switch-root'));
return switches.find((el) => el.textContent.trim() === 'Hide Read') || null;
}
function getNotificationItems() {
// Civitai's class names for the list are hashed per build and change
// between deploys, so the list is found by its structure instead:
// every notification is an <a> with a <time> in it. Going up from the
// "Hide Read" switch, the first ancestor holding such a link is the
// panel, and the link's parent is the list.
const hideRead = findHideReadSwitch();
if (!hideRead) return [];
let panel = hideRead.parentElement;
let time = null;
for (let up = 0; panel && up < 8; up++, panel = panel.parentElement) {
time = panel.querySelector('a time[datetime]');
if (time) break;
}
if (!time) {
log('No notifications found near the "Hide Read" switch.');
return [];
}
// The list sits a few levels below the panel (list → Paper → scroll
// area → panel). A link found further down is page content, reached
// because the notification list is empty.
const list = time.closest('a').parentElement;
let depth = 0;
let el = list;
while (el && el !== panel && depth <= 4) { el = el.parentElement; depth++; }
if (el !== panel) {
log('No notification list found next to the "Hide Read" switch.');
return [];
}
return Array.from(list.children).filter((child) => child.tagName === 'A');
}
function extractUsername(item) {
// Comment/update/version-release notifications wrap the author's
// avatar in a link to their profile. Milestones, announcements, and
// similar system notifications don't have this — they come back as
// null and are left in their normal chronological position.
const userLink = item.querySelector('a[href^="/user/"]');
if (!userLink) return null;
const match = (userLink.getAttribute('href') || '').match(/^\/user\/([^/?#]+)/);
return match ? match[1] : null;
}
// Every follower is a different user, so without GROUP_FOLLOWS each
// follow would be a group of its own.
const FOLLOWS = Symbol('follows');
function isFollowNotification(item) {
return item.textContent.includes(' has followed you!');
}
/* ------------------------------------------------------------------
* Sorting
* The list is rendered as a CSS grid (Mantine SimpleGrid). Instead of
* physically moving the <a> nodes around with insertBefore/appendChild
* (which can fight with React's own re-rendering of this list and
* throw DOM errors), we just set the CSS `order` property on each
* item. That's purely visual, leaves the actual DOM order alone, and
* is trivial to reset.
* ------------------------------------------------------------------ */
function applySort() {
const items = getNotificationItems();
if (items.length === 0) return;
if (!sortEnabled) {
items.forEach((item) => { item.style.order = ''; });
return;
}
// Gather all of an author's items into one group, and all follows
// into another; anything without a detected author becomes its own
// singleton group.
//
// The feed arrives newest-first and the DOM order never changes, so
// groups are created in the order of each group's newest item and
// items within a group are newest-first too. The overall feed still
// trends newest-first, with each group's older notifications pulled
// up to sit right after its newest one.
const groups = [];
const groupIndexByKey = new Map();
for (const item of items) {
const key = GROUP_FOLLOWS && isFollowNotification(item) ? FOLLOWS : extractUsername(item);
if (key === null) {
groups.push([item]);
continue;
}
if (groupIndexByKey.has(key)) {
groups[groupIndexByKey.get(key)].push(item);
} else {
groupIndexByKey.set(key, groups.length);
groups.push([item]);
}
}
let order = 0;
for (const group of groups) {
for (const item of group) {
item.style.order = String(order++);
}
}
log(`sorted ${items.length} notifications into ${groups.length} groups`);
}
/* ------------------------------------------------------------------
* Toggle button, inserted next to the "Hide Read" switch
* ------------------------------------------------------------------ */
function updateButtonStyle(btn) {
btn.setAttribute('aria-pressed', String(sortEnabled));
if (sortEnabled) {
btn.style.background = 'var(--mantine-color-blue-filled, #1c7ed6)';
btn.style.color = '#fff';
btn.style.borderColor = 'transparent';
} else {
btn.style.background = 'transparent';
btn.style.color = 'inherit';
btn.style.borderColor = 'var(--mantine-color-gray-4, #ced4da)';
}
}
function makeToggleButton() {
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = 'Sort by User';
btn.title = 'Group notifications by the user who sent them';
btn.setAttribute('data-civitai-user-sort-toggle', 'true');
btn.style.cssText = [
'cursor:pointer',
'border-radius:999px',
'padding:6px 12px',
'font-size:13px',
'font-weight:600',
'border:1px solid var(--mantine-color-gray-4, #ced4da)',
'background:transparent',
'color:inherit',
'transition:background .15s,color .15s',
'white-space:nowrap',
].join(';');
updateButtonStyle(btn);
btn.addEventListener('click', () => {
sortEnabled = !sortEnabled;
updateButtonStyle(btn);
applySort();
});
return btn;
}
function ensureToggleButton() {
if (document.querySelector('[data-civitai-user-sort-toggle]')) return;
// Insert our button right after the "Hide Read" switch's wrapper.
const hideReadSwitch = findHideReadSwitch();
if (!hideReadSwitch) {
log('Could not find the "Hide Read" switch to anchor the button next to.');
return;
}
const btn = makeToggleButton();
hideReadSwitch.insertAdjacentElement('afterend', btn);
log('Inserted "Sort by User" toggle.');
}
/* ------------------------------------------------------------------
* Wiring: re-run on DOM changes (notifications panel opening, tab
* switches, new items loading, read/unread state changes)
* Throttled rather than debounced: a debounce restarts its timer on
* every mutation, so on a page that keeps changing it would never run.
* ------------------------------------------------------------------ */
let scheduled = null;
function scheduleRun() {
if (scheduled) return;
scheduled = setTimeout(() => {
scheduled = null;
ensureToggleButton();
applySort();
}, 250);
}
const observer = new MutationObserver(scheduleRun);
observer.observe(document.body, { childList: true, subtree: true });
window.addEventListener('load', scheduleRun);
scheduleRun();
})();Optional: remember the toggle
Out of the box, Sort by User starts switched off every time you load a Civitai page. I like it that way since i usually load all events to dom on notification tab and turn it on, so it does not jump while loading, but if you'd rather have it stay the way you left it, the script has a few lines for that near the top, commented out.
Those lines should then look like this:
const STORAGE_KEY = 'civitai-user-sort-enabled';
function loadSortEnabled() {
try { return localStorage.getItem(STORAGE_KEY) === 'true'; } catch (e) { return false; }
}
let sortEnabled = loadSortEnabled();
// Saved from a capture listener so a page handler can't swallow the
// click, and after a timeout so the button has flipped sortEnabled.
document.addEventListener('click', (event) => {
if (!event.target.closest?.('[data-civitai-user-sort-toggle]')) return;
setTimeout(() => {
try { localStorage.setItem(STORAGE_KEY, String(sortEnabled)); } catch (e) { /* storage blocked */ }
});
}, true);
// let sortEnabled = false;
Is it safe?
A userscript runs inside the page, so it's fair to ask what it does. This one:
Only changes the order things are shown in. It doesn't click, hide, mark as read or delete anything.
Sends nothing anywhere. It makes no network requests, and with @grant none Tampermonkey gives it no permissions beyond what the page itself has.
Stores nothing: no cookies, no local storage, no settings. That's also why the button starts switched off after a reload.
Runs only on civitai.red and civitai.com, as the two @match lines at the top say.
Is short and readable, about 200 lines with comments. Read it before you paste it, as you should with any script.
Good to know
The button starts off on every page load. Once switched on, it stays on as you move around the site and open and close the panel, until you reload the page.
Only the notifications loaded so far are sorted. Scroll down in the panel to load older ones; they're added to the groups as they arrive.
It depends on Civitai's layout. It finds the Hide Read switch by its English label, and the notification list as the links with a time stamp next to it. It doesn't rely on Civitai's internal class names, which change with every Civitai update. If a bigger redesign breaks it, the button doesn't appear, or it does but nothing moves; nothing else on the page is affected. Leave a comment and I'll update it.


