Equicord/src/equicordplugins/profileSets/components/presetManager.tsx
Eazvy 189ab429bb
profileSets avatar / types fix (#794)
* profileSets avatar / types fix

- Fix avatar pending payload flow to use USER_PROFILE_SETTINGS_SET_PENDING_AVATAR with NEW_ASSET imageUri payload.

- Resolve TypeScript typing issues around IconUtils avatar/banner URL resolution without any casts.

- Remove debug logging and legacy pending dispatch paths; keep recents and profile pending behavior aligned with current Discord profile settings.

- Clean up profileSets rulebook compliance issues (nullish coalescing/defaults, typed preset field updates, no logger/console usage).

* profilesets: remove display name sanitizer subscription

* fixed checks

* fix pending profile event flow

* skip collectible dispatch when already equipped

* fixed checks

* Remove empty stop method from profile set plugin

Removed the empty stop method from the profile set plugin.

* bleh

* Update src/equicordplugins/profileSets/utils/profile.ts

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update src/equicordplugins/profileSets/utils/profile.ts

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* t

* fix fallback guard

* restore avatar size fallback

* fix fallback guard

* fix fallback guard

* fix to slider

* address gemini review issues

* fix avatarSize

* update avatarSize

* address review: destructure avatarSize

* restore direct avatarSize access

* destructure avatarSize

* Apply suggestion from @gemini-code-assist[bot]

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Apply suggestion from @gemini-code-assist[bot]

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Apply suggestion from @gemini-code-assist[bot]

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: thororen <78185467+thororen1234@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-20 16:06:46 -05:00

290 lines
12 KiB
TypeScript

/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Button } from "@components/Button";
import { Heading } from "@components/Heading";
import { classes } from "@utils/misc";
import { openModal } from "@utils/modal";
import { React, SelectedGuildStore, TextInput, useStateFromStores } from "@webpack/common";
import { cl, settings } from "../index";
import { exportPresets, ImportDecision, importPresets, savePreset } from "../utils/actions";
import { loadPresetAsPending } from "../utils/profile";
import { loadPresets, presets, PresetSection, setCurrentPresetIndex } from "../utils/storage";
import { ImportProfilesModal } from "./confirmModal";
import { PresetList } from "./presetList";
const PRESETS_PER_PAGE = 5;
type PresetManagerProps = {
section?: PresetSection;
guildId?: string;
};
export function PresetManager({ section, guildId }: PresetManagerProps) {
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<number>(-1);
const [searchMode, setSearchMode] = React.useState(false);
const lastRandomIndexRef = React.useRef<number>(-1);
const resolvedSection: PresetSection = section ?? "main";
const isServerSection = resolvedSection === "server";
const lastSelectedGuildId = useStateFromStores(
[SelectedGuildStore],
() => SelectedGuildStore.getLastSelectedGuildId() ?? SelectedGuildStore.getGuildId()
);
const resolvedGuildId = isServerSection ? (guildId ?? lastSelectedGuildId ?? undefined) : undefined;
const canUseGuild = !isServerSection || Boolean(resolvedGuildId);
React.useEffect(() => {
let isActive = true;
(async () => {
await loadPresets(resolvedSection);
if (!isActive) return;
setSelectedPreset(-1);
setCurrentPage(1);
setPageInput("1");
forceUpdate();
})();
return () => {
isActive = false;
};
}, [resolvedGuildId, resolvedSection]);
const filteredPresets = !searchMode
? presets
: presets.filter(preset => preset.name.toLowerCase().includes(presetName.toLowerCase()));
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 (!canUseGuild) return;
const trimmedName = presetName.trim();
if (!trimmedName) return;
setIsSaving(true);
await savePreset(trimmedName, resolvedSection, resolvedGuildId);
setPresetName("");
setIsSaving(false);
const newTotalPages = Math.ceil(presets.length / PRESETS_PER_PAGE);
handlePageChange(newTotalPages);
forceUpdate();
};
const applyPreset = (index: number) => {
setSelectedPreset(index);
setCurrentPresetIndex(index);
loadPresetAsPending(presets[index], resolvedGuildId, {
isGuildProfile: resolvedSection === "server"
});
forceUpdate();
};
const handleLoadPreset = (index: number) => {
if (!canUseGuild) return;
applyPreset(index);
};
const selectRandomPreset = (sectionType: PresetSection) => {
const availablePresets = presets;
if (!availablePresets.length) return null;
const randomIndex = Math.floor(Math.random() * availablePresets.length);
return { preset: availablePresets[randomIndex], index: randomIndex, section: sectionType };
};
const handleRandomPreset = () => {
if (!canUseGuild) return;
const selection = selectRandomPreset(resolvedSection);
if (!selection) return;
let nextIndex = selection.index;
if (presets.length > 1 && nextIndex === lastRandomIndexRef.current) {
let attempts = 0;
while (attempts < 5 && nextIndex === lastRandomIndexRef.current) {
nextIndex = Math.floor(Math.random() * presets.length);
attempts++;
}
}
lastRandomIndexRef.current = nextIndex;
applyPreset(nextIndex);
};
const showImportPrompt = (existingCount: number): Promise<ImportDecision> => {
return new Promise(resolve => {
openModal(props => (
<ImportProfilesModal
{...props}
title="Import Profiles"
message={`You have ${existingCount} existing profiles in this section. Do you want to override them or merge with imported profiles?`}
onOverride={() => resolve("override")}
onMerge={() => resolve("merge")}
onCancel={() => resolve("cancel")}
/>
));
});
};
const { avatarSize } = settings.store;
const hasPresets = presets.length > 0;
const shouldShowPagination = filteredPresets.length > PRESETS_PER_PAGE;
return (
<div className={classes(cl("section"), isServerSection ? cl("section-server") : "")} >
<Heading tag="h3" className={cl("heading")}>
Saved Profiles
</Heading>
<div className={cl("text")}>
<TextInput
placeholder={searchMode ? "Search profiles..." : "Profile Name"}
value={presetName}
onChange={setPresetName}
className={cl("text-input")}
/>
</div>
<div className={cl("search")}>
{!searchMode && (
<Button
size="small"
disabled={isSaving || !presetName.trim() || !canUseGuild}
onClick={handleSavePreset}
className={cl("search-button")}
>
{isSaving ? "Saving..." : "Save Profile"}
</Button>
)}
{hasPresets && (
<Button
size="small"
variant={searchMode ? "primary" : "secondary"}
onClick={() => {
setSearchMode(!searchMode);
handlePageChange(1);
}}
>
{searchMode ? "Cancel Search" : "Search"}
</Button>
)}
</div>
{hasPresets && (
<>
<PresetList
presets={currentPresets}
allPresets={presets}
avatarSize={avatarSize}
selectedPreset={selectedPreset}
onLoad={handleLoadPreset}
onUpdate={() => {
const newTotal = Math.ceil(presets.length / PRESETS_PER_PAGE);
if (newTotal === 0) {
handlePageChange(1);
} else if (currentPage > newTotal) {
handlePageChange(newTotal);
}
forceUpdate();
}}
guildId={resolvedGuildId}
section={resolvedSection}
currentPage={currentPage}
onPageChange={handlePageChange}
/>
{shouldShowPagination && (
<div className={cl("pagination")}>
<Button
size="small"
variant="secondary"
disabled={currentPage === 1}
onClick={() => handlePageChange(currentPage - 1)}
>
←
</Button>
<div className={cl("page")}>
<input
type="text"
value={pageInput}
onChange={e => {
const { value } = e.target;
setPageInput(value);
const num = parseInt(value);
if (!isNaN(num) && num >= 1 && num <= totalPages) {
setCurrentPage(num);
}
}}
className={cl("page-input")}
/>
<span className={cl("page-of")}>
/ {totalPages}
</span>
</div>
<Button
size="small"
variant="secondary"
disabled={currentPage === totalPages}
onClick={() => handlePageChange(currentPage + 1)}
>
→
</Button>
</div>
)}
<hr className={cl("block")} />
</>
)}
<div className={cl("import")}>
<Button
size="small"
variant="secondary"
onClick={handleRandomPreset}
className={cl("random")}
disabled={!presets.length || !canUseGuild}
>
<span className={cl("random-content")}>
<svg className={cl("random-icon")} viewBox="0 0 24 24" aria-hidden="true">
<path
fill="currentColor"
d="M20.5 4h-5a1 1 0 0 0 0 2h2.586l-4.293 4.293a1 1 0 0 0 1.414 1.414L19.5 7.414V10a1 1 0 1 0 2 0V5a1 1 0 0 0-1-1Zm-16 1a1 1 0 0 0 0 2h2.586l4.293 4.293a1 1 0 1 0 1.414-1.414L8.414 5H4.5ZM5 15a1 1 0 0 0-1 1v5a1 1 0 1 0 2 0v-2.586l4.293 4.293a1 1 0 0 0 1.414-1.414L7.414 17H10a1 1 0 1 0 0-2H5Zm14.5 0a1 1 0 0 0 0 2h-2.586l-4.293 4.293a1 1 0 1 0 1.414 1.414L18.586 17H21a1 1 0 1 0 0-2h-1.5Z"
/>
</svg>
Random
</span>
</Button>
<Button
size="small"
variant="secondary"
onClick={() => importPresets(forceUpdate, showImportPrompt, resolvedSection, resolvedGuildId)}
disabled={!canUseGuild}
>
Import
</Button>
<Button
size="small"
variant="secondary"
onClick={() => exportPresets(resolvedSection)}
>
Export All
</Button>
</div>
<hr className={cl("block")} />
{resolvedSection === "server" && (
<hr className={cl("block")} />
)}
</div>
);
}