mirror of
https://github.com/Equicord/Equicord.git
synced 2025-06-29 16:34:25 -04:00
revision
This commit is contained in:
parent
acfa10992f
commit
64dd447abe
9 changed files with 2004 additions and 0 deletions
237
src/equicordplugins/accelerator/index.ts
Normal file
237
src/equicordplugins/accelerator/index.ts
Normal file
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
351
src/equicordplugins/accelerator/modules/fastcache.ts
Normal file
351
src/equicordplugins/accelerator/modules/fastcache.ts
Normal file
|
@ -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<string, CacheEntry>;
|
||||||
|
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<void> {
|
||||||
|
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();
|
255
src/equicordplugins/accelerator/modules/imagepreloader.ts
Normal file
255
src/equicordplugins/accelerator/modules/imagepreloader.ts
Normal file
|
@ -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<string>();
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
173
src/equicordplugins/accelerator/modules/intersection.ts
Normal file
173
src/equicordplugins/accelerator/modules/intersection.ts
Normal file
|
@ -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<Element>();
|
||||||
|
private imagePreloadQueue = new Set<string>();
|
||||||
|
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();
|
86
src/equicordplugins/accelerator/modules/messageAccessory.tsx
Normal file
86
src/equicordplugins/accelerator/modules/messageAccessory.tsx
Normal file
|
@ -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<string>();
|
||||||
|
|
||||||
|
export function CacheIndicatorAccessory({ message }: MessageAccessoryProps) {
|
||||||
|
const [showIndicator, setShowIndicator] = React.useState(false);
|
||||||
|
const indicatorRef = React.useRef<HTMLDivElement>(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 (
|
||||||
|
<div
|
||||||
|
ref={indicatorRef}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "50%",
|
||||||
|
right: "12px",
|
||||||
|
width: "10px",
|
||||||
|
height: "10px",
|
||||||
|
backgroundColor: "#3ba55c",
|
||||||
|
borderRadius: "50%",
|
||||||
|
zIndex: 100,
|
||||||
|
transform: "translateY(-50%)",
|
||||||
|
boxShadow: "0 0 0 2px rgba(32, 34, 37, 0.95), 0 2px 8px rgba(0, 0, 0, 0.5)",
|
||||||
|
animation: "acceleratorCachePulse 2.5s ease-in-out infinite",
|
||||||
|
cursor: "help",
|
||||||
|
pointerEvents: "none" // Don't interfere with message interactions
|
||||||
|
}}
|
||||||
|
title="This message was served instantly from fastcache"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
214
src/equicordplugins/accelerator/modules/preloader.ts
Normal file
214
src/equicordplugins/accelerator/modules/preloader.ts
Normal file
|
@ -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<string, PreloadedChannel>();
|
||||||
|
private preloadQueue = new Set<string>();
|
||||||
|
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();
|
513
src/equicordplugins/accelerator/modules/stats.ts
Normal file
513
src/equicordplugins/accelerator/modules/stats.ts
Normal file
|
@ -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<string, number>(); // Track when image requests start
|
||||||
|
private processedImages = new Set<string>(); // 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 = `
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: ${this.isMinimized ? '0' : '8px'}; padding-bottom: 4px; border-bottom: 1px solid var(--background-modifier-accent);">
|
||||||
|
<span style="font-weight: 600; color: var(--text-brand);">🚀 Accelerator Stats</span>
|
||||||
|
<div style="display: flex; gap: 8px;">
|
||||||
|
<button id="accelerator-minimize" style="background: none; border: none; color: var(--text-muted); cursor: pointer; padding: 2px; font-size: 14px;">${this.isMinimized ? '📈' : '📉'}</button>
|
||||||
|
<button id="accelerator-close" style="background: none; border: none; color: var(--text-muted); cursor: pointer; padding: 2px; font-size: 12px;">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const contentHTML = this.isMinimized ? '' : `
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; font-size: 11px;">
|
||||||
|
<div style="background: var(--background-tertiary); padding: 6px; border-radius: 4px;">
|
||||||
|
<div style="color: var(--text-muted); margin-bottom: 2px;">Channel Switches</div>
|
||||||
|
<div style="font-weight: 600; color: var(--text-brand);">${formatNumber(stats.channelSwitches)}</div>
|
||||||
|
</div>
|
||||||
|
<div style="background: var(--background-tertiary); padding: 6px; border-radius: 4px;">
|
||||||
|
<div style="color: var(--text-muted); margin-bottom: 2px;">Channel Load Time</div>
|
||||||
|
<div style="font-weight: 600; color: ${stats.averageLoadTime < 1000 ? 'var(--text-positive)' : stats.averageLoadTime < 2000 ? 'var(--text-warning)' : 'var(--text-danger)'};">${formatLoadTime(stats.averageLoadTime)}</div>
|
||||||
|
</div>
|
||||||
|
<div style="background: var(--background-tertiary); padding: 6px; border-radius: 4px;">
|
||||||
|
<div style="color: var(--text-muted); margin-bottom: 2px;">Messages from Discord</div>
|
||||||
|
<div style="font-weight: 600; color: var(--text-normal);">${formatNumber(stats.messagesLoaded)}</div>
|
||||||
|
</div>
|
||||||
|
<div style="background: var(--background-tertiary); padding: 6px; border-radius: 4px;">
|
||||||
|
<div style="color: var(--text-muted); margin-bottom: 2px;">Messages from Cache</div>
|
||||||
|
<div style="font-weight: 600; color: var(--text-positive);">${formatNumber(stats.messagesServedFromCache)}</div>
|
||||||
|
</div>
|
||||||
|
<div style="background: var(--background-tertiary); padding: 6px; border-radius: 4px;">
|
||||||
|
<div style="color: var(--text-muted); margin-bottom: 2px;">Messages Stored</div>
|
||||||
|
<div style="font-weight: 600; color: var(--text-brand);">${formatNumber(stats.messagesCached)}</div>
|
||||||
|
</div>
|
||||||
|
<div style="background: var(--background-tertiary); padding: 6px; border-radius: 4px;">
|
||||||
|
<div style="color: var(--text-muted); margin-bottom: 2px;">Message Cache Rate</div>
|
||||||
|
<div style="font-weight: 600; color: ${cacheRate > 70 ? 'var(--text-positive)' : cacheRate > 40 ? 'var(--text-warning)' : 'var(--text-danger)'};">${formatCacheRate(cacheRate)}</div>
|
||||||
|
</div>
|
||||||
|
<div style="background: var(--background-tertiary); padding: 6px; border-radius: 4px;">
|
||||||
|
<div style="color: var(--text-muted); margin-bottom: 2px;">Images Preloaded</div>
|
||||||
|
<div style="font-weight: 600; color: var(--text-positive);">${formatNumber(stats.imagesOptimized)}</div>
|
||||||
|
</div>
|
||||||
|
<div style="background: var(--background-tertiary); padding: 6px; border-radius: 4px;">
|
||||||
|
<div style="color: var(--text-muted); margin-bottom: 2px;">Images from Cache</div>
|
||||||
|
<div style="font-weight: 600; color: var(--text-positive);">${formatNumber(stats.imagesCached)}</div>
|
||||||
|
</div>
|
||||||
|
<div style="background: var(--background-tertiary); padding: 6px; border-radius: 4px;">
|
||||||
|
<div style="color: var(--text-muted); margin-bottom: 2px;">Uptime</div>
|
||||||
|
<div style="font-weight: 600; color: var(--text-muted);">${uptime}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
171
src/equicordplugins/accelerator/styles.css
Normal file
171
src/equicordplugins/accelerator/styles.css
Normal file
|
@ -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;
|
||||||
|
}
|
|
@ -1094,6 +1094,10 @@ export const EquicordDevs = Object.freeze({
|
||||||
name: "Suffocate",
|
name: "Suffocate",
|
||||||
id: 772601756776923187n
|
id: 772601756776923187n
|
||||||
},
|
},
|
||||||
|
galpt: {
|
||||||
|
name: "galpt",
|
||||||
|
id: 631418827841863712n
|
||||||
|
},
|
||||||
} satisfies Record<string, Dev>);
|
} satisfies Record<string, Dev>);
|
||||||
|
|
||||||
// iife so #__PURE__ works correctly
|
// iife so #__PURE__ works correctly
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue