// ==UserScript==
// @name Comick Mention Notifier
// @namespace https://github.com/GooglyBlox
// @version 1.1
// @description Shows notifications when someone mentions you in Comick comments
// @author GooglyBlox
// @match https://comick.io/*
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @connect auth.comick.io
// @connect api.comick.io
// @license MIT
// ==/UserScript==
(function() {
'use strict';
let userData = null;
let mentionCache = new Set(GM_getValue('mentionCache', []));
let readMentions = new Set(GM_getValue('readMentions', []));
let lastChecked = GM_getValue('lastChecked', 0);
let isInitialized = GM_getValue('isInitialized', false);
let currentMentions = [];
let currentPage = 1;
let totalPages = 1;
function waitForElement(selector, timeout = 15000) {
return new Promise((resolve) => {
if (document.querySelector(selector)) {
return resolve(document.querySelector(selector));
}
const observer = new MutationObserver(() => {
if (document.querySelector(selector)) {
observer.disconnect();
resolve(document.querySelector(selector));
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
setTimeout(() => {
observer.disconnect();
resolve(null);
}, timeout);
});
}
async function extractUserIdFromHeader() {
const myListLink = await waitForElement('a[href*="/user/"][href*="/list"]');
if (myListLink) {
const match = myListLink.href.match(/\/user\/([^\/]+)\/list/);
if (match) {
return match[1];
}
}
return null;
}
async function fetchUsername(userId) {
try {
const response = await makeRequest('https://auth.comick.io/sessions/whoami');
const username = response?.identity?.traits?.username;
if (username) {
return username;
}
} catch (error) {
// Silent error handling
}
return null;
}
async function initializeUserData() {
const userId = await extractUserIdFromHeader();
if (!userId) return null;
const username = await fetchUsername(userId);
if (!username) return null;
return { id: userId, username: username };
}
function makeRequest(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: url,
onload: function(response) {
try {
const data = JSON.parse(response.responseText);
resolve(data);
} catch (error) {
reject(error);
}
},
onerror: function(error) {
reject(error);
}
});
});
}
async function fetchUserComments(userId, page = 1) {
try {
const response = await makeRequest(`https://api.comick.io/user/${userId}/comments?page=${page}`);
return response || [];
} catch (error) {
return [];
}
}
function buildCommentUrl(comment, commentId) {
const comic = comment.md_chapters?.md_comics;
const chapter = comment.md_chapters;
if (!comic?.slug || !chapter?.hid || !chapter?.chap || !chapter?.lang) {
return null;
}
return `https://comick.io/comic/${comic.slug}/${chapter.hid}-chapter-${chapter.chap}-${chapter.lang}#comment-${commentId}`;
}
function removeMentionFromContent(content, username) {
if (!content || !username) return content;
const escapedUsername = username.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const mentionPattern = new RegExp(`@${escapedUsername}\\b`, 'gi');
return content.replace(mentionPattern, '').replace(/^\s+/, '').trim();
}
function findMentions(comments, username) {
const mentions = [];
const mentionPattern = new RegExp(`@${username}\\b`, 'i');
comments.forEach((comment) => {
if (comment.other_comments && comment.other_comments.length > 0) {
const repliesWithMentions = comment.other_comments.filter(reply => {
const replyUsername = reply.identities?.traits?.username;
return replyUsername !== username &&
reply.parsed &&
mentionPattern.test(reply.parsed);
});
if (repliesWithMentions.length > 0) {
mentions.push({
id: `mention-${comment.id}`,
originalComment: {
id: comment.id,
content: comment.parsed,
createdAt: comment.created_at,
url: buildCommentUrl(comment, comment.id)
},
replies: repliesWithMentions.map(reply => ({
id: reply.id,
content: removeMentionFromContent(reply.parsed, username),
author: reply.identities?.traits?.username,
createdAt: reply.created_at,
url: buildCommentUrl(comment, reply.id)
})),
chapterTitle: comment.md_chapters?.md_comics?.title || 'Unknown',
chapterNumber: comment.md_chapters?.chap || 'Unknown',
chapterUrl: buildCommentUrl(comment, comment.id)?.split('#')[0]
});
}
}
});
return mentions;
}
async function performFirstTimeInitialization() {
if (!userData || isInitialized) {
return;
}
const allMentions = [];
let page = 1;
let hasMorePages = true;
while (hasMorePages && page <= 50) {
const comments = await fetchUserComments(userData.id, page);
if (comments.length === 0) {
hasMorePages = false;
break;
}
const mentions = findMentions(comments, userData.username);
allMentions.push(...mentions);
page++;
if (page > 50) {
break;
}
}
allMentions.forEach(mention => {
mentionCache.add(mention.id);
readMentions.add(mention.id);
mention.replies.forEach(reply => {
readMentions.add(`reply-${reply.id}`);
});
});
isInitialized = true;
lastChecked = Date.now();
GM_setValue('isInitialized', true);
GM_setValue('lastChecked', lastChecked);
GM_setValue('mentionCache', Array.from(mentionCache));
GM_setValue('readMentions', Array.from(readMentions));
}
async function checkForMentions() {
if (!userData) {
return [];
}
const allMentions = [];
let page = 1;
let hasMorePages = true;
while (hasMorePages && page <= 10) {
const comments = await fetchUserComments(userData.id, page);
if (comments.length === 0) {
hasMorePages = false;
break;
}
const mentions = findMentions(comments, userData.username);
allMentions.push(...mentions);
const oldestComment = comments[comments.length - 1];
if (oldestComment && new Date(oldestComment.created_at).getTime() < lastChecked) {
hasMorePages = false;
}
page++;
}
const newMentions = allMentions.filter(mention =>
!mentionCache.has(mention.id) ||
new Date(mention.originalComment.createdAt).getTime() > lastChecked
);
newMentions.forEach(mention => mentionCache.add(mention.id));
return allMentions;
}
function createNotificationIcon() {
const icon = document.createElement('div');
icon.className = 'relative cursor-pointer';
icon.innerHTML = `
<div class="rounded-full h-8 w-8 flex-none flex items-center justify-center bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 text-gray-600 dark:text-gray-400">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" />
</svg>
</div>
<div id="mention-badge" class="absolute bg-red-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center hidden z-10" style="font-size: 10px; line-height: 1; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); bottom: -5px; right: -2px;">
<span id="mention-count">0</span>
</div>
`;
return icon;
}
function createModal() {
const modal = document.createElement('div');
modal.id = 'mention-modal';
modal.className = 'fixed inset-0 bg-black bg-opacity-60 z-50 flex items-center justify-center hidden backdrop-blur-sm';
modal.innerHTML = `
<div class="bg-white dark:bg-gray-900 rounded-xl max-w-4xl w-full mx-4 max-h-[85vh] overflow-hidden border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
<div>
<h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100">Your Mentions</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">Comments mentioning you across ComicK</p>
</div>
<button id="close-modal" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 p-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div id="mentions-list" class="overflow-y-auto" style="max-height: calc(85vh - 180px);">
<div class="flex items-center justify-center p-8">
<div class="text-gray-500 dark:text-gray-400">Loading mentions...</div>
</div>
</div>
<div id="modal-pagination" class="flex items-center justify-between p-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 hidden">
<div class="text-sm text-gray-600 dark:text-gray-400">
Page <span id="current-page">1</span> of <span id="total-pages">1</span>
</div>
<div class="flex space-x-2">
<button id="prev-page" class="px-3 py-1 text-sm bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
Previous
</button>
<button id="next-page" class="px-3 py-1 text-sm bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
Next
</button>
</div>
</div>
</div>
`;
return modal;
}
function renderMentionItem(mention) {
const isRead = readMentions.has(mention.id);
const unreadReplies = mention.replies.filter(reply => !readMentions.has(`reply-${reply.id}`));
const allRepliesRead = mention.replies.every(reply => readMentions.has(`reply-${reply.id}`));
const showMentionButton = !isRead || !allRepliesRead;
return `
<div class="border-b border-gray-200 dark:border-gray-700 last:border-b-0">
<div class="p-6 bg-white dark:bg-gray-900">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center space-x-3">
<h3 class="font-medium text-gray-900 dark:text-gray-100">${mention.chapterTitle}</h3>
<span class="text-sm text-gray-500 dark:text-gray-400">Chapter ${mention.chapterNumber}</span>
${unreadReplies.length > 0 ? `<span class="bg-red-500 text-white text-xs px-2 py-1 rounded-full">${unreadReplies.length} new</span>` : ''}
</div>
${showMentionButton ? `
<button class="mark-mention-read text-xs px-3 py-1 rounded-lg bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors" data-mention-id="${mention.id}">
Mark as Read
</button>
` : ''}
</div>
<div class="space-y-4">
<div class="flex space-x-3">
<div class="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center flex-shrink-0">
<span class="text-white text-sm font-medium">You</span>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center space-x-2 mb-1">
<span class="font-medium text-gray-900 dark:text-gray-100">${userData?.username || 'You'}</span>
<span class="text-sm text-gray-500 dark:text-gray-400">${new Date(mention.originalComment.createdAt).toLocaleString()}</span>
${mention.originalComment.url ? `<a href="${mention.originalComment.url}" target="_blank" class="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200 underline">View</a>` : ''}
</div>
<div class="text-sm text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-800 p-3 rounded-lg">${mention.originalComment.content.trim()}</div>
</div>
</div>
<div class="pl-11 space-y-4">
${mention.replies.map(reply => {
const replyIsRead = readMentions.has(`reply-${reply.id}`);
return `
<div class="flex space-x-3">
<div class="w-8 h-8 bg-gray-500 dark:bg-gray-600 rounded-full flex items-center justify-center flex-shrink-0">
<span class="text-white text-sm font-medium">${reply.author.charAt(0).toUpperCase()}</span>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center space-x-2 mb-1">
<span class="font-medium text-gray-900 dark:text-gray-100">${reply.author}</span>
<span class="text-sm text-gray-500 dark:text-gray-400">${new Date(reply.createdAt).toLocaleString()}</span>
${!replyIsRead ? '<span class="bg-orange-500 text-white text-xs px-2 py-0.5 rounded-full">New</span>' : ''}
${reply.url ? `<a href="${reply.url}" target="_blank" class="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200 underline">View</a>` : ''}
${!replyIsRead ? `
<button class="mark-reply-read text-xs px-2 py-1 rounded bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors" data-reply-id="${reply.id}">
Mark Read
</button>
` : ''}
</div>
<div class="text-sm text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-800 p-3 rounded-lg">${reply.content.trim()}</div>
</div>
</div>
`;
}).join('')}
</div>
</div>
</div>
</div>
`;
}
function updateMentionDisplay(mentions) {
const badge = document.getElementById('mention-badge');
const count = document.getElementById('mention-count');
if (!badge || !count) return;
currentMentions = mentions;
const unreadCount = mentions.reduce((total, mention) => {
const unreadReplies = mention.replies.filter(reply => !readMentions.has(`reply-${reply.id}`));
return total + unreadReplies.length;
}, 0);
if (unreadCount > 0) {
badge.classList.remove('hidden');
count.textContent = unreadCount > 99 ? '99+' : unreadCount;
} else {
badge.classList.add('hidden');
}
}
function renderMentions(mentions, page = 1) {
const list = document.getElementById('mentions-list');
const pagination = document.getElementById('modal-pagination');
if (!list) return;
const itemsPerPage = 5;
totalPages = Math.max(1, Math.ceil(mentions.length / itemsPerPage));
currentPage = Math.min(page, totalPages);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const pageItems = mentions.slice(startIndex, endIndex);
if (pageItems.length > 0) {
list.innerHTML = pageItems.map(renderMentionItem).join('');
setupItemEvents();
if (totalPages > 1) {
pagination.classList.remove('hidden');
document.getElementById('current-page').textContent = currentPage;
document.getElementById('total-pages').textContent = totalPages;
document.getElementById('prev-page').disabled = currentPage === 1;
document.getElementById('next-page').disabled = currentPage === totalPages;
} else {
pagination.classList.add('hidden');
}
} else {
list.innerHTML = '<div class="flex items-center justify-center p-8"><div class="text-gray-500 dark:text-gray-400">No mentions found</div></div>';
pagination.classList.add('hidden');
}
}
function setupItemEvents() {
document.querySelectorAll('.mark-mention-read').forEach(button => {
button.addEventListener('click', function() {
const mentionId = this.getAttribute('data-mention-id');
markMentionAsRead(mentionId);
});
});
document.querySelectorAll('.mark-reply-read').forEach(button => {
button.addEventListener('click', function() {
const replyId = this.getAttribute('data-reply-id');
markReplyAsRead(replyId);
});
});
}
function setupModalEvents() {
const prevButton = document.getElementById('prev-page');
const nextButton = document.getElementById('next-page');
if (prevButton) {
prevButton.replaceWith(prevButton.cloneNode(true));
document.getElementById('prev-page').addEventListener('click', () => {
if (currentPage > 1) {
renderMentions(currentMentions, currentPage - 1);
}
});
}
if (nextButton) {
nextButton.replaceWith(nextButton.cloneNode(true));
document.getElementById('next-page').addEventListener('click', () => {
if (currentPage < totalPages) {
renderMentions(currentMentions, currentPage + 1);
}
});
}
}
function markMentionAsRead(mentionId) {
readMentions.add(mentionId);
const mention = currentMentions.find(m => m.id === mentionId);
if (mention) {
mention.replies.forEach(reply => {
readMentions.add(`reply-${reply.id}`);
});
}
GM_setValue('readMentions', Array.from(readMentions));
renderMentions(currentMentions, currentPage);
updateBadgeCount();
}
function markReplyAsRead(replyId) {
readMentions.add(`reply-${replyId}`);
GM_setValue('readMentions', Array.from(readMentions));
renderMentions(currentMentions, currentPage);
updateBadgeCount();
}
function updateBadgeCount() {
const badge = document.getElementById('mention-badge');
const count = document.getElementById('mention-count');
if (!badge || !count) return;
const unreadCount = currentMentions.reduce((total, mention) => {
const unreadReplies = mention.replies.filter(reply => !readMentions.has(`reply-${reply.id}`));
return total + unreadReplies.length;
}, 0);
if (unreadCount > 0) {
badge.classList.remove('hidden');
count.textContent = unreadCount > 99 ? '99+' : unreadCount;
} else {
badge.classList.add('hidden');
}
}
async function showModal() {
const modal = document.getElementById('mention-modal');
if (!modal) return;
modal.classList.remove('hidden');
const list = document.getElementById('mentions-list');
if (list) {
list.innerHTML = '<div class="flex items-center justify-center p-8"><div class="text-gray-500 dark:text-gray-400">Loading mentions...</div></div>';
}
if (!userData) {
userData = await initializeUserData();
}
if (userData) {
const mentions = await checkForMentions();
currentMentions = mentions;
renderMentions(mentions);
setupModalEvents();
} else {
if (list) {
list.innerHTML = '<div class="flex items-center justify-center p-8"><div class="text-red-500 dark:text-red-400">Unable to load user data</div></div>';
}
}
}
async function insertNotificationIcon() {
const container = await waitForElement('.flex.items-center.justify-between.space-x-2.md\\:space-x-3');
if (!container || document.getElementById('mention-notifier')) return;
const icon = createNotificationIcon();
icon.id = 'mention-notifier';
const genderIconContainer = container.querySelector('.items-center.flex.justify-center.w-8.h-8');
if (genderIconContainer) {
container.insertBefore(icon, genderIconContainer);
} else {
container.insertBefore(icon, container.firstElementChild);
}
let modal = document.getElementById('mention-modal');
if (!modal) {
modal = createModal();
document.body.appendChild(modal);
}
icon.addEventListener('click', showModal);
const closeButton = document.getElementById('close-modal');
if (closeButton) {
closeButton.replaceWith(closeButton.cloneNode(true));
document.getElementById('close-modal').addEventListener('click', () => {
modal.classList.add('hidden');
});
}
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.classList.add('hidden');
}
});
}
async function initializeMentionChecker() {
userData = await initializeUserData();
if (!userData) {
setTimeout(initializeMentionChecker, 5000);
return;
}
if (!isInitialized) {
await performFirstTimeInitialization();
}
const mentions = await checkForMentions();
await insertNotificationIcon();
updateMentionDisplay(mentions);
lastChecked = Date.now();
GM_setValue('lastChecked', lastChecked);
GM_setValue('mentionCache', Array.from(mentionCache));
}
function startPeriodicCheck() {
initializeMentionChecker();
setInterval(async () => {
if (userData) {
const mentions = await checkForMentions();
updateMentionDisplay(mentions);
lastChecked = Date.now();
GM_setValue('lastChecked', lastChecked);
GM_setValue('mentionCache', Array.from(mentionCache));
}
}, 300000);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startPeriodicCheck);
} else {
startPeriodicCheck();
}
const observer = new MutationObserver(() => {
if (!document.getElementById('mention-notifier') && userData) {
insertNotificationIcon();
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
})();