diff --git a/src/core/plugins/index.ts b/src/core/plugins/index.ts index 845cabd..1545ea5 100644 --- a/src/core/plugins/index.ts +++ b/src/core/plugins/index.ts @@ -36,7 +36,6 @@ export const getCorePlugins = (): CorePluginRecord => ({ "bunny.notrack": makeLazyPlugin(() => require("./notrack")), "bunny.messagefix": makeLazyPlugin(() => require("./messagefix")), "bunny.fixembed": makeLazyPlugin(() => require("./fixembed")), - "bunny.enhancements": makeLazyPlugin(() => require("./shiggyenhancements")), }); /** diff --git a/src/core/plugins/shiggyenhancements/index.tsx b/src/core/plugins/shiggyenhancements/index.tsx deleted file mode 100644 index b1c5005..0000000 --- a/src/core/plugins/shiggyenhancements/index.tsx +++ /dev/null @@ -1,301 +0,0 @@ -import { defineCorePlugin } from ".."; -import { findByProps } from "@metro"; -import { logger } from "@lib/utils/logger"; -import { settings } from "@lib/api/settings"; -import { React } from "@metro/common"; - -const { TableRowGroup, TableRow, TableSwitchRow, Stack } = findByProps( - "TableRowGroup", - "TableRow", - "TableSwitchRow", - "Stack", -); -const { ScrollView } = require("react-native"); - -// Get ReactNative for NativeModules -const { ReactNative } = window as any; -const { DCDSoundManager } = ReactNative?.NativeModules || {}; - -// Sound settings type -type SoundSettings = { - enabled: boolean; - probability: number; -}; - -declare module "@lib/api/settings" { - interface Settings { - startupSound?: SoundSettings; - } -} - -const soundUrl = - "https://github.com/kmmiio99o/kmmiio99o.github.io/raw/refs/heads/main/music/shiggycord.mp3"; -const soundId = 6971; -let soundDuration = -1; -let timeoutId: NodeJS.Timeout | null = null; -let isPlaying = false; -let isPrepared = false; - -// Function to prepare the sound -const prepareSound = function (): Promise { - return new Promise(function (resolve) { - if (!DCDSoundManager) { - logger.error("DCDSoundManager not available"); - resolve(null); - return; - } - - DCDSoundManager.prepare( - soundUrl, - "music", - soundId, - function (error: any, sound: any) { - if (error) { - logger.error("Failed to prepare sound:", error); - resolve(null); - } else { - resolve(sound); - } - }, - ); - }); -}; - -// Function to play the sound -async function playSound() { - if (!DCDSoundManager) { - logger.error("DCDSoundManager not available for playback"); - return; - } - - if (isPlaying) { - if (timeoutId != null) clearTimeout(timeoutId); - DCDSoundManager.stop(soundId); - isPlaying = false; - } - - isPlaying = true; - - try { - await DCDSoundManager.play(soundId); - - if (soundDuration > 0) { - timeoutId = setTimeout(function () { - isPlaying = false; - DCDSoundManager.stop(soundId); - timeoutId = null; - }, soundDuration); - } - } catch (error) { - logger.error("Error playing sound:", error); - isPlaying = false; - } -} - -// Check if we should play based on probability -function shouldPlaySound(): boolean { - const probability = settings.startupSound?.probability ?? 2; - const random = Math.random() * 100; - return random <= probability; -} - -export default defineCorePlugin({ - manifest: { - id: "bunny.enhancements", - version: "0.1.0", - type: "plugin", - spec: 3, - main: "", - display: { - name: "Shiggy Enhancements", - description: - "Fixes common discord bugs because discord wont, and adds some features. (Originally Kettu Enhancements)", - authors: [{ name: "cocobo1" }, { name: "Shiggy Team" }], - }, - }, - - SettingsComponent() { - const { useState, useEffect } = React; - const [config, setConfig] = useState({ - enabled: settings.startupSound?.enabled ?? true, - probability: settings.startupSound?.probability ?? 2, - }); - - useEffect(() => { - settings.startupSound = config; - }, [config]); - - const updateConfig = ( - key: keyof SoundSettings, - value: boolean | number, - ) => { - setConfig((prev) => ({ ...prev, [key]: value })); - }; - - // Get UI components including TextInput - const { - TableRowGroup: _TRG, - TableRow: _TR, - TableSwitchRow: _TSR, - Stack: _S, - TextInput: _TextInput, - } = findByProps( - "TableRowGroup", - "TableRow", - "TableSwitchRow", - "Stack", - "TextInput", - ); - - // Fallback if table components aren't available - if (!_TRG || !_TSR || !_TR || !_S) { - const FallbackText = - "Startup Sound UI unavailable (missing TableRow components)."; - return React.createElement( - ScrollView, - { style: { flex: 1, padding: 12 } }, - FallbackText, - ); - } - - const TableRowGroup = _TRG; - const TableRow = _TR; - const TableSwitchRow = _TSR; - const Stack = _S; - const TextInput = _TextInput; - - // Handle probability input change - const handleProbabilityChange = (v: string) => { - const num = parseInt(v); - if (!isNaN(num) && num >= 0 && num <= 100) { - updateConfig("probability", num); - } else if (v === "") { - updateConfig("probability", 0); - } - }; - - // Create settings elements - const settingsElements = []; - - // Startup Sound toggle with probability display - settingsElements.push( - React.createElement( - TableRowGroup, - { title: "Startup Sound", key: "startup-sound" }, - React.createElement(TableSwitchRow, { - label: "Enable Startup Sound", - value: config.enabled, - onValueChange: (v: boolean) => updateConfig("enabled", v), - }), - React.createElement(TableRow, { - label: "Play Chance", - subLabel: `Current: ${config.probability}% chance to play on startup`, - }), - ), - ); - - // Probability input in its own invisible group - if (TextInput) { - settingsElements.push( - React.createElement( - TableRowGroup, - { title: "", key: "probability-input-group" }, - React.createElement(TextInput, { - placeholder: "Enter probability (0-100)", - value: config.probability.toString(), - onChange: handleProbabilityChange, - key: "probability-input", - }), - ), - ); - } - - // About section - settingsElements.push( - React.createElement( - TableRowGroup, - { title: "About", key: "about" }, - React.createElement(TableRow, { - label: "Description", - subLabel: - "Shiggy Enhancements, more options, more features. Originally Kettu Enhancements with additinal features.", - disabled: true, - }), - ), - ); - - return React.createElement( - ScrollView, - { style: { flex: 1 } }, - React.createElement( - Stack, - { spacing: 8, style: { padding: 10 } }, - ...settingsElements, - ), - ); - }, - - start() { - logger.log("Shiggy Enhancements: Starting plugin"); - - // Initialize sound settings - settings.startupSound = settings.startupSound || { - enabled: false, - probability: 2, - }; - - // Check if DCDSoundManager is available - if (!DCDSoundManager) { - logger.error("DCDSoundManager not found - startup sound disabled"); - return; - } - - // Play startup sound if enabled and probability check passes - if (settings.startupSound.enabled && shouldPlaySound()) { - logger.log( - `Attempting to play startup sound (${settings.startupSound.probability}% chance)`, - ); - - // Always prepare the sound fresh each time - prepareSound() - .then(function (sound) { - if (sound) { - isPrepared = true; - soundDuration = sound.duration || 5000; - logger.log(`Sound prepared, duration: ${soundDuration}ms`); - playSound(); - logger.log( - `Startup sound played (${settings.startupSound?.probability}% chance)`, - ); - } else { - logger.error("Failed to prepare sound"); - } - }) - .catch((error) => { - logger.error("Error in sound preparation:", error); - }); - } else if (settings.startupSound.enabled) { - logger.log( - `Startup sound skipped due to probability (${settings.startupSound?.probability}% chance)`, - ); - } - }, - - stop() { - logger.log("Shiggy Enhancements: Stopping plugin"); - - // Stop and cleanup audio - if (DCDSoundManager && isPlaying) { - DCDSoundManager.stop(soundId); - isPlaying = false; - } - - if (timeoutId) { - clearTimeout(timeoutId); - timeoutId = null; - } - - // Reset preparation state so it can play again next time - isPrepared = false; - }, -}); diff --git a/src/core/ui/components/AddonPage.tsx b/src/core/ui/components/AddonPage.tsx index 06dd58f..d1cd4ad 100644 --- a/src/core/ui/components/AddonPage.tsx +++ b/src/core/ui/components/AddonPage.tsx @@ -22,7 +22,6 @@ import { } from "@metro/common/components"; import { ErrorBoundary, Search } from "@ui/components"; import { isNotNil } from "es-toolkit"; -import fuzzysort from "fuzzysort"; import { ComponentType, ReactNode, @@ -181,6 +180,27 @@ export default function AddonPage({ const { bottom: bottomInset } = useSafeAreaInsets(); const navigation = NavigationNative.useNavigation(); + // Lazy-load fuzzysort to reduce initial bundle size + const fuzzysortRef = React.useRef(null); + const [fuzzysortReady, setFuzzysortReady] = React.useState(false); + React.useEffect(() => { + let mounted = true; + import("fuzzysort") + .then((mod) => { + const f = (mod as any).default ?? mod; + if (mounted) { + fuzzysortRef.current = f; + setFuzzysortReady(true); + } + }) + .catch(() => { + // ignore load errors + }); + return () => { + mounted = false; + }; + }, []); + useEffect(() => { if (props.OptionsActionSheetComponent) { navigation.setOptions({ @@ -205,11 +225,13 @@ export default function AddonPage({ const items = values.filter((i) => isNotNil(i) && typeof i === "object"); if (!search && sortFn) items.sort(sortFn); - return fuzzysort.go(search, items, { + if (!fuzzysortReady || !fuzzysortRef.current) return [] as any[]; + + return fuzzysortRef.current.go(search, items, { keys: props.searchKeywords, all: true, }); - }, [props.items, sortFn, search]); + }, [props.items, sortFn, search, fuzzysortReady]); const onInstallPress = useCallback(() => { if (!props.installAction) return () => {}; diff --git a/src/core/ui/settings/pages/Plugins/components/PluginCard.tsx b/src/core/ui/settings/pages/Plugins/components/PluginCard.tsx index b922379..1fd422b 100644 --- a/src/core/ui/settings/pages/Plugins/components/PluginCard.tsx +++ b/src/core/ui/settings/pages/Plugins/components/PluginCard.tsx @@ -11,10 +11,11 @@ import { Text, } from "@metro/common/components"; import { showSheet } from "@ui/sheets"; -import chroma from "chroma-js"; import { createContext, useContext, useMemo } from "react"; import { Image, View } from "react-native"; +const getChroma = () => require("chroma-js") as typeof import("chroma-js").default; + const CardContext = createContext<{ plugin: UnifiedPluginModel; result: Fuzzysort.KeysResult; @@ -22,7 +23,7 @@ const CardContext = createContext<{ const useCardContext = () => useContext(CardContext); function getHighlightColor(): import("react-native").ColorValue { - return chroma(tokens.unsafe_rawColors.YELLOW_300).alpha(0.3).hex(); + return getChroma()(tokens.unsafe_rawColors.YELLOW_300).alpha(0.3).hex(); } function Title() { diff --git a/src/core/vendetta/api.tsx b/src/core/vendetta/api.tsx index 13f5063..72b3fd0 100644 --- a/src/core/vendetta/api.tsx +++ b/src/core/vendetta/api.tsx @@ -147,7 +147,12 @@ export const initVendettaObject = (): any => { return require("moment"); }, get chroma() { - return require("chroma-js"); + // Lazy-load chroma-js to reduce startup cost + const cached = (this as any)._chroma; + if (cached) return cached; + const loaded = require("chroma-js"); + (this as any)._chroma = loaded; + return loaded; }, get lodash() { return require("lodash"); diff --git a/src/core/vendetta/storage.ts b/src/core/vendetta/storage.ts index 9a5bf8c..08579c6 100644 --- a/src/core/vendetta/storage.ts +++ b/src/core/vendetta/storage.ts @@ -153,6 +153,8 @@ export interface StorageBackend { } const ILLEGAL_CHARS_REGEX = /[<>:"/\\|?*]/g; +// Debouncer map for per-store writes to disk +const _writeDebouncers = new Map(); const filePathFixer = (file: string): string => Platform.select({ default: file, @@ -215,6 +217,71 @@ export const createMMKVBackend = (store: string, defaultData = {}) => { })()); }; +// Lightweight in-memory backend to accelerate startup and avoid blocking IO +// while still providing eventual persistence to the native storage. +export const createMemoryBackend = ( + store: string, + defaultData: any = {}, +) => { + const mmkvPath = getMMKVPath(store); + const defaultStr = JSON.stringify(defaultData); + + // In-memory backing object used by the proxy immediately + const memory: any = { ...defaultData }; + + // Load persisted data in background without blocking startup + (async () => { + try { + const cached = await NativeCacheModule.getItem(store); + if (cached) { + const parsed = JSON.parse(cached); + Object.assign(memory, parsed); + } + } catch { + // ignore + } + + try { + const path = `${NativeFileModule.getConstants().DocumentsDirPath}/${mmkvPath}`; + if (await NativeFileModule.fileExists(path)) { + const content = await NativeFileModule.readFile(path, "utf8"); + const data = JSON.parse(content); + Object.assign(memory, data); + } + } catch { + // ignore + } + })(); + + // Hydration is intentionally omitted for stability in this rewrite + + // Local path helper (inlined to avoid hoisting concerns) + const fixPath = (file: string) => + Platform.select({ default: file, ios: NativeFileModule.saveFileToGallery ? file : `Documents/${file}` }); + + return { + get: async () => memory, + set: async (data: any) => { + Object.assign(memory, data); + // Debounced persistence to disk to avoid IO storms + try { + const existing = _writeDebouncers.get(store); + if (existing) clearTimeout(existing); + const timeout = setTimeout(async () => { + try { + await NativeFileModule.writeFile("documents", fixPath(mmkvPath), JSON.stringify(memory), "utf8"); + } catch { + // ignore persistence errors to keep startup fast + } + }, 120); + _writeDebouncers.set(store, timeout); + } catch { + // ignore + } + }, + } as unknown as StorageBackend; +}; + export const createFileBackend = (file: string, defaultData = {}, migratePromise?: Promise): StorageBackend => { return { get: async () => { @@ -239,4 +306,3 @@ export const createFileBackend = (file: string, defaultData = {}, migratePromise } }; }; - diff --git a/src/lib/addons/fonts/index.ts b/src/lib/addons/fonts/index.ts index 47fc4d4..07a671a 100644 --- a/src/lib/addons/fonts/index.ts +++ b/src/lib/addons/fonts/index.ts @@ -1,4 +1,4 @@ -import { awaitStorage, createMMKVBackend, createStorage, wrapSync } from "@core/vendetta/storage"; +import { awaitStorage, createMemoryBackend, createStorage, wrapSync } from "@core/vendetta/storage"; import { clearFolder, downloadFile, fileExists, removeFile, writeFile } from "@lib/api/native/fs"; import { safeFetch } from "@lib/utils"; @@ -13,7 +13,8 @@ export type FontDefinition = { } type FontStorage = Record & { __selected?: string; }; -export const fonts = wrapSync(createStorage(createMMKVBackend("BUNNY_FONTS"))); +// Use a memory-backed storage for faster startup; persistence is still written back to disk +export const fonts = wrapSync(createStorage(createMemoryBackend("BUNNY_FONTS", {}))); async function writeFont(font: FontDefinition | null) { if (!font && font !== null) throw new Error("Arg font must be a valid object or null"); diff --git a/src/lib/addons/themes/colors/parser.ts b/src/lib/addons/themes/colors/parser.ts index 2bafafd..0b7d342 100644 --- a/src/lib/addons/themes/colors/parser.ts +++ b/src/lib/addons/themes/colors/parser.ts @@ -1,8 +1,9 @@ import { findByProps } from "@metro"; -import chroma from "chroma-js"; import { omit } from "es-toolkit"; import { Platform, processColor } from "react-native"; +const getChroma = () => require("chroma-js") as typeof import("chroma-js").default; + import { colorsPref } from "./preferences"; import { ColorManifest, InternalColorDefinition } from "./types"; @@ -33,7 +34,7 @@ export function parseColorManifest(manifest: ColorManifest): InternalColorDefini } else if (typeof semanticColorValue === "string") { if (semanticColorValue.startsWith("#")) { semanticColorDefinitions[semanticColorKey] = { - value: chroma(semanticColorValue).hex(), + value: getChroma()(semanticColorValue).hex(), opacity: 1, }; } else { @@ -208,7 +209,7 @@ export function applyAndroidAlphaKeys(rawColors?: Record) { for (const key in alphaMap) { const [colorKey, alpha] = alphaMap[key]; if (!rawColors[colorKey]) continue; - rawColors[key] = chroma(rawColors[colorKey]).alpha(alpha).hex(); + rawColors[key] = getChroma()(rawColors[colorKey]).alpha(alpha).hex(); } return rawColors; @@ -221,11 +222,11 @@ export function normalizeToHex(colorString: string | undefined): string | undefi return "#00000000"; } - if (chroma.valid(colorString)) return chroma(colorString).hex(); + if (getChroma().valid(colorString)) return getChroma()(colorString).hex(); const color = Number(processColor(colorString)); - return chroma.rgb( + return getChroma().rgb( color >> 16 & 0xff, // red color >> 8 & 0xff, // green color & 0xff, // blue diff --git a/src/lib/addons/themes/colors/patches/background.tsx b/src/lib/addons/themes/colors/patches/background.tsx index b678ee5..468c30c 100644 --- a/src/lib/addons/themes/colors/patches/background.tsx +++ b/src/lib/addons/themes/colors/patches/background.tsx @@ -4,7 +4,7 @@ import { after } from "@lib/api/patcher"; import { useObservable } from "@lib/api/storage"; import { findInReactTree } from "@lib/utils"; import { findByFilePathLazy } from "@metro"; -import chroma from "chroma-js"; +const getChroma = () => require("chroma-js") as typeof import("chroma-js").default; import { ImageBackground, StyleSheet } from "react-native"; import { logger } from "@lib/utils/logger"; @@ -43,7 +43,7 @@ export default function patchChatBackground() { if (messagesComponent) { const flattened = StyleSheet.flatten(messagesComponent.props.style); - const backgroundColor = chroma( + const backgroundColor = getChroma()( flattened.backgroundColor || "black" ).alpha( 1 - (_colorRef.current.background?.opacity ?? 1) diff --git a/src/lib/addons/themes/colors/patches/resolver.ts b/src/lib/addons/themes/colors/patches/resolver.ts index 93ee2a2..73b8bd3 100644 --- a/src/lib/addons/themes/colors/patches/resolver.ts +++ b/src/lib/addons/themes/colors/patches/resolver.ts @@ -4,7 +4,7 @@ import { before, instead } from "@lib/api/patcher"; import { findByProps } from "@metro"; import { byMutableProp } from "@metro/filters"; import { createLazyModule } from "@metro/lazy"; -import chroma from "chroma-js"; +const getChroma = () => require("chroma-js") as typeof import("chroma-js").default; const tokenReference = findByProps("SemanticColor"); const isThemeModule = createLazyModule(byMutableProp("isThemeDark")); @@ -67,14 +67,14 @@ export default function patchDefinitionAndResolver() { if (semanticDef?.value) { if (semanticDef.opacity === 1) return semanticDef.value; - return chroma(semanticDef.value).alpha(semanticDef.opacity).hex(); + return getChroma()(semanticDef.value).alpha(semanticDef.opacity).hex(); } const rawKey = colorDef.raw; const rawValue = rawKey ? currentRef.raw[rawKey] : undefined; if (rawValue) { // Set opacity if needed - return (colorDef.opacity === 1) ? rawValue : chroma(rawValue).alpha(colorDef.opacity).hex(); + return (colorDef.opacity === 1) ? rawValue : getChroma()(rawValue).alpha(colorDef.opacity).hex(); } // Fallback to default diff --git a/src/metro/common/types/components.ts b/src/metro/common/types/components.ts index 87a3bf3..381d4b5 100644 --- a/src/metro/common/types/components.ts +++ b/src/metro/common/types/components.ts @@ -1,6 +1,6 @@ import { Nullish } from "@lib/utils/types"; import { TextStyles, ThemeColors } from "@ui/types"; -import { Falsey } from "lodash"; +import type { Falsey } from "lodash"; import { FC, MutableRefObject, PropsWithoutRef, ReactNode, RefObject } from "react"; import type * as RN from "react-native"; import { ImageSourcePropType, PressableProps } from "react-native";