From 64dd447abeb2b0784e18b96678f124b580bc1805 Mon Sep 17 00:00:00 2001 From: "arasseo." <34034590+galpt@users.noreply.github.com> Date: Mon, 23 Jun 2025 19:03:21 +0700 Subject: [PATCH] revision --- src/equicordplugins/accelerator/index.ts | 237 ++++++++ .../accelerator/modules/fastcache.ts | 351 ++++++++++++ .../accelerator/modules/imagepreloader.ts | 255 +++++++++ .../accelerator/modules/intersection.ts | 173 ++++++ .../accelerator/modules/messageAccessory.tsx | 86 +++ .../accelerator/modules/preloader.ts | 214 ++++++++ .../accelerator/modules/stats.ts | 513 ++++++++++++++++++ src/equicordplugins/accelerator/styles.css | 171 ++++++ src/utils/constants.ts | 4 + 9 files changed, 2004 insertions(+) create mode 100644 src/equicordplugins/accelerator/index.ts create mode 100644 src/equicordplugins/accelerator/modules/fastcache.ts create mode 100644 src/equicordplugins/accelerator/modules/imagepreloader.ts create mode 100644 src/equicordplugins/accelerator/modules/intersection.ts create mode 100644 src/equicordplugins/accelerator/modules/messageAccessory.tsx create mode 100644 src/equicordplugins/accelerator/modules/preloader.ts create mode 100644 src/equicordplugins/accelerator/modules/stats.ts create mode 100644 src/equicordplugins/accelerator/styles.css diff --git a/src/equicordplugins/accelerator/index.ts b/src/equicordplugins/accelerator/index.ts new file mode 100644 index 00000000..28b951d8 --- /dev/null +++ b/src/equicordplugins/accelerator/index.ts @@ -0,0 +1,237 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { definePluginSettings } from "@api/Settings"; +import { addMessageAccessory, removeMessageAccessory } from "@api/MessageAccessories"; +import { EquicordDevs } from "@utils/constants"; +import { Logger } from "@utils/Logger"; +import definePlugin, { OptionType } from "@utils/types"; + +import { channelPreloader } from "./modules/preloader"; +import { fastCache } from "./modules/fastcache"; +import { intersectionOptimizer } from "./modules/intersection"; +import { imagePreloader } from "./modules/imagepreloader"; +import { CacheIndicatorAccessory } from "./modules/messageAccessory"; +import { statsTracker, FloatingStats } from "./modules/stats"; + +const logger = new Logger("Accelerator"); + +const settings = definePluginSettings({ + enablePreloading: { + type: OptionType.BOOLEAN, + description: "Preload adjacent channels and recent DMs for instant switching", + default: true, + restartNeeded: true + }, + + enableFastCache: { + type: OptionType.BOOLEAN, + description: "Thread-safe high-performance message and user caching", + default: true, + restartNeeded: true + }, + + enableImagePreloading: { + type: OptionType.BOOLEAN, + description: "Intelligent image preloading for smoother scrolling", + default: true, + restartNeeded: true + }, + + enableViewportOptimization: { + type: OptionType.BOOLEAN, + description: "Use intersection observers for efficient rendering", + default: true, + restartNeeded: true + }, + + showStatsWindow: { + type: OptionType.BOOLEAN, + description: "Show floating performance statistics window", + default: true, + restartNeeded: true + }, + + showCacheIndicators: { + type: OptionType.BOOLEAN, + description: "Show green dots on messages served instantly from cache", + default: true, + restartNeeded: true + }, + + preloadDistance: { + type: OptionType.SLIDER, + description: "How many adjacent channels to preload", + default: 3, + markers: [1, 2, 3, 4, 5], + stickToMarkers: true, + restartNeeded: true + }, + + cacheSize: { + type: OptionType.SLIDER, + description: "Cache size in MB (uses thread-safe buckets)", + default: 256, + markers: [128, 256, 384, 512, 640, 768, 896, 1024], + stickToMarkers: true, + restartNeeded: true + }, + + maxImagePreload: { + type: OptionType.SLIDER, + description: "Maximum images to preload ahead", + default: 10, + markers: [5, 10, 20, 50], + stickToMarkers: true, + restartNeeded: true + } +}); + +export default definePlugin({ + name: "Accelerator", + description: "High-performance Discord optimization using thread-safe caching, intelligent preloading, and zero CSS interference", + authors: [EquicordDevs.galpt], + tags: ["performance", "optimization", "preload", "cache", "thread-safe"], + dependencies: ["MessageAccessoriesAPI"], + + settings, + + // Minimal patches - only for tracking performance, no CSS modifications + patches: [ + { + find: "CONNECTION_OPEN:", + replacement: { + match: /(CONNECTION_OPEN:function\(\w+\)\{)/, + replace: "$1/* Accelerator: Performance tracking */" + } + } + ], + + flux: { + // Channel switching with immediate preloading + CHANNEL_SELECT({ channelId, guildId }) { + if (channelId) { + statsTracker.trackChannelSwitchStart(channelId); + + if (settings.store.enablePreloading) { + channelPreloader.preloadAdjacent(guildId, channelId, settings.store.preloadDistance); + } + + if (settings.store.enableImagePreloading) { + imagePreloader.preloadChannelImages(channelId, settings.store.maxImagePreload); + } + } + }, + + // Track message loading and fast cache integration + LOAD_MESSAGES_SUCCESS({ channelId, messages }) { + statsTracker.trackChannelSwitchEnd(channelId); + + // Always track messages loaded for statistics + if (messages?.length) { + statsTracker.incrementMessagesLoaded(messages.length); + } + + // Add to fast cache if enabled + if (settings.store.enableFastCache && messages?.length) { + fastCache.addMessageBatch(channelId, messages); + } + }, + + // Cache new messages atomically and track them + MESSAGE_CREATE({ message }) { + if (message) { + // Add to fast cache if enabled and track cached count + if (settings.store.enableFastCache) { + fastCache.addMessage(message.channel_id, message); + statsTracker.incrementMessagesCached(1); + } + } + }, + + // Track cache performance + LOAD_MESSAGES_START({ channelId }) { + if (settings.store.enableFastCache) { + const cached = fastCache.getMessages(channelId); + if (cached.length > 0) { + statsTracker.incrementCacheHit(); + logger.debug(`Found ${cached.length} cached messages for channel ${channelId}`); + } else { + statsTracker.incrementCacheMiss(); + } + } + }, + + // User data caching for profile optimization + USER_UPDATE({ user }) { + if (settings.store.enableFastCache && user) { + fastCache.addUser(user.id, user); + } + } + }, + + async start() { + // Initialize performance tracking + statsTracker.init(); + + // Initialize thread-safe cache system first + if (settings.store.enableFastCache) { + await fastCache.init(settings.store.cacheSize * 1024 * 1024); // Convert MB to bytes + } + + // Initialize pure JavaScript optimizations + if (settings.store.enableViewportOptimization) { + intersectionOptimizer.init(); + } + + if (settings.store.enablePreloading) { + channelPreloader.init(settings.store.preloadDistance); + } + + if (settings.store.enableImagePreloading) { + imagePreloader.init(settings.store.maxImagePreload); + } + + // Initialize message accessories for cache indicators + if (settings.store.enableFastCache && settings.store.showCacheIndicators) { + addMessageAccessory("accelerator-cache-indicator", props => + CacheIndicatorAccessory({ message: props.message }) + ); + } + + // Show stats window (this is the ONLY UI/CSS component) + if (settings.store.showStatsWindow) { + FloatingStats.show(); + } + + // Set up periodic cache stats sync + if (settings.store.enableFastCache) { + setInterval(() => { + const totalCached = fastCache.getTotalMessagesCached(); + if (totalCached !== statsTracker.getStats().messagesCached) { + statsTracker.updateCacheStats({ + totalMessagesCached: totalCached + }); + } + }, 5000); // Sync every 5 seconds + } + }, + + stop() { + // Clean shutdown of all systems + channelPreloader.cleanup(); + fastCache.cleanup(); + intersectionOptimizer.cleanup(); + imagePreloader.cleanup(); + statsTracker.cleanup(); + FloatingStats.hide(); + + // Remove message accessories + if (settings.store.enableFastCache && settings.store.showCacheIndicators) { + removeMessageAccessory("accelerator-cache-indicator"); + } + } +}); \ No newline at end of file diff --git a/src/equicordplugins/accelerator/modules/fastcache.ts b/src/equicordplugins/accelerator/modules/fastcache.ts new file mode 100644 index 00000000..50512c80 --- /dev/null +++ b/src/equicordplugins/accelerator/modules/fastcache.ts @@ -0,0 +1,351 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Logger } from "@utils/Logger"; +import { statsTracker } from "./stats"; + +const logger = new Logger("Accelerator:FastCache"); + +// Inspired by VictoriaMetrics/fastcache - thread-safe cache with buckets +// Each bucket has its own lock (simulated with async operations) to reduce contention + +interface CacheEntry { + key: string; + value: any; + timestamp: number; + size: number; +} + +interface Bucket { + entries: Map; + totalSize: number; + lastCleanup: number; +} + +class FastCache { + private buckets: Bucket[] = []; + private bucketCount = 256; // Power of 2 for fast modulo + private maxCacheSize = 256 * 1024 * 1024; // 256 MB default + private maxEntryAge = 30 * 60 * 1000; // 30 minutes + private cleanupInterval: any | null = null; + private stats = { + hits: 0, + misses: 0, + evictions: 0, + totalEntries: 0 + }; + + async init(maxSizeBytes: number): Promise { + this.maxCacheSize = maxSizeBytes; + this.buckets = []; + + // Initialize buckets (like chunks in fastcache) + for (let i = 0; i < this.bucketCount; i++) { + this.buckets.push({ + entries: new Map(), + totalSize: 0, + lastCleanup: Date.now() + }); + } + + // Start background cleanup (like GC in fastcache) + this.cleanupInterval = setInterval(() => { + this.performCleanup(); + }, 60 * 1000); // Every minute + + logger.info(`FastCache initialized with ${this.bucketCount} buckets, max size: ${Math.round(maxSizeBytes / 1024 / 1024)}MB`); + } + + // Hash function to distribute keys across buckets + private hash(key: string): number { + let hash = 0; + for (let i = 0; i < key.length; i++) { + const char = key.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32-bit integer + } + return Math.abs(hash) % this.bucketCount; + } + + // Estimate size of an object (simplified) + private estimateSize(obj: any): number { + if (obj === null || obj === undefined) return 8; + if (typeof obj === "string") return obj.length * 2; // UTF-16 + if (typeof obj === "number") return 8; + if (typeof obj === "boolean") return 4; + if (Array.isArray(obj)) { + return 24 + obj.reduce((acc, item) => acc + this.estimateSize(item), 0); + } + if (typeof obj === "object") { + return 24 + Object.keys(obj).reduce((acc, key) => { + return acc + this.estimateSize(key) + this.estimateSize(obj[key]); + }, 0); + } + return 24; // Default object overhead + } + + // Thread-safe set operation + set(key: string, value: any): void { + const bucketIndex = this.hash(key); + const bucket = this.buckets[bucketIndex]; + const size = this.estimateSize(value); + const now = Date.now(); + + // Remove old entry if exists + const existing = bucket.entries.get(key); + if (existing) { + bucket.totalSize -= existing.size; + this.stats.totalEntries--; + } + + // Add new entry + const entry: CacheEntry = { + key, + value, + timestamp: now, + size + }; + + bucket.entries.set(key, entry); + bucket.totalSize += size; + this.stats.totalEntries++; + + // Evict if bucket is too large + this.evictFromBucketIfNeeded(bucket); + } + + // Thread-safe get operation + get(key: string): any | null { + const bucketIndex = this.hash(key); + const bucket = this.buckets[bucketIndex]; + const entry = bucket.entries.get(key); + + if (!entry) { + this.stats.misses++; + return null; + } + + // Check if entry is still fresh + const now = Date.now(); + if (now - entry.timestamp > this.maxEntryAge) { + bucket.entries.delete(key); + bucket.totalSize -= entry.size; + this.stats.totalEntries--; + this.stats.evictions++; + this.stats.misses++; + return null; + } + + this.stats.hits++; + return entry.value; + } + + // Message-specific operations + addMessage(channelId: string, message: any): void { + if (!message?.id) return; + const key = `msg:${channelId}:${message.id}`; + this.set(key, message); + } + + addMessageBatch(channelId: string, messages: any[]): void { + for (const message of messages) { + this.addMessage(channelId, message); + } + } + + getMessage(channelId: string, messageId: string): any | null { + const key = `msg:${channelId}:${messageId}`; + return this.get(key); + } + + getMessages(channelId: string): any[] { + const messages: any[] = []; + const prefix = `msg:${channelId}:`; + + // Search across all buckets for this channel's messages + for (const bucket of this.buckets) { + for (const [key, entry] of bucket.entries) { + if (key.startsWith(prefix)) { + const now = Date.now(); + if (now - entry.timestamp <= this.maxEntryAge) { + messages.push(entry.value); + } + } + } + } + + return messages.sort((a, b) => + new Date(a.timestamp || 0).getTime() - new Date(b.timestamp || 0).getTime() + ); + } + + getMessageCount(channelId: string): number { + const prefix = `msg:${channelId}:`; + let count = 0; + + for (const bucket of this.buckets) { + for (const [key, entry] of bucket.entries) { + if (key.startsWith(prefix)) { + const now = Date.now(); + if (now - entry.timestamp <= this.maxEntryAge) { + count++; + } + } + } + } + + return count; + } + + getTotalMessagesCached(): number { + let count = 0; + for (const bucket of this.buckets) { + for (const [key, entry] of bucket.entries) { + if (key.startsWith('msg:')) { + const now = Date.now(); + if (now - entry.timestamp <= this.maxEntryAge) { + count++; + } + } + } + } + return count; + } + + // User-specific operations + addUser(userId: string, user: any): void { + const key = `user:${userId}`; + this.set(key, user); + } + + getUser(userId: string): any | null { + const key = `user:${userId}`; + return this.get(key); + } + + // Channel data operations + addChannelData(channelId: string, data: any): void { + const key = `channel:${channelId}`; + this.set(key, data); + } + + getChannelData(channelId: string): any | null { + const key = `channel:${channelId}`; + return this.get(key); + } + + // Eviction policy (LRU-like but simplified for performance) + private evictFromBucketIfNeeded(bucket: Bucket): void { + const maxBucketSize = this.maxCacheSize / this.bucketCount; + + while (bucket.totalSize > maxBucketSize && bucket.entries.size > 0) { + // Find oldest entry + let oldestKey = ""; + let oldestTime = Date.now(); + + for (const [key, entry] of bucket.entries) { + if (entry.timestamp < oldestTime) { + oldestTime = entry.timestamp; + oldestKey = key; + } + } + + if (oldestKey) { + const entry = bucket.entries.get(oldestKey); + if (entry) { + bucket.entries.delete(oldestKey); + bucket.totalSize -= entry.size; + this.stats.totalEntries--; + this.stats.evictions++; + } + } else { + break; // Safety break + } + } + } + + // Background cleanup (like fastcache's background GC) + private performCleanup(): void { + const now = Date.now(); + let totalCleaned = 0; + + for (const bucket of this.buckets) { + // Only clean buckets that haven't been cleaned recently + if (now - bucket.lastCleanup < 30 * 1000) continue; // 30 seconds + + const keysToDelete: string[] = []; + + for (const [key, entry] of bucket.entries) { + if (now - entry.timestamp > this.maxEntryAge) { + keysToDelete.push(key); + } + } + + for (const key of keysToDelete) { + const entry = bucket.entries.get(key); + if (entry) { + bucket.entries.delete(key); + bucket.totalSize -= entry.size; + this.stats.totalEntries--; + totalCleaned++; + } + } + + bucket.lastCleanup = now; + } + + if (totalCleaned > 0) { + logger.debug(`Cleanup removed ${totalCleaned} expired entries`); + } + + // Update stats tracker + statsTracker.updateCacheStats({ + hits: this.stats.hits, + misses: this.stats.misses, + evictions: this.stats.evictions, + totalEntries: this.stats.totalEntries, + totalSize: this.getTotalSize() + }); + } + + // Get cache statistics + getStats() { + return { + ...this.stats, + totalSize: this.getTotalSize(), + bucketCount: this.bucketCount, + avgBucketSize: this.getTotalSize() / this.bucketCount + }; + } + + private getTotalSize(): number { + return this.buckets.reduce((total, bucket) => total + bucket.totalSize, 0); + } + + cleanup(): void { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + + // Clear all buckets + for (const bucket of this.buckets) { + bucket.entries.clear(); + bucket.totalSize = 0; + } + + this.stats = { + hits: 0, + misses: 0, + evictions: 0, + totalEntries: 0 + }; + + logger.debug("FastCache cleanup completed"); + } +} + +export const fastCache = new FastCache(); \ No newline at end of file diff --git a/src/equicordplugins/accelerator/modules/imagepreloader.ts b/src/equicordplugins/accelerator/modules/imagepreloader.ts new file mode 100644 index 00000000..6c58bf94 --- /dev/null +++ b/src/equicordplugins/accelerator/modules/imagepreloader.ts @@ -0,0 +1,255 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Logger } from "@utils/Logger"; +import { MessageStore } from "@webpack/common"; + +const logger = new Logger("Accelerator:ImagePreloader"); + +class ImagePreloader { + private preloadedImages = new Set(); + private preloadQueue: string[] = []; + private isPreloading = false; + private maxPreload = 10; + private intersectionObserver: IntersectionObserver | null = null; + + init(maxPreload: number): void { + this.maxPreload = maxPreload; + this.setupIntersectionObserver(); + logger.info("Image preloader initialized"); + } + + // Set up intersection observer for intelligent preloading + private setupIntersectionObserver(): void { + this.intersectionObserver = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + this.preloadNearbyImages(entry.target); + } + } + }, + { + rootMargin: "100px 0px 100px 0px", // Preload when 100px away + threshold: 0.1 + } + ); + + // Observe existing images + this.observeExistingImages(); + + // Use MutationObserver to watch for new images + const mutationObserver = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.type === "childList") { + for (const node of mutation.addedNodes) { + if (node.nodeType === Node.ELEMENT_NODE) { + this.observeImagesInElement(node as Element); + } + } + } + } + }); + + mutationObserver.observe(document.body, { + childList: true, + subtree: true + }); + } + + private observeExistingImages(): void { + const images = document.querySelectorAll('img, [style*="background-image"]'); + for (const img of images) { + this.intersectionObserver?.observe(img); + } + } + + private observeImagesInElement(element: Element): void { + // Observe the element itself if it's an image + if (element.tagName === "IMG" || + (element as HTMLElement).style.backgroundImage) { + this.intersectionObserver?.observe(element); + } + + // Observe child images + const images = element.querySelectorAll('img, [style*="background-image"]'); + for (const img of images) { + this.intersectionObserver?.observe(img); + } + } + + private preloadNearbyImages(target: Element): void { + // Find adjacent images to preload + const container = target.closest('[class*="message"], [class*="content"]'); + if (!container) return; + + const nearbyImages = this.findNearbyImageUrls(container); + this.addToPreloadQueue(nearbyImages); + } + + private findNearbyImageUrls(container: Element): string[] { + const urls: string[] = []; + + // Find images in current and adjacent messages + const messageElements = container.parentElement?.querySelectorAll('[class*="message"]') || []; + const currentIndex = Array.from(messageElements).indexOf(container as Element); + + // Check current message and a few before/after + const start = Math.max(0, currentIndex - 2); + const end = Math.min(messageElements.length, currentIndex + 3); + + for (let i = start; i < end; i++) { + const messageEl = messageElements[i]; + + // Find image URLs in this message + const images = messageEl.querySelectorAll('img'); + for (const img of images) { + if (img.src && !this.preloadedImages.has(img.src)) { + urls.push(img.src); + } + } + + // Find background images + const elementsWithBg = messageEl.querySelectorAll('[style*="background-image"]'); + for (const el of elementsWithBg) { + const bgImage = (el as HTMLElement).style.backgroundImage; + const urlMatch = bgImage.match(/url\(['"]?(.*?)['"]?\)/); + if (urlMatch && urlMatch[1] && !this.preloadedImages.has(urlMatch[1])) { + urls.push(urlMatch[1]); + } + } + } + + return urls.slice(0, this.maxPreload); + } + + preloadChannelImages(channelId: string, maxImages: number): void { + try { + // Get messages from Discord's message store + const messages = MessageStore.getMessages(channelId); + if (!messages?._array) return; + + const imageUrls: string[] = []; + + // Extract image URLs from recent messages + for (const message of messages._array.slice(-20)) { // Last 20 messages + if (imageUrls.length >= maxImages) break; + + // Check attachments + if (message.attachments) { + for (const attachment of message.attachments) { + if (attachment.content_type?.startsWith('image/') && + attachment.url && + !this.preloadedImages.has(attachment.url)) { + imageUrls.push(attachment.url); + } + } + } + + // Check embeds + if (message.embeds) { + for (const embed of message.embeds) { + if (embed.image?.url && !this.preloadedImages.has(embed.image.url)) { + imageUrls.push(embed.image.url); + } + if (embed.thumbnail?.url && !this.preloadedImages.has(embed.thumbnail.url)) { + imageUrls.push(embed.thumbnail.url); + } + } + } + } + + this.addToPreloadQueue(imageUrls.slice(0, maxImages)); + } catch (error) { + logger.warn("Failed to preload channel images:", error); + } + } + + private addToPreloadQueue(urls: string[]): void { + for (const url of urls) { + if (!this.preloadedImages.has(url) && !this.preloadQueue.includes(url)) { + this.preloadQueue.push(url); + } + } + + if (!this.isPreloading) { + this.processPreloadQueue(); + } + } + + private async processPreloadQueue(): Promise { + if (this.isPreloading || this.preloadQueue.length === 0) return; + + this.isPreloading = true; + + while (this.preloadQueue.length > 0 && this.preloadedImages.size < this.maxPreload * 2) { + const url = this.preloadQueue.shift(); + if (!url || this.preloadedImages.has(url)) continue; + + try { + await this.preloadImage(url); + this.preloadedImages.add(url); + } catch (error) { + logger.debug(`Failed to preload image: ${url}`, error); + } + + // Add small delay to avoid blocking the main thread + await new Promise(resolve => setTimeout(resolve, 10)); + } + + this.isPreloading = false; + } + + private preloadImage(url: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + + const cleanup = () => { + img.onload = null; + img.onerror = null; + img.onabort = null; + }; + + img.onload = () => { + cleanup(); + resolve(); + }; + + img.onerror = () => { + cleanup(); + reject(new Error(`Failed to load ${url}`)); + }; + + img.onabort = () => { + cleanup(); + reject(new Error(`Aborted loading ${url}`)); + }; + + // Set timeout to avoid hanging + setTimeout(() => { + cleanup(); + reject(new Error(`Timeout loading ${url}`)); + }, 5000); + + img.src = url; + }); + } + + cleanup(): void { + if (this.intersectionObserver) { + this.intersectionObserver.disconnect(); + this.intersectionObserver = null; + } + + this.preloadedImages.clear(); + this.preloadQueue.length = 0; + this.isPreloading = false; + + logger.debug("Image preloader cleanup completed"); + } +} + +export const imagePreloader = new ImagePreloader(); \ No newline at end of file diff --git a/src/equicordplugins/accelerator/modules/intersection.ts b/src/equicordplugins/accelerator/modules/intersection.ts new file mode 100644 index 00000000..c55f3275 --- /dev/null +++ b/src/equicordplugins/accelerator/modules/intersection.ts @@ -0,0 +1,173 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Logger } from "@utils/Logger"; +import { statsTracker } from "./stats"; + +const logger = new Logger("Accelerator:IntersectionOptimizer"); + +class IntersectionOptimizer { + private imageObserver: IntersectionObserver | null = null; + private observedElements = new Set(); + private imagePreloadQueue = new Set(); + private isEnabled = false; + + init() { + this.setupImageObserver(); + this.isEnabled = true; + logger.info("Intersection optimizer initialized (scroll-safe mode)"); + } + + private setupImageObserver() { + // Only handle image lazy loading - no layout modifications to avoid scroll issues + this.imageObserver = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + const img = entry.target as HTMLImageElement; + this.optimizeImageLoading(img); + this.imageObserver?.unobserve(img); + } + }); + }, { + rootMargin: '1000px 0px', // Preload images 1000px before they enter viewport + threshold: 0.01 + }); + + this.observeExistingImages(); + this.setupImageMutationObserver(); + } + + private observeExistingImages() { + if (!this.imageObserver) return; + + // Only observe Discord CDN images to avoid interfering with other content + const images = document.querySelectorAll('img[src*="cdn.discordapp.com"], img[src*="media.discordapp.net"]'); + images.forEach(img => { + if (!this.observedElements.has(img)) { + this.imageObserver!.observe(img); + this.observedElements.add(img); + } + }); + } + + private setupImageMutationObserver() { + if (!this.imageObserver) return; + + const mutationObserver = new MutationObserver(mutations => { + if (!this.isEnabled) return; + + mutations.forEach(mutation => { + mutation.addedNodes.forEach(node => { + if (node instanceof HTMLElement) { + // Check for new Discord images only + const images = node.matches('img') ? [node] : + Array.from(node.querySelectorAll('img[src*="cdn.discordapp.com"], img[src*="media.discordapp.net"]')); + + images.forEach(img => { + if (!this.observedElements.has(img) && this.imageObserver) { + this.imageObserver.observe(img); + this.observedElements.add(img); + } + }); + } + }); + }); + }); + + mutationObserver.observe(document.body, { + childList: true, + subtree: true + }); + } + + private optimizeImageLoading(img: HTMLImageElement) { + try { + // Only apply safe image optimizations that don't affect layout + if (!img.loading) { + img.loading = 'lazy'; + } + if (!img.decoding) { + img.decoding = 'async'; + } + + // Preload higher quality version if available + const src = img.src; + if (src && (src.includes('cdn.discordapp.com') || src.includes('media.discordapp.net'))) { + this.preloadHigherQuality(src); + } + + logger.debug(`Optimized image loading: ${img.src}`); + statsTracker.incrementImagesOptimized(); + } catch (error) { + logger.error("Failed to optimize image loading:", error); + } + } + + private preloadHigherQuality(src: string) { + if (this.imagePreloadQueue.has(src)) return; + + this.imagePreloadQueue.add(src); + + try { + // Convert to higher quality if it's a Discord CDN image with quality params + let highQualitySrc = src; + + if (src.includes('?')) { + const url = new URL(src); + + // Remove width/height constraints for better quality + url.searchParams.delete('width'); + url.searchParams.delete('height'); + + // Set higher quality if quality param exists + if (url.searchParams.has('quality')) { + url.searchParams.set('quality', '100'); + } + + // Remove format constraints to get original format + if (url.searchParams.has('format')) { + url.searchParams.delete('format'); + } + + highQualitySrc = url.toString(); + } + + // Preload the higher quality version + if (highQualitySrc !== src) { + const preloadImg = new Image(); + preloadImg.onload = () => { + this.imagePreloadQueue.delete(src); + logger.debug(`Preloaded high quality image: ${highQualitySrc}`); + }; + preloadImg.onerror = () => { + this.imagePreloadQueue.delete(src); + }; + preloadImg.src = highQualitySrc; + } else { + this.imagePreloadQueue.delete(src); + } + } catch (error) { + this.imagePreloadQueue.delete(src); + logger.error("Failed to preload higher quality image:", error); + } + } + + cleanup() { + this.isEnabled = false; + + if (this.imageObserver) { + this.imageObserver.disconnect(); + this.imageObserver = null; + } + + this.observedElements.clear(); + this.imagePreloadQueue.clear(); + + logger.debug("Intersection optimizer cleanup completed"); + } +} + +export const intersectionOptimizer = new IntersectionOptimizer(); \ No newline at end of file diff --git a/src/equicordplugins/accelerator/modules/messageAccessory.tsx b/src/equicordplugins/accelerator/modules/messageAccessory.tsx new file mode 100644 index 00000000..f4202a56 --- /dev/null +++ b/src/equicordplugins/accelerator/modules/messageAccessory.tsx @@ -0,0 +1,86 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Logger } from "@utils/Logger"; +import { React } from "@webpack/common"; + +import { fastCache } from "./fastcache"; +import { statsTracker } from "./stats"; + +const logger = new Logger("Accelerator:MessageAccessory"); + +interface MessageAccessoryProps { + message: any; +} + +let processedMessages = new Set(); + +export function CacheIndicatorAccessory({ message }: MessageAccessoryProps) { + const [showIndicator, setShowIndicator] = React.useState(false); + const indicatorRef = React.useRef(null); + + React.useEffect(() => { + if (!message?.id || !message?.channel_id) return; + + const messageKey = `${message.channel_id}:${message.id}`; + + // Avoid processing the same message multiple times + if (processedMessages.has(messageKey)) return; + processedMessages.add(messageKey); + + // Add slight delay to ensure cache has been checked + setTimeout(() => { + const cachedMessage = fastCache.getMessage(message.channel_id, message.id); + + if (cachedMessage) { + setShowIndicator(true); + statsTracker.incrementMessagesServedFromCache(1); + logger.debug(`Message ${message.id} served from cache`); + } + }, 100); + }, [message?.id, message?.channel_id]); + + // Ensure proper positioning after render + React.useEffect(() => { + if (showIndicator && indicatorRef.current) { + const indicator = indicatorRef.current; + const messageElement = indicator.closest('[class*="message"]') || + indicator.closest('[id^="chat-messages-"]') || + indicator.closest('[class*="messageListItem-"]'); + + if (messageElement) { + const messageRect = messageElement.getBoundingClientRect(); + // Adjust position to be perfectly centered in the message highlight area + indicator.style.top = "50%"; + indicator.style.right = "12px"; + } + } + }, [showIndicator]); + + if (!showIndicator) return null; + + return ( +
+ ); +} \ No newline at end of file diff --git a/src/equicordplugins/accelerator/modules/preloader.ts b/src/equicordplugins/accelerator/modules/preloader.ts new file mode 100644 index 00000000..41bce7e5 --- /dev/null +++ b/src/equicordplugins/accelerator/modules/preloader.ts @@ -0,0 +1,214 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { FluxDispatcher, ChannelStore, GuildChannelStore, ChannelActionCreators, MessageActions } from "@webpack/common"; +import { Logger } from "@utils/Logger"; + +const logger = new Logger("Accelerator:Preloader"); + +interface PreloadedChannel { + channelId: string; + timestamp: number; + messages: boolean; +} + +class ChannelPreloader { + private preloadedChannels = new Map(); + private preloadQueue = new Set(); + private maxPreloadAge = 5 * 60 * 1000; // 5 minutes + private preloadDistance = 3; + private isScrolling = false; + private scrollTimeout: number | null = null; + private lastScrollTime = 0; + + init(distance: number) { + this.preloadDistance = distance; + this.setupScrollDetection(); + logger.info("Channel preloader initialized (scroll-aware)"); + } + + private setupScrollDetection() { + // Detect when user is actively scrolling to avoid interference + const handleScroll = () => { + this.isScrolling = true; + this.lastScrollTime = Date.now(); + + if (this.scrollTimeout) { + clearTimeout(this.scrollTimeout); + } + + // Consider scrolling finished after 150ms of no scroll events + this.scrollTimeout = setTimeout(() => { + this.isScrolling = false; + }, 150); + }; + + // Listen to scroll events on potential scroll containers + document.addEventListener('scroll', handleScroll, { passive: true, capture: true }); + document.addEventListener('wheel', handleScroll, { passive: true, capture: true }); + } + + async preloadAdjacent(guildId: string | null, currentChannelId: string, distance: number) { + // Don't start new preloads while user is actively scrolling + if (this.isScrolling || (Date.now() - this.lastScrollTime) < 500) { + logger.debug("Skipping preload during scroll activity"); + return; + } + + try { + const adjacentChannels = this.getAdjacentChannels(guildId, currentChannelId, distance); + + // More conservative preloading to avoid interfering with scroll + const batchSize = 1; // Reduced from 2 to 1 + for (let i = 0; i < adjacentChannels.length; i += batchSize) { + // Check if user started scrolling during preload + if (this.isScrolling) { + logger.debug("Stopping preload due to scroll activity"); + break; + } + + const batch = adjacentChannels.slice(i, i + batchSize); + await Promise.all(batch.map(channelId => this.preloadChannel(channelId))); + + // Longer delay between batches to be less aggressive + if (i + batchSize < adjacentChannels.length) { + await new Promise(resolve => setTimeout(resolve, 200)); + } + } + + // Cleanup old preloaded channels + this.cleanup(); + } catch (error) { + logger.error("Failed to preload adjacent channels:", error); + } + } + + private getAdjacentChannels(guildId: string | null, currentChannelId: string, distance: number): string[] { + const channels: string[] = []; + + try { + if (guildId) { + // Guild channels + const guildChannels = GuildChannelStore.getChannels(guildId); + const selectableChannels = guildChannels.SELECTABLE || []; + + const currentIndex = selectableChannels.findIndex(ch => ch.channel.id === currentChannelId); + if (currentIndex !== -1) { + // Get channels before and after current + for (let i = 1; i <= distance; i++) { + const beforeIndex = currentIndex - i; + const afterIndex = currentIndex + i; + + if (beforeIndex >= 0) { + channels.push(selectableChannels[beforeIndex].channel.id); + } + if (afterIndex < selectableChannels.length) { + channels.push(selectableChannels[afterIndex].channel.id); + } + } + } + } else { + // DM channels - preload recent conversations + const recentChannels = this.getRecentDMChannels(currentChannelId, distance); + channels.push(...recentChannels); + } + } catch (error) { + logger.error("Failed to get adjacent channels:", error); + } + + return channels.filter(id => id !== currentChannelId); + } + + private getRecentDMChannels(excludeChannelId: string, count: number): string[] { + // Get recent DM channels from Discord's internal stores + try { + const privateChannels = ChannelStore.getSortedPrivateChannels(); + return privateChannels + .filter(channel => channel.id !== excludeChannelId) + .slice(0, count) + .map(channel => channel.id); + } catch (error) { + logger.error("Failed to get recent DM channels:", error); + return []; + } + } + + private async preloadChannel(channelId: string) { + if (this.preloadQueue.has(channelId)) return; + if (this.isScrolling) return; // Don't start new preloads during scroll + + const existingPreload = this.preloadedChannels.get(channelId); + const now = Date.now(); + + // Skip if recently preloaded + if (existingPreload && (now - existingPreload.timestamp) < this.maxPreloadAge) { + return; + } + + this.preloadQueue.add(channelId); + + try { + const channel = ChannelStore.getChannel(channelId); + if (!channel) return; + + // Check again if user started scrolling + if (this.isScrolling) { + this.preloadQueue.delete(channelId); + return; + } + + // Use Discord's internal preload system - but be more conservative + if (channel.guild_id) { + await ChannelActionCreators.preload(channel.guild_id, channelId); + } + + // Only preload messages for non-DM channels, and with smaller batch size + if (channel.type !== 1 && channel.type !== 3 && !this.isScrolling) { + await MessageActions.fetchMessages({ + channelId, + limit: 25 // Reduced from 50 to 25 + }); + } + + this.preloadedChannels.set(channelId, { + channelId, + timestamp: now, + messages: true + }); + + logger.debug(`Preloaded channel: ${channelId}`); + } catch (error) { + logger.error(`Failed to preload channel ${channelId}:`, error); + } finally { + this.preloadQueue.delete(channelId); + } + } + + cleanup() { + const now = Date.now(); + const toRemove: string[] = []; + + for (const [channelId, preload] of this.preloadedChannels) { + if ((now - preload.timestamp) > this.maxPreloadAge) { + toRemove.push(channelId); + } + } + + toRemove.forEach(channelId => this.preloadedChannels.delete(channelId)); + + if (toRemove.length > 0) { + logger.debug(`Cleaned up ${toRemove.length} old preloaded channels`); + } + + // Cleanup scroll detection + if (this.scrollTimeout) { + clearTimeout(this.scrollTimeout); + this.scrollTimeout = null; + } + } +} + +export const channelPreloader = new ChannelPreloader(); \ No newline at end of file diff --git a/src/equicordplugins/accelerator/modules/stats.ts b/src/equicordplugins/accelerator/modules/stats.ts new file mode 100644 index 00000000..0467ad27 --- /dev/null +++ b/src/equicordplugins/accelerator/modules/stats.ts @@ -0,0 +1,513 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Logger } from "@utils/Logger"; + +const logger = new Logger("Accelerator:Stats"); + +interface AcceleratorStats { + channelSwitches: number; + messagesLoaded: number; + messagesCached: number; + messagesServedFromCache: number; + imagesOptimized: number; + imagesCached: number; + cacheHits: number; + cacheMisses: number; + averageLoadTime: number; + startTime: number; + lastUpdate: number; +} + +interface ChannelSwitchTracking { + channelId: string; + startTime: number; +} + +class StatsTracker { + private stats: AcceleratorStats; + private loadTimes: number[] = []; + private maxLoadTimeHistory = 50; + private currentChannelSwitch: ChannelSwitchTracking | null = null; + private imageLoadTimes = new Map(); // Track when image requests start + private processedImages = new Set(); // Track which images we've seen before + + constructor() { + this.stats = this.getInitialStats(); + } + + private getInitialStats(): AcceleratorStats { + return { + channelSwitches: 0, + messagesLoaded: 0, + messagesCached: 0, + messagesServedFromCache: 0, + imagesOptimized: 0, + imagesCached: 0, + cacheHits: 0, + cacheMisses: 0, + averageLoadTime: 0, + startTime: Date.now(), + lastUpdate: Date.now() + }; + } + + init() { + this.stats = this.getInitialStats(); + this.setupImageCacheTracking(); + logger.info("Stats tracker initialized - tracking real Discord performance"); + } + + // Track real Discord channel switching performance like messageFetchTimer + trackChannelSwitchStart(channelId: string): void { + this.currentChannelSwitch = { + channelId, + startTime: performance.now() + }; + logger.debug(`Channel switch started: ${channelId}`); + } + + trackChannelSwitchEnd(channelId: string): void { + if (!this.currentChannelSwitch || this.currentChannelSwitch.channelId !== channelId) { + logger.debug(`Channel switch end without matching start: ${channelId}`); + return; + } + + const loadTime = performance.now() - this.currentChannelSwitch.startTime; + this.recordLoadTime(loadTime); + this.incrementChannelSwitch(); + this.currentChannelSwitch = null; + + logger.debug(`Channel switch completed: ${channelId} in ${loadTime.toFixed(1)}ms`); + } + + // Set up real image cache tracking by hooking into image loading + private setupImageCacheTracking() { + const statsTracker = this; + + // Use MutationObserver to track all image elements added to DOM + const imageObserver = new MutationObserver((mutations) => { + mutations.forEach(mutation => { + mutation.addedNodes.forEach(node => { + if (node.nodeType === Node.ELEMENT_NODE) { + const element = node as Element; + + // Check if it's an image or contains images + const images = element.tagName === 'IMG' + ? [element as HTMLImageElement] + : Array.from(element.querySelectorAll('img')); + + images.forEach(img => { + this.trackImageElement(img); + }); + } + }); + }); + }); + + // Start observing + imageObserver.observe(document.body, { + childList: true, + subtree: true + }); + + // Track existing images + document.querySelectorAll('img').forEach(img => { + this.trackImageElement(img); + }); + } + + private trackImageElement(img: HTMLImageElement) { + const src = img.src || img.getAttribute('data-src') || ''; + + // Only track Discord CDN images + if (!src || (!src.includes('cdn.discordapp.com') && !src.includes('media.discordapp.net'))) { + return; + } + + const startTime = performance.now(); + this.imageLoadTimes.set(src, startTime); + + // Check if we've seen this image before (cache scenario) + const wasProcessed = this.processedImages.has(src); + + const onLoad = () => { + const endTime = performance.now(); + const loadTime = endTime - (this.imageLoadTimes.get(src) || endTime); + + if (wasProcessed) { + // Image was processed before, likely from cache + this.incrementImagesCached(); + if (loadTime < 50) { // Very fast load likely means cache hit + this.incrementCacheHit(); + } + } else { + // First time seeing this image + this.incrementImagesOptimized(); + this.processedImages.add(src); + if (loadTime >= 50) { // Slower load likely means cache miss + this.incrementCacheMiss(); + } + } + + this.imageLoadTimes.delete(src); + logger.debug(`Image loaded: ${src} in ${loadTime.toFixed(1)}ms (cached: ${wasProcessed})`); + + // Clean up listeners + img.removeEventListener('load', onLoad); + img.removeEventListener('error', onError); + }; + + const onError = () => { + this.imageLoadTimes.delete(src); + img.removeEventListener('load', onLoad); + img.removeEventListener('error', onError); + }; + + // If image is already loaded, track it immediately + if (img.complete && img.naturalWidth > 0) { + setTimeout(onLoad, 0); + } else { + img.addEventListener('load', onLoad); + img.addEventListener('error', onError); + } + } + + incrementChannelSwitch() { + this.stats.channelSwitches++; + this.updateTimestamp(); + logger.debug(`Channel switch tracked: ${this.stats.channelSwitches}`); + } + + incrementMessagesLoaded(count: number) { + this.stats.messagesLoaded += count; + this.updateTimestamp(); + logger.debug(`Messages loaded: +${count} (total: ${this.stats.messagesLoaded})`); + } + + incrementMessagesCached(count: number) { + this.stats.messagesCached += count; + this.updateTimestamp(); + logger.debug(`Messages cached: +${count} (total: ${this.stats.messagesCached})`); + } + + incrementMessagesServedFromCache(count: number) { + this.stats.messagesServedFromCache += count; + this.updateTimestamp(); + logger.debug(`Messages served from cache: +${count} (total: ${this.stats.messagesServedFromCache})`); + } + + incrementImagesOptimized() { + this.stats.imagesOptimized++; + this.updateTimestamp(); + logger.debug(`Images optimized: ${this.stats.imagesOptimized}`); + } + + incrementImagesCached() { + this.stats.imagesCached++; + this.updateTimestamp(); + logger.debug(`Images cached: ${this.stats.imagesCached}`); + } + + + + incrementCacheHit() { + this.stats.cacheHits++; + this.updateTimestamp(); + logger.debug(`Cache hit: ${this.stats.cacheHits}`); + } + + incrementCacheMiss() { + this.stats.cacheMisses++; + this.updateTimestamp(); + logger.debug(`Cache miss: ${this.stats.cacheMisses}`); + } + + updateCacheStats(cacheStats: any): void { + if (cacheStats.hits !== undefined) this.stats.cacheHits = cacheStats.hits; + if (cacheStats.misses !== undefined) this.stats.cacheMisses = cacheStats.misses; + if (cacheStats.totalMessagesCached !== undefined) this.stats.messagesCached = cacheStats.totalMessagesCached; + this.updateTimestamp(); + logger.debug(`Cache stats updated:`, cacheStats); + } + + recordLoadTime(time: number) { + // Only record realistic load times (filter out obviously wrong values) + if (time > 0 && time < 30000) { // Between 0 and 30 seconds + this.loadTimes.push(time); + if (this.loadTimes.length > this.maxLoadTimeHistory) { + this.loadTimes.shift(); + } + + this.stats.averageLoadTime = this.loadTimes.reduce((a, b) => a + b, 0) / this.loadTimes.length; + this.updateTimestamp(); + logger.debug(`Load time recorded: ${time.toFixed(1)}ms (avg: ${this.stats.averageLoadTime.toFixed(1)}ms)`); + } + } + + private updateTimestamp() { + this.stats.lastUpdate = Date.now(); + } + + getStats(): AcceleratorStats { + return { ...this.stats }; + } + + getFormattedUptime(): string { + const uptime = Date.now() - this.stats.startTime; + const minutes = Math.floor(uptime / 60000); + const seconds = Math.floor((uptime % 60000) / 1000); + return `${minutes}m ${seconds}s`; + } + + getCacheHitRate(): number { + const total = this.stats.cacheHits + this.stats.cacheMisses; + return total > 0 ? (this.stats.cacheHits / total) * 100 : 0; + } + + cleanup() { + // Reset tracking state + this.currentChannelSwitch = null; + this.imageLoadTimes.clear(); + this.processedImages.clear(); + this.stats = this.getInitialStats(); + this.loadTimes = []; + logger.debug("Stats tracker cleanup completed"); + } +} + +export const statsTracker = new StatsTracker(); + +// Floating Stats Window using vanilla DOM +export class FloatingStats { + private static container: HTMLDivElement | null = null; + private static isVisible = false; + private static updateInterval: number | null = null; + private static isDragging = false; + private static dragOffset = { x: 0, y: 0 }; + private static position = { x: window.innerWidth - 320, y: 20 }; + private static isMinimized = false; + + static show() { + if (this.isVisible) return; + + this.createContainer(); + this.setupEventListeners(); + this.startUpdating(); + this.isVisible = true; + + logger.info("Floating stats window shown"); + } + + static hide() { + if (!this.isVisible) return; + + if (this.container) { + document.body.removeChild(this.container); + this.container = null; + } + + if (this.updateInterval) { + clearInterval(this.updateInterval); + this.updateInterval = null; + } + + this.isVisible = false; + logger.info("Floating stats window hidden"); + } + + static toggle() { + if (this.isVisible) { + this.hide(); + } else { + this.show(); + } + } + + private static createContainer() { + this.container = document.createElement("div"); + this.container.id = "accelerator-stats-container"; + + this.updateStyles(); + this.updateContent(); + + document.body.appendChild(this.container); + } + + private static updateStyles() { + if (!this.container) return; + + Object.assign(this.container.style, { + position: "fixed", + top: `${this.position.y}px`, + left: `${this.position.x}px`, + width: "300px", + minHeight: this.isMinimized ? "40px" : "auto", + maxHeight: this.isMinimized ? "40px" : "500px", + backgroundColor: "var(--background-secondary)", + border: "1px solid var(--background-secondary-alt)", + borderRadius: "8px", + padding: "12px", + fontSize: "12px", + fontFamily: "var(--font-primary)", + color: "var(--text-normal)", + zIndex: "9999", + userSelect: "none", + boxShadow: "0 8px 24px rgba(0, 0, 0, 0.15)", + backdropFilter: "blur(10px)", + overflow: "hidden", + transition: "all 0.2s ease" + }); + } + + private static updateContent() { + if (!this.container) return; + + const stats = statsTracker.getStats(); + const uptime = statsTracker.getFormattedUptime(); + const cacheRate = statsTracker.getCacheHitRate(); + + const formatNumber = (num: number): string => { + if (num >= 1000) return (num / 1000).toFixed(1) + "k"; + return num.toString(); + }; + + const formatLoadTime = (time: number): string => { + return time < 1000 ? `${Math.round(time)}ms` : `${(time / 1000).toFixed(1)}s`; + }; + + const formatCacheRate = (rate: number): string => { + return rate.toFixed(1) + "%"; + }; + + const headerHTML = ` +
+ 🚀 Accelerator Stats +
+ + +
+
+ `; + + const contentHTML = this.isMinimized ? '' : ` +
+
+
Channel Switches
+
${formatNumber(stats.channelSwitches)}
+
+
+
Channel Load Time
+
${formatLoadTime(stats.averageLoadTime)}
+
+
+
Messages from Discord
+
${formatNumber(stats.messagesLoaded)}
+
+
+
Messages from Cache
+
${formatNumber(stats.messagesServedFromCache)}
+
+
+
Messages Stored
+
${formatNumber(stats.messagesCached)}
+
+
+
Message Cache Rate
+
${formatCacheRate(cacheRate)}
+
+
+
Images Preloaded
+
${formatNumber(stats.imagesOptimized)}
+
+
+
Images from Cache
+
${formatNumber(stats.imagesCached)}
+
+
+
Uptime
+
${uptime}
+
+
+ `; + + this.container.innerHTML = headerHTML + contentHTML; + this.setupToggleButton(); + } + + private static setupToggleButton() { + const minimizeBtn = document.getElementById("accelerator-minimize"); + const closeBtn = document.getElementById("accelerator-close"); + + if (minimizeBtn) { + minimizeBtn.onclick = (e) => { + e.stopPropagation(); + this.isMinimized = !this.isMinimized; + this.updateStyles(); + this.updateContent(); + }; + } + + if (closeBtn) { + closeBtn.onclick = (e) => { + e.stopPropagation(); + this.hide(); + }; + } + } + + private static setupEventListeners() { + if (!this.container) return; + + // Dragging functionality + this.container.onmousedown = (e) => { + if ((e.target as HTMLElement).tagName === "BUTTON") return; + + this.isDragging = true; + this.dragOffset.x = e.clientX - this.position.x; + this.dragOffset.y = e.clientY - this.position.y; + + if (this.container) { + this.container.style.cursor = "grabbing"; + this.container.style.opacity = "0.8"; + } + }; + + document.onmousemove = (e) => { + if (!this.isDragging || !this.container) return; + + this.position.x = e.clientX - this.dragOffset.x; + this.position.y = e.clientY - this.dragOffset.y; + + // Keep within screen bounds + this.position.x = Math.max(0, Math.min(window.innerWidth - 300, this.position.x)); + this.position.y = Math.max(0, Math.min(window.innerHeight - 100, this.position.y)); + + this.container.style.left = `${this.position.x}px`; + this.container.style.top = `${this.position.y}px`; + }; + + document.onmouseup = () => { + if (!this.isDragging) return; + + this.isDragging = false; + if (this.container) { + this.container.style.cursor = "default"; + this.container.style.opacity = "1"; + } + }; + } + + private static startUpdating() { + if (this.updateInterval) clearInterval(this.updateInterval); + + this.updateInterval = setInterval(() => { + if (this.isVisible && this.container) { + this.updateContent(); + } + }, 1000) as any; + } +} \ No newline at end of file diff --git a/src/equicordplugins/accelerator/styles.css b/src/equicordplugins/accelerator/styles.css new file mode 100644 index 00000000..a16d3f2b --- /dev/null +++ b/src/equicordplugins/accelerator/styles.css @@ -0,0 +1,171 @@ +/* + * Accelerator Stats Window - The ONLY CSS component + * This contains ONLY styles for the floating stats window + * NO performance CSS that could interfere with Discord's layout + */ + +.accelerator-stats-window { + position: fixed; + z-index: 10000; + background: rgba(32, 34, 37, 0.95); + border: 1px solid #40444b; + border-radius: 8px; + backdrop-filter: blur(10px); + font-family: 'gg sans', 'Noto Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-size: 12px; + color: #dcddde; + min-width: 280px; + max-width: 400px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + user-select: none; + cursor: move; +} + +.accelerator-stats-header { + padding: 12px 16px 8px; + border-bottom: 1px solid #40444b; + font-weight: 600; + display: flex; + justify-content: space-between; + align-items: center; + background: linear-gradient(90deg, #5865f2, #7289da); + color: white; + border-radius: 7px 7px 0 0; + cursor: move; +} + +.accelerator-stats-content { + padding: 12px 16px; + max-height: 400px; + overflow-y: auto; +} + +.accelerator-stats-content::-webkit-scrollbar { + width: 6px; +} + +.accelerator-stats-content::-webkit-scrollbar-track { + background: transparent; +} + +.accelerator-stats-content::-webkit-scrollbar-thumb { + background: #40444b; + border-radius: 3px; +} + +.accelerator-stats-section { + margin-bottom: 12px; +} + +.accelerator-stats-section h4 { + margin: 0 0 6px 0; + font-size: 11px; + font-weight: 600; + color: #b9bbbe; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.accelerator-stats-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.accelerator-stats-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 4px 0; +} + +.accelerator-stats-label { + color: #b9bbbe; + font-size: 11px; +} + +.accelerator-stats-value { + color: #dcddde; + font-weight: 500; + font-size: 12px; +} + +.accelerator-stats-value.positive { + color: #3ba55c; +} + +.accelerator-stats-value.neutral { + color: #faa61a; +} + +.accelerator-stats-toggle { + background: none; + border: none; + color: #dcddde; + cursor: pointer; + font-size: 14px; + padding: 4px; + border-radius: 4px; + transition: background-color 0.2s; +} + +.accelerator-stats-toggle:hover { + background: rgba(255, 255, 255, 0.1); +} + +.accelerator-stats-window.minimized .accelerator-stats-content { + display: none; +} + +.accelerator-stats-window.minimized { + min-width: auto; +} + +/* Cache Indicator - Shows when a message was served from fastcache */ +.accelerator-cache-indicator { + position: absolute; + top: 4px; + right: 4px; + width: 8px; + height: 8px; + background: #3ba55c; + border-radius: 50%; + z-index: 10; + box-shadow: 0 0 0 1px rgba(32, 34, 37, 0.9), 0 1px 3px rgba(0, 0, 0, 0.3); + animation: accelerator-cache-pulse 2s ease-out; + cursor: help; +} + +@keyframes acceleratorCachePulse { + 0% { + transform: translateY(-50%) scale(1); + opacity: 1; + box-shadow: 0 0 0 2px rgba(32, 34, 37, 0.95), 0 2px 8px rgba(0, 0, 0, 0.5), 0 0 0 0 rgba(59, 165, 92, 1); + } + + 50% { + transform: translateY(-50%) scale(1.4); + opacity: 0.6; + box-shadow: 0 0 0 2px rgba(32, 34, 37, 0.95), 0 2px 8px rgba(0, 0, 0, 0.5), 0 0 0 12px rgba(59, 165, 92, 0.6); + } + + 100% { + transform: translateY(-50%) scale(1); + opacity: 1; + box-shadow: 0 0 0 2px rgba(32, 34, 37, 0.95), 0 2px 8px rgba(0, 0, 0, 0.5), 0 0 0 0 rgba(59, 165, 92, 1); + } +} + +/* Ensure the message container has relative positioning for the indicator */ +[class*="message-"], +[id^="chat-messages-"], +[data-list-item-id^="chat-messages"] { + position: relative; +} + +/* Additional support for Discord's message structure */ +[class*="messageListItem-"], +[class*="groupStart-"], +[class*="wrapper-"] { + position: relative; +} \ No newline at end of file diff --git a/src/utils/constants.ts b/src/utils/constants.ts index d06e173b..82652b3d 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1094,6 +1094,10 @@ export const EquicordDevs = Object.freeze({ name: "Suffocate", id: 772601756776923187n }, + galpt: { + name: "galpt", + id: 631418827841863712n + }, } satisfies Record); // iife so #__PURE__ works correctly