diff --git a/packages/discord-types/src/common/User.d.ts b/packages/discord-types/src/common/User.d.ts index 26047a7c6..0905b8bf2 100644 --- a/packages/discord-types/src/common/User.d.ts +++ b/packages/discord-types/src/common/User.d.ts @@ -83,3 +83,52 @@ export interface UserJSON { username: string; globalName: string | undefined; } + +export interface ProfileEffect { + skuId: string; + title?: string; + description?: string; + accessibilityLabel?: string; + reducedMotionSrc?: string; + thumbnailPreviewSrc?: string; + effects?: any[]; + animationType?: number; + staticFrameSrc?: string; + type?: number; +} + +export interface Nameplate { + skuId: string; + asset: string; + label?: string; + palette?: string; + type?: number; +} + +export interface ProfilePreset { + name: string; + timestamp: number; + avatarDataUrl?: string | null; + bannerDataUrl?: string | null; + bio?: string | null; + accentColor?: number | null; + themeColors?: number[] | null; + globalName?: string | null; + pronouns?: string | null; + avatarDecoration?: { + asset: string; + skuId: string; + } | null; + profileEffect?: ProfileEffect | null; + nameplate?: Nameplate | null; + primaryGuildId?: string | null; + customStatus?: CustomStatus | null; + displayNameStyles?: DisplayNameStyles | null; +} + +export interface CustomStatus { + text?: string; + emojiId?: string; + emojiName?: string; + expiresAtMs?: string; +} diff --git a/packages/discord-types/src/stores/UserProfileStore.d.ts b/packages/discord-types/src/stores/UserProfileStore.d.ts index f6c5e5ad9..84929d60b 100644 --- a/packages/discord-types/src/stores/UserProfileStore.d.ts +++ b/packages/discord-types/src/stores/UserProfileStore.d.ts @@ -1,4 +1,4 @@ -import { FluxStore, Guild, User, Application, ApplicationInstallParams } from ".."; +import { FluxStore, Guild, User, Application, ApplicationInstallParams, ProfileEffect } from ".."; import { ApplicationFlags, ApplicationIntegrationType } from "../../enums"; export interface MutualFriend { @@ -70,9 +70,11 @@ export interface UserProfileBase extends Pick { */ badges: ProfileBadge[]; bio: string | undefined; + collectibles: ProfileEffect[]; popoutAnimationParticleType: string | null; profileEffectExpiresAt: number | Date | undefined; profileEffectId: undefined | string; + profileEffect: ProfileEffect; /** * often an empty string when not set */ diff --git a/src/equicordplugins/bannersEverywhere/index.tsx b/src/equicordplugins/bannersEverywhere/index.tsx index 2c7188d34..3023ce951 100644 --- a/src/equicordplugins/bannersEverywhere/index.tsx +++ b/src/equicordplugins/bannersEverywhere/index.tsx @@ -12,7 +12,7 @@ import usrbg from "@plugins/usrbg"; import { Devs } from "@utils/constants"; import definePlugin, { OptionType } from "@utils/types"; import { User } from "@vencord/discord-types"; -import { findStoreLazy } from "@webpack"; +import { UserProfileStore } from "@webpack/common"; import style from "./style.css?managed"; @@ -41,8 +41,6 @@ const settings = definePluginSettings({ const DATASTORE_KEY = "bannersEverywhere"; -const UserProfileStore = findStoreLazy("UserProfileStore"); - export default definePlugin({ name: "BannersEverywhere", description: "Displays banners in the member list ", diff --git a/src/equicordplugins/components.dev/components/ComponentsTab.tsx b/src/equicordplugins/components.dev/components/ComponentsTab.tsx index 26ace0405..724a76cc9 100644 --- a/src/equicordplugins/components.dev/components/ComponentsTab.tsx +++ b/src/equicordplugins/components.dev/components/ComponentsTab.tsx @@ -4,8 +4,6 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -import "../styles.css"; - import ErrorBoundary from "@components/ErrorBoundary"; import { Heading } from "@components/Heading"; import { SettingsTab, wrapTab } from "@components/settings"; diff --git a/src/equicordplugins/components.dev/index.tsx b/src/equicordplugins/components.dev/index.tsx index 56c8acf82..1a2536aba 100644 --- a/src/equicordplugins/components.dev/index.tsx +++ b/src/equicordplugins/components.dev/index.tsx @@ -4,6 +4,8 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ +import "./styles.css"; + import SettingsPlugin from "@plugins/_core/settings"; import { EquicordDevs } from "@utils/constants"; import definePlugin, { StartAt } from "@utils/types"; diff --git a/src/equicordplugins/iconViewer/components/IconsTab.tsx b/src/equicordplugins/iconViewer/components/IconsTab.tsx index 423f5a2e5..2996e33ef 100644 --- a/src/equicordplugins/iconViewer/components/IconsTab.tsx +++ b/src/equicordplugins/iconViewer/components/IconsTab.tsx @@ -4,8 +4,6 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -import "../styles.css"; - import { Button } from "@components/Button"; import { Heading } from "@components/Heading"; import { SettingsTab, wrapTab } from "@components/settings"; diff --git a/src/equicordplugins/iconViewer/index.tsx b/src/equicordplugins/iconViewer/index.tsx index 864bd79da..2ab4442fb 100644 --- a/src/equicordplugins/iconViewer/index.tsx +++ b/src/equicordplugins/iconViewer/index.tsx @@ -4,6 +4,8 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ +import "./styles.css"; + import { MagnifyingGlassIcon } from "@components/Icons"; import SettingsPlugin, { settingsSectionMap } from "@plugins/_core/settings"; import { EquicordDevs } from "@utils/constants"; diff --git a/src/equicordplugins/profileSets/components/confirmModal.tsx b/src/equicordplugins/profileSets/components/confirmModal.tsx new file mode 100644 index 000000000..2b60da113 --- /dev/null +++ b/src/equicordplugins/profileSets/components/confirmModal.tsx @@ -0,0 +1,50 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Heading } from "@components/Heading"; +import { Button } from "@components/index"; +import { Paragraph } from "@components/Paragraph"; +import { ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot } from "@utils/modal"; +interface ConfirmModalProps extends ModalProps { + title: string; + message: string; + confirmText: string; + cancelText: string; + onConfirm: () => void; + onCancel: () => void; +} + +export function ConfirmModal({ title, message, confirmText, cancelText, onConfirm, onCancel, onClose, transitionState }: ConfirmModalProps) { + return ( + + + {title} + + + {message} + + + + + + + ); +} diff --git a/src/equicordplugins/profileSets/components/presetList.tsx b/src/equicordplugins/profileSets/components/presetList.tsx new file mode 100644 index 000000000..e5e8c011c --- /dev/null +++ b/src/equicordplugins/profileSets/components/presetList.tsx @@ -0,0 +1,205 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { classes } from "@utils/misc"; +import { ProfilePreset } from "@vencord/discord-types"; +import { ContextMenuApi, Menu, React, showToast, TextInput, Toasts } from "@webpack/common"; + +import { cl } from ".."; +import { deletePreset, movePreset, renamePreset, updatePresetField } from "../utils/actions"; +import { getCurrentProfile } from "../utils/profile"; + +interface PresetListProps { + presets: ProfilePreset[]; + allPresets: ProfilePreset[]; + avatarSize: number; + selectedPreset: number; + onLoad: (index: number) => void; + onUpdate: () => void; + guildId?: string; + currentPage: number; + onPageChange: (page: number) => void; +} + +export function PresetList({ presets, allPresets, avatarSize, selectedPreset, onLoad, onUpdate, guildId, currentPage, onPageChange }: PresetListProps) { + const [renaming, setRenaming] = React.useState(-1); + const [renameText, setRenameText] = React.useState(""); + + return ( +
+ {presets.map(preset => { + const actualIndex = allPresets.indexOf(preset); + const isRenaming = renaming === actualIndex; + const date = new Date(preset.timestamp); + const formattedDate = date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric" + }); + const formattedTime = date.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit" + }); + + return ( +
{ + if (!isRenaming) { + onLoad(actualIndex); + showToast(`Profile "${preset.name}" loaded successfully`, Toasts.Type.SUCCESS); + } + }} + onKeyDown={e => { + if (!isRenaming && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + onLoad(actualIndex); + showToast(`Profile "${preset.name}" loaded successfully`, Toasts.Type.SUCCESS); + } + }} + className={classes(cl("row"), !isRenaming && selectedPreset === actualIndex ? "selected" : "")} + > +
+ {preset.avatarDataUrl && ( + + )} +
+ {isRenaming ? ( + { + if (renameText.trim()) { + renamePreset(actualIndex, renameText); + onUpdate(); + } + setRenaming(-1); + }} + onKeyDown={e => { + if (e.key === "Enter") { + if (renameText.trim()) { + renamePreset(actualIndex, renameText); + onUpdate(); + } + setRenaming(-1); + } else if (e.key === "Escape") { + setRenaming(-1); + } + e.stopPropagation(); + }} + onClick={e => e.stopPropagation()} + autoFocus + /> + ) : ( + <> +
+ {preset.name} +
+
+ {formattedDate} at {formattedTime} +
+ + )} +
+
+
+ { + e.stopPropagation(); + const target = e.currentTarget; + ContextMenuApi.openContextMenu(e, () => ( + + { + setRenaming(actualIndex); + setRenameText(preset.name); + }} + /> + { + const profile = await getCurrentProfile(guildId); + Object.entries(profile).forEach(([key, value]) => { + if (value !== null && value !== undefined) { + updatePresetField(actualIndex, key as any, value); + } + }); + showToast("Profile updated successfully", Toasts.Type.SUCCESS); + onUpdate(); + }} + /> + + {actualIndex > 0 && ( + { + movePreset(actualIndex, actualIndex - 1); + onUpdate(); + }} + /> + )} + {actualIndex < allPresets.length - 1 && ( + { + movePreset(actualIndex, actualIndex + 1); + onUpdate(); + }} + /> + )} + {currentPage > 1 && ( + { + movePreset(actualIndex, 0); + onPageChange(1); + onUpdate(); + }} + /> + )} + + { + await deletePreset(actualIndex); + onUpdate(); + }} + /> + + )); + }} + > + + +
+
+ ); + })} +
+ ); +} diff --git a/src/equicordplugins/profileSets/components/presetManager.tsx b/src/equicordplugins/profileSets/components/presetManager.tsx new file mode 100644 index 000000000..660abc687 --- /dev/null +++ b/src/equicordplugins/profileSets/components/presetManager.tsx @@ -0,0 +1,198 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Heading } from "@components/Heading"; +import { Button } from "@components/index"; +import { openModal } from "@utils/modal"; +import { React, TextInput } from "@webpack/common"; + +import { cl, settings } from "../index"; +import { exportPresets, importPresets, savePreset } from "../utils/actions"; +import { loadPresetAsPending } from "../utils/profile"; +import { presets, setCurrentPresetIndex } from "../utils/storage"; +import { ConfirmModal } from "./confirmModal"; +import { PresetList } from "./presetList"; + +const PRESETS_PER_PAGE = 5; + +export function PresetManager({ guildId }: { guildId?: string; }) { + const [presetName, setPresetName] = React.useState(""); + const [, forceUpdate] = React.useReducer(x => x + 1, 0); + const [isSaving, setIsSaving] = React.useState(false); + const [currentPage, setCurrentPage] = React.useState(1); + const [pageInput, setPageInput] = React.useState("1"); + const [selectedPreset, setSelectedPreset] = React.useState(-1); + const [searchMode, setSearchMode] = React.useState(false); + + const filteredPresets = searchMode + ? presets.filter(p => p.name.toLowerCase().includes(presetName.toLowerCase())) + : presets; + + const totalPages = Math.ceil(filteredPresets.length / PRESETS_PER_PAGE); + const startIndex = (currentPage - 1) * PRESETS_PER_PAGE; + const currentPresets = filteredPresets.slice(startIndex, startIndex + PRESETS_PER_PAGE); + + const handlePageChange = (newPage: number) => { + if (newPage >= 1 && newPage <= totalPages) { + setCurrentPage(newPage); + setPageInput(String(newPage)); + } + }; + + const handleSavePreset = async () => { + if (!presetName.trim()) return; + setIsSaving(true); + await savePreset(presetName.trim(), guildId); + setPresetName(""); + setIsSaving(false); + const newTotalPages = Math.ceil(presets.length / PRESETS_PER_PAGE); + handlePageChange(newTotalPages); + forceUpdate(); + }; + + const handleLoadPreset = (index: number) => { + setSelectedPreset(index); + setCurrentPresetIndex(index); + loadPresetAsPending(presets[index], guildId); + forceUpdate(); + }; + + const showOverridePrompt = (): Promise => { + return new Promise(resolve => { + openModal(props => ( + resolve(true)} + onCancel={() => resolve(false)} + /> + )); + }); + }; + + const avatarSize = settings.store.avatarSize || 40; + const hasPresets = presets.length > 0; + const shouldShowPagination = filteredPresets.length > PRESETS_PER_PAGE; + + return ( +
+ + Saved Profiles + + +
+ +
+ +
+ {!searchMode && ( + + )} + {hasPresets && ( + + )} +
+ + {hasPresets && ( + <> + + + {shouldShowPagination && ( +
+ +
+ { + const { value } = e.target; + setPageInput(value); + const num = parseInt(value); + if (!isNaN(num) && num >= 1 && num <= totalPages) { + setCurrentPage(num); + } + }} + className={cl("page-input")} + /> + + / {totalPages} + +
+ +
+ )} + +
+ + +
+ +
+ + )} +
+ ); +} diff --git a/src/equicordplugins/profileSets/index.tsx b/src/equicordplugins/profileSets/index.tsx new file mode 100644 index 000000000..115cd0553 --- /dev/null +++ b/src/equicordplugins/profileSets/index.tsx @@ -0,0 +1,49 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import "./styles.css"; + +import { definePluginSettings } from "@api/Settings"; +import { EquicordDevs } from "@utils/constants"; +import { classNameFactory } from "@utils/css"; +import definePlugin, { makeRange, OptionType } from "@utils/types"; +import { React } from "@webpack/common"; + +import { PresetManager } from "./components/presetManager"; +import { loadPresets } from "./utils/storage"; + +export const cl = classNameFactory("vc-profile-presets-"); +export const settings = definePluginSettings({ + avatarSize: { + type: OptionType.SLIDER, + description: "Avatar size in preset list.", + default: 40, + markers: makeRange(32, 96, 8), + stickToMarkers: true + }, +}); + +export default definePlugin({ + name: "ProfileSets", + description: "Allows you to save and load different profile presets, via the Profile Section in Settings.", + authors: [EquicordDevs.omaw, EquicordDevs.justjxke], + settings, + patches: [ + { + find: "DefaultCustomizationSections: user cannot be undefined", + replacement: { + match: /return.{0,50}children:\[(?<=\.getLegacyUsername\(\).*?)/, + replace: "$&$self.renderPresetSection()," + } + } + ], + start() { + loadPresets(); + }, + renderPresetSection() { + return ; + } +}); diff --git a/src/equicordplugins/profileSets/styles.css b/src/equicordplugins/profileSets/styles.css new file mode 100644 index 000000000..432ca6dcc --- /dev/null +++ b/src/equicordplugins/profileSets/styles.css @@ -0,0 +1,164 @@ +.vc-profile-presets-section { + margin-bottom: 20px; + padding: 0; + background-color: transparent; +} + +.vc-profile-presets-heading { + margin-bottom: 8px; + font-size: 16px; + font-weight: 600; +} + +.vc-profile-presets-text { + margin-bottom: 8px; +} + +.vc-profile-presets-text-input { + width: 100%; + max-width: 500px; +} + +.vc-profile-presets-search { + display: flex; + gap: 8px; + margin-bottom: 12px; + flex-wrap: wrap; +} + +.vc-profile-presets-search-button { + min-width: 120px; +} + +.vc-profile-presets-pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + margin-top: 12px; +} + +.vc-profile-presets-page { + display: flex; + align-items: center; + gap: 4px; +} + +.vc-profile-presets-page-of { + font-size: 14px; + color: var(--text-normal); + font-weight: 500; +} + +.vc-profile-presets-import { + display: flex; + gap: 8px; + margin-bottom: 16px; +} + +.vc-profile-presets-block { + margin: 16px 0; + border: none; + border-top: 1px solid var(--background-modifier-accent); +} + +.vc-profile-presets-avatar-url { + display: flex; + align-items: center; + gap: 12px; + flex: 1; + min-width: 0; +} + +.vc-profile-presets-rename { + min-width: 0; + flex: 1; +} + +.vc-profile-presets-updated { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.vc-profile-presets-list-container { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 12px; + padding: 12px; + background-color: var(--background-secondary); + border-radius: 8px; + border: 2px solid var(--background-modifier-accent); +} + +.vc-profile-presets-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 12px; + background-color: var(--background-primary); + border-radius: 6px; + cursor: pointer; + transition: all 0.15s ease; + border: 2px solid transparent; + outline: none; +} + +.vc-profile-presets-row:hover { + background-color: var(--background-modifier-hover) !important; + border-color: var(--background-modifier-accent) !important; +} + +.vc-profile-presets-row:focus-visible { + border-color: var(--brand-experiment) !important; + box-shadow: 0 0 0 2px var(--brand-experiment-30a); +} + +.vc-profile-presets-row.selected { + background-color: var(--background-modifier-selected); +} + +.vc-profile-presets-avatar { + border-radius: 50%; + border: 2px solid var(--background-tertiary); + flex-shrink: 0; +} + +.vc-profile-presets-name { + font-size: 15px; + font-weight: 600; + color: var(--header-primary); + margin-bottom: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.vc-profile-presets-timestamp { + font-size: 12px; + color: var(--text-normal); + font-weight: 500; + opacity: 0.8; +} + +.vc-profile-presets-menu-icon { + opacity: 0.7; + cursor: pointer; + transition: opacity 0.15s ease; +} + +.vc-profile-presets-menu-icon:hover { + opacity: 1; +} + +.vc-profile-presets-page-input { + width: 40px; + text-align: center; + padding: 4px; + background-color: var(--background-secondary); + border: 2px solid var(--background-modifier-accent); + border-radius: 4px; + color: var(--text-normal); +} \ No newline at end of file diff --git a/src/equicordplugins/profileSets/utils/actions.ts b/src/equicordplugins/profileSets/utils/actions.ts new file mode 100644 index 000000000..b225b48ea --- /dev/null +++ b/src/equicordplugins/profileSets/utils/actions.ts @@ -0,0 +1,135 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { ProfilePreset } from "@vencord/discord-types"; +import { showToast, Toasts } from "@webpack/common"; + +import { getCurrentProfile } from "./profile"; +import { addPreset, movePresetInArray, presets, removePreset, replaceAllPresets, savePresetsData, updatePreset } from "./storage"; + +export async function savePreset(name: string, guildId?: string) { + const profile = await getCurrentProfile(guildId); + const newPreset: ProfilePreset = { + name, + timestamp: Date.now(), + ...profile, + }; + addPreset(newPreset); + await savePresetsData(); + showToast(`Profile "${name}" saved successfully`, Toasts.Type.SUCCESS); +} + +export async function updatePresetField(index: number, field: keyof Omit, value: any) { + if (index < 0 || index >= presets.length) return; + + const updatedPreset = { + ...presets[index], + [field]: value, + timestamp: Date.now() + }; + updatePreset(index, updatedPreset); + await savePresetsData(); +} + +export async function deletePreset(index: number) { + if (index < 0 || index >= presets.length) return; + + const preset = presets[index]; + removePreset(index); + await savePresetsData(); + showToast(`Profile "${preset.name}" deleted`, Toasts.Type.MESSAGE); +} + +export async function movePreset(fromIndex: number, toIndex: number) { + if (fromIndex < 0 || fromIndex >= presets.length || toIndex < 0 || toIndex >= presets.length) return; + + movePresetInArray(fromIndex, toIndex); + await savePresetsData(); +} + +export async function renamePreset(index: number, newName: string) { + if (index < 0 || index >= presets.length || !newName.trim()) return; + + const updatedPreset = { ...presets[index], name: newName.trim() }; + updatePreset(index, updatedPreset); + await savePresetsData(); + showToast(`Profile renamed to "${newName}"`, Toasts.Type.SUCCESS); +} + +export function exportPresets() { + try { + const dataStr = JSON.stringify(presets, null, 2); + const dataBlob = new Blob([dataStr], { type: "application/json" }); + const url = URL.createObjectURL(dataBlob); + const link = document.createElement("a"); + link.href = url; + link.download = `profile-presets-${Date.now()}.json`; + link.click(); + URL.revokeObjectURL(url); + showToast("Profiles exported successfully", Toasts.Type.SUCCESS); + } catch (err) { + showToast("Failed to export profiles", Toasts.Type.FAILURE); + } +} + +function presetsAreEqual(preset1: ProfilePreset, preset2: ProfilePreset): boolean { + return preset1.avatarDataUrl === preset2.avatarDataUrl && + preset1.bannerDataUrl === preset2.bannerDataUrl && + preset1.bio === preset2.bio && + preset1.pronouns === preset2.pronouns && + JSON.stringify(preset1.themeColors) === JSON.stringify(preset2.themeColors) && + JSON.stringify(preset1.avatarDecoration) === JSON.stringify(preset2.avatarDecoration) && + JSON.stringify(preset1.profileEffect) === JSON.stringify(preset2.profileEffect) && + JSON.stringify(preset1.nameplate) === JSON.stringify(preset2.nameplate); +} + +export async function importPresets(forceUpdate: () => void, onOverridePrompt: () => Promise) { + const input = document.createElement("input"); + input.type = "file"; + input.accept = "application/json"; + input.onchange = async (e: any) => { + try { + const file = e.target.files[0]; + if (!file) return; + + const text = await file.text(); + const importedPresets = JSON.parse(text); + + if (!Array.isArray(importedPresets)) { + showToast("Invalid profile file format", Toasts.Type.FAILURE); + return; + } + + if (presets.length > 0) { + const hasDuplicates = importedPresets.some(imported => + presets.some(existing => presetsAreEqual(imported, existing)) + ); + + if (hasDuplicates) { + const shouldOverride = await onOverridePrompt(); + if (shouldOverride) { + replaceAllPresets(importedPresets); + } else { + const combined = [...presets, ...importedPresets]; + replaceAllPresets(combined); + } + } else { + const combined = [...presets, ...importedPresets]; + replaceAllPresets(combined); + } + } else { + replaceAllPresets(importedPresets); + } + + await savePresetsData(); + forceUpdate(); + showToast(`Imported ${importedPresets.length} profiles successfully`, Toasts.Type.SUCCESS); + } catch (err) { + showToast("Failed to import profiles", Toasts.Type.FAILURE); + } + }; + input.click(); +} diff --git a/src/equicordplugins/profileSets/utils/profile.ts b/src/equicordplugins/profileSets/utils/profile.ts new file mode 100644 index 000000000..851750bb4 --- /dev/null +++ b/src/equicordplugins/profileSets/utils/profile.ts @@ -0,0 +1,329 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { getUserSettingLazy } from "@api/UserSettings"; +import { Logger } from "@utils/Logger"; +import { CustomStatus, ProfileEffect, ProfilePreset } from "@vencord/discord-types"; +import { findStoreLazy } from "@webpack"; +import { FluxDispatcher, UserProfileStore, UserStore } from "@webpack/common"; + +const logger = new Logger("ProfilePresets"); +const UserProfileSettingsStore = findStoreLazy("UserProfileSettingsStore"); +const CustomStatusSettings = getUserSettingLazy("status", "customStatus")!; + +function dispatch(type: string, payload: any) { + FluxDispatcher.dispatch({ type, ...payload }); +} + +export async function imageUrlToBase64(url: string): Promise { + try { + const response = await fetch(url); + const blob = await response.blob(); + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + } catch (err) { + logger.error("Failed to convert image to base64", err); + return null; + } +} + +async function processImage(imageData: any, userId: string, type: "avatar" | "banner"): Promise { + if (!imageData) return null; + + if (typeof imageData === "object" && imageData.imageUri) { + return imageData.imageUri; + } + + if (typeof imageData === "string") { + if (imageData.startsWith("data:")) return imageData; + + const isAnimated = imageData.startsWith("a_"); + const size = type === "banner" ? 1024 : 512; + const urlPath = type === "banner" ? "banners" : "avatars"; + const url = `https://cdn.discordapp.com/${urlPath}/${userId}/${imageData}.${isAnimated ? "gif" : "png"}?size=${size}`; + return await imageUrlToBase64(url); + } + + return null; +} + +export async function getCurrentProfile(guildId?: string): Promise> { + const currentUser = UserStore.getCurrentUser(); + const userProfile = UserProfileStore.getUserProfile(currentUser.id); + const userAny = currentUser as any; + + const pendingChanges = UserProfileSettingsStore.getPendingChanges(guildId); + + const customStatusSetting = CustomStatusSettings.getSetting(); + const customStatus: CustomStatus | null = customStatusSetting ? { + text: customStatusSetting.text, + emojiId: customStatusSetting.emojiId, + emojiName: customStatusSetting.emojiName, + expiresAtMs: customStatusSetting.expiresAtMs + } : null; + + const avatarDecoration = (pendingChanges.pendingAvatarDecoration !== undefined + ? pendingChanges.pendingAvatarDecoration + : userAny.avatarDecorationData) ? { + asset: (pendingChanges.pendingAvatarDecoration || userAny.avatarDecorationData).asset, + skuId: (pendingChanges.pendingAvatarDecoration || userAny.avatarDecorationData).skuId + } : null; + + let profileEffect: ProfileEffect | null = null; + const effectToUse = pendingChanges.pendingProfileEffect !== undefined + ? pendingChanges.pendingProfileEffect + : userProfile?.profileEffect; + + if (effectToUse) { + if (effectToUse.skuId && effectToUse.effects) { + profileEffect = { + skuId: effectToUse.skuId, + title: effectToUse.title, + description: effectToUse.description, + accessibilityLabel: effectToUse.accessibilityLabel, + reducedMotionSrc: effectToUse.reducedMotionSrc, + thumbnailPreviewSrc: effectToUse.thumbnailPreviewSrc, + effects: effectToUse.effects, + animationType: effectToUse.animationType, + staticFrameSrc: effectToUse.staticFrameSrc, + type: effectToUse.type || 1 + }; + } else if (effectToUse.skuId) { + const collectible = userProfile?.collectibles?.find((c: any) => c.skuId === effectToUse.skuId); + if (collectible) { + profileEffect = { + skuId: collectible.skuId, + title: collectible.title, + description: collectible.description, + accessibilityLabel: collectible.accessibilityLabel, + reducedMotionSrc: collectible.reducedMotionSrc, + thumbnailPreviewSrc: collectible.thumbnailPreviewSrc, + effects: collectible.effects, + animationType: collectible.animationType, + staticFrameSrc: collectible.staticFrameSrc, + type: collectible.type || 1 + }; + } + } + } + + const nameplateToUse = pendingChanges.pendingNameplate !== undefined + ? pendingChanges.pendingNameplate + : userAny.collectibles?.nameplate; + + const nameplate = nameplateToUse ? { + skuId: nameplateToUse.skuId, + asset: nameplateToUse.asset, + label: nameplateToUse.label, + palette: nameplateToUse.palette, + type: nameplateToUse.type || 2 + } : null; + + const displayNameStylesToUse = pendingChanges.pendingDisplayNameStyles !== undefined + ? pendingChanges.pendingDisplayNameStyles + : userAny.displayNameStyles; + + const displayNameStyles = displayNameStylesToUse ? { + font_id: displayNameStylesToUse.font_id, + effect_id: displayNameStylesToUse.effect_id, + colors: Array.isArray(displayNameStylesToUse.colors) ? displayNameStylesToUse.colors : [] + } : null; + + const avatarToUse = pendingChanges.pendingAvatar !== undefined + ? pendingChanges.pendingAvatar + : currentUser.avatar; + + const avatarDataUrl = await processImage(avatarToUse, currentUser.id, "avatar"); + + const bannerToUse = pendingChanges.pendingBanner !== undefined + ? pendingChanges.pendingBanner + : userProfile?.banner; + + const bannerDataUrl = await processImage(bannerToUse, currentUser.id, "banner"); + + return { + avatarDataUrl, + bannerDataUrl, + bio: pendingChanges.pendingBio !== undefined ? pendingChanges.pendingBio : userProfile?.bio || null, + accentColor: pendingChanges.pendingAccentColor !== undefined ? pendingChanges.pendingAccentColor : userProfile?.accentColor || null, + themeColors: pendingChanges.pendingThemeColors !== undefined + ? pendingChanges.pendingThemeColors + : userProfile?.themeColors || null, + globalName: pendingChanges.pendingGlobalName !== undefined ? pendingChanges.pendingGlobalName : currentUser.globalName || null, + pronouns: pendingChanges.pendingPronouns !== undefined ? pendingChanges.pendingPronouns : userProfile?.pronouns || null, + avatarDecoration, + profileEffect, + nameplate, + primaryGuildId: pendingChanges.pendingPrimaryGuildId !== undefined ? pendingChanges.pendingPrimaryGuildId : userAny.primaryGuild?.identityGuildId || null, + customStatus, + displayNameStyles + }; +} + +export function loadPresetAsPending(preset: ProfilePreset, guildId?: string) { + try { + if (preset.avatarDataUrl) { + dispatch("USER_SETTINGS_ACCOUNT_SET_PENDING_AVATAR", { guildId, avatar: preset.avatarDataUrl }); + } + + if (preset.bannerDataUrl) { + dispatch("USER_SETTINGS_ACCOUNT_SET_PENDING_BANNER", { guildId, banner: preset.bannerDataUrl }); + } + + if (preset.bio !== undefined) { + dispatch("USER_SETTINGS_ACCOUNT_SET_PENDING_BIO", { guildId, bio: preset.bio }); + } + + if (preset.pronouns !== undefined) { + dispatch("USER_SETTINGS_ACCOUNT_SET_PENDING_PRONOUNS", { guildId, pronouns: preset.pronouns }); + } + + if (preset.themeColors !== undefined) { + dispatch("USER_SETTINGS_ACCOUNT_SET_PENDING_THEME_COLORS", { + guildId, + themeColors: Array.isArray(preset.themeColors) ? preset.themeColors.map(Number) : null + }); + } + + if (preset.globalName !== undefined) { + dispatch("USER_SETTINGS_ACCOUNT_SET_PENDING_GLOBAL_NAME", { globalName: preset.globalName }); + } + + if (preset.avatarDecoration !== undefined) { + dispatch("USER_SETTINGS_ACCOUNT_SET_PENDING_COLLECTIBLES_ITEM", { + guildId, + item: { type: 0, value: preset.avatarDecoration } + }); + } + + if (preset.profileEffect !== undefined) { + dispatch("USER_SETTINGS_ACCOUNT_SET_PENDING_COLLECTIBLES_ITEM", { + guildId, + item: { type: 1, value: preset.profileEffect } + }); + } + + if (preset.nameplate !== undefined) { + dispatch("USER_SETTINGS_ACCOUNT_SET_PENDING_COLLECTIBLES_ITEM", { + guildId, + item: { type: 2, value: preset.nameplate } + }); + } + + if (preset.displayNameStyles) { + dispatch("USER_SETTINGS_ACCOUNT_SET_PENDING_DISPLAY_NAME_STYLES", { + guildId, + displayNameStyles: preset.displayNameStyles + }); + } + + if (preset.primaryGuildId !== undefined && !guildId) { + dispatch("USER_SETTINGS_SET_PENDING_PRIMARY_GUILD_ID", { primaryGuildId: preset.primaryGuildId }); + } + + if (preset.customStatus && !guildId) { + CustomStatusSettings.updateSetting({ + text: preset.customStatus.text || "", + expiresAtMs: preset.customStatus.expiresAtMs || "0", + emojiId: preset.customStatus.emojiId || "0", + emojiName: preset.customStatus.emojiName || "" + }); + } + } catch (err) { + logger.error("Failed to load profile", err); + throw err; + } +} + +export async function copyUserProfile(userId: string) { + const user = UserStore.getUser(userId); + const userProfile = UserProfileStore.getUserProfile(userId); + + if (!user) { + throw new Error("User not found"); + } + + if (userProfile?.banner) { + const bannerUrl = `https://cdn.discordapp.com/banners/${userId}/${userProfile.banner}.${userProfile.banner.startsWith("a_") ? "gif" : "png"}?size=1024`; + const bannerDataUrl = await imageUrlToBase64(bannerUrl); + if (bannerDataUrl) { + dispatch("USER_SETTINGS_ACCOUNT_SET_PENDING_BANNER", { guildId: undefined, banner: bannerDataUrl }); + } + } + + if (userProfile?.bio) { + dispatch("USER_SETTINGS_ACCOUNT_SET_PENDING_BIO", { guildId: undefined, bio: userProfile.bio }); + } + + if (userProfile?.pronouns) { + dispatch("USER_SETTINGS_ACCOUNT_SET_PENDING_PRONOUNS", { guildId: undefined, pronouns: userProfile.pronouns }); + } + + if (userProfile?.themeColors) { + dispatch("USER_SETTINGS_ACCOUNT_SET_PENDING_THEME_COLORS", { guildId: undefined, themeColors: userProfile.themeColors }); + } + + const userAny = user as any; + if (userAny.avatarDecorationData) { + dispatch("USER_SETTINGS_ACCOUNT_SET_PENDING_COLLECTIBLES_ITEM", { + guildId: undefined, + item: { + type: 0, + value: { + asset: userAny.avatarDecorationData.asset, + skuId: userAny.avatarDecorationData.skuId + } + } + }); + } + + if (userAny.collectibles?.nameplate) { + const np = userAny.collectibles.nameplate; + dispatch("USER_SETTINGS_ACCOUNT_SET_PENDING_COLLECTIBLES_ITEM", { + guildId: undefined, + item: { + type: 2, + value: { + skuId: np.skuId, + asset: np.asset, + label: np.label, + palette: np.palette, + type: 2 + } + } + }); + } + + if (userProfile?.profileEffect?.skuId) { + const collectible = userProfile.collectibles?.find((c: any) => c.skuId === userProfile.profileEffect.skuId); + if (collectible) { + dispatch("USER_SETTINGS_ACCOUNT_SET_PENDING_COLLECTIBLES_ITEM", { + guildId: undefined, + item: { + type: 1, + value: { + skuId: collectible.skuId, + title: collectible.title, + description: collectible.description, + accessibilityLabel: collectible.accessibilityLabel, + reducedMotionSrc: collectible.reducedMotionSrc, + thumbnailPreviewSrc: collectible.thumbnailPreviewSrc, + effects: collectible.effects, + animationType: collectible.animationType, + staticFrameSrc: collectible.staticFrameSrc, + type: collectible.type || 1 + } + } + }); + } + } + + return user.username; +} diff --git a/src/equicordplugins/profileSets/utils/storage.ts b/src/equicordplugins/profileSets/utils/storage.ts new file mode 100644 index 000000000..f13d6865e --- /dev/null +++ b/src/equicordplugins/profileSets/utils/storage.ts @@ -0,0 +1,70 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { DataStore } from "@api/index"; +import { Logger } from "@utils/Logger"; +import { ProfilePreset } from "@vencord/discord-types"; + +const logger = new Logger("ProfilePresets"); +const PRESETS_KEY = "ProfileDataset"; + +export let presets: ProfilePreset[] = []; +export let currentPresetIndex = -1; + +export async function loadPresets() { + try { + const stored = await DataStore.get(PRESETS_KEY); + if (stored && Array.isArray(stored)) { + presets = stored; + } + } catch (err) { + logger.error("Failed to load presets", err); + presets = []; + } +} + +export async function savePresetsData() { + try { + await DataStore.set(PRESETS_KEY, presets); + } catch (err) { + logger.error("Failed to save presets", err); + } +} + +export function setCurrentPresetIndex(index: number) { + currentPresetIndex = index; +} + +export function addPreset(preset: ProfilePreset) { + presets.push(preset); +} + +export function updatePreset(index: number, preset: ProfilePreset) { + if (index >= 0 && index < presets.length) { + presets[index] = preset; + } +} + +export function removePreset(index: number) { + if (index >= 0 && index < presets.length) { + presets.splice(index, 1); + if (currentPresetIndex === index) { + currentPresetIndex = -1; + } else if (currentPresetIndex > index) { + currentPresetIndex--; + } + } +} + +export function movePresetInArray(fromIndex: number, toIndex: number) { + if (fromIndex < 0 || fromIndex >= presets.length || toIndex < 0 || toIndex >= presets.length) return; + const [preset] = presets.splice(fromIndex, 1); + presets.splice(toIndex, 0, preset); +} + +export function replaceAllPresets(newPresets: ProfilePreset[]) { + presets = newPresets; +} diff --git a/src/webpack/common/utils.ts b/src/webpack/common/utils.ts index 6bfc93e8c..f6176e36b 100644 --- a/src/webpack/common/utils.ts +++ b/src/webpack/common/utils.ts @@ -83,6 +83,7 @@ const ToastType = { BOOKMARK: "bookmark", CLOCK: "clock" }; + const ToastPosition = { TOP: 0, BOTTOM: 1