fix(userpfp): fixed imports, add avatar preview and file upload (#1030)

* fix(userpfp): fixed imports, add avatar preview and file upload

* feat: add sketchmyname to authors and refactor userPFP

* Update AvatarModal.tsx

---------

Co-authored-by: thororen1234 <78185467+thororen1234@users.noreply.github.com>
This commit is contained in:
macaowski 2026-05-06 23:28:42 +02:00 • committed by GitHub
parent b22fa4ae8f
commit 39f82af5ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 178 additions and 20 deletions

View file

@ -5,32 +5,68 @@
*/
import { set } from "@api/DataStore";
import { classNameFactory } from "@api/Styles";
import { Button } from "@components/Button";
import { Flex } from "@components/Flex";
import { Heading } from "@components/Heading";
import { Margins } from "@utils/margins";
import { Margins } from "@components/margins";
import { classNameFactory } from "@utils/css";
import { ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot } from "@utils/modal";
import { TextInput, useState } from "@webpack/common";
import { IconUtils, React, TextInput, Toasts, UserStore, useState } from "@webpack/common";
import { data, KEY_DATASTORE } from ".";
const cl = classNameFactory("vc-custom-avatars-");
export function SetAvatarModal({ userId, modalProps }: { userId: string, modalProps: ModalProps; }) {
const cl = classNameFactory("vc-userpfp-");
function fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
export function SetAvatarModal({ userId, modalProps }: { userId: string; modalProps: ModalProps; }) {
const { avatars } = data;
const initialAvatarUrl = avatars[userId] || "";
const [url, setUrl] = useState(initialAvatarUrl);
const user = UserStore.getUser(userId);
const originalAvatar = IconUtils.getUserAvatarURL(user, true, 128) || "";
const [url, setUrl] = useState(avatars[userId] || "");
const [preview, setPreview] = useState<string>(avatars[userId] || "");
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = React.useRef<HTMLInputElement>(null);
function handleKey(e: KeyboardEvent) {
if (e.key === "Enter") saveUserAvatar();
}
function handleUrlChange(val: string) {
setUrl(val);
setPreview(val.trim());
}
async function handleFile(file: File) {
if (!file.type.startsWith("image/")) return;
if (file.type === "image/gif" || file.type === "image/webp") {
Toasts.show({
message: "GIFs/WebP must be added via URL. Upload your GIF/WebP to a image hosting service and paste the link.",
type: Toasts.Type.FAILURE,
id: Toasts.genId(),
});
return;
}
const dataUrl = await fileToDataUrl(file);
setUrl(dataUrl);
setPreview(dataUrl);
}
async function saveUserAvatar() {
if (!url.trim()) {
await deleteUserAvatar();
return;
}
avatars[userId] = url.trim();
await set(KEY_DATASTORE, avatars);
modalProps.onClose();
@ -48,20 +84,71 @@ export function SetAvatarModal({ userId, modalProps }: { userId: string, modalPr
<Heading tag="h3">Custom Avatar</Heading>
<ModalCloseButton onClick={modalProps.onClose} />
</ModalHeader>
<ModalContent className={cl("modal-content")} onKeyDown={handleKey}>
<section className={Margins.bottom16}>
{/* Preview */}
<div className={cl("preview-row")}>
<div className={cl("preview-box")}>
<span className={cl("preview-label")}>Original</span>
<img src={originalAvatar} className={cl("avatar")} alt="original" />
</div>
<span className={cl("arrow")}>→</span>
<div className={cl("preview-box")}>
<span className={cl("preview-label")}>Local</span>
<img
src={preview || originalAvatar}
className={`${cl("avatar")} ${preview ? cl("avatar-active") : ""}`}
alt="local"
/>
</div>
</div>
{/* URL input */}
<section className={Margins.bottom8}>
<Heading tag="h3">Enter PNG/GIF URL</Heading>
<TextInput
placeholder="https://example.com/image.png"
value={url}
onChange={setUrl}
value={url.startsWith("data:") ? "(uploaded file)" : url}
onChange={handleUrlChange}
autoFocus
/>
</section>
{/* Drag & drop */}
<div
className={`${cl("dropzone")} ${isDragging ? cl("dropzone-active") : ""}`}
onDragOver={e => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={() => setIsDragging(false)}
onDrop={e => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files?.[0];
if (file) handleFile(file);
}}
onClick={() => fileInputRef.current?.click()}
>
{isDragging ? "Drop here!" : "⬆ Drag an image or click to upload (for GIFs or WebP use a URL instead)"}
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg"
style={{ display: "none" }}
onChange={e => {
const file = e.currentTarget.files?.[0];
if (file) handleFile(file);
e.currentTarget.value = "";
}}
/>
</div>
</ModalContent>
<ModalFooter className={cl("modal-footer")}>
<Flex gap="8px">
{avatars[userId] && (<Button variant="dangerPrimary" onClick={deleteUserAvatar}>Delete</Button>)}
{avatars[userId] && (
<Button variant="dangerPrimary" onClick={deleteUserAvatar}>Delete</Button>
)}
<Button onClick={saveUserAvatar}>Save</Button>
</Flex>
</ModalFooter>

View file

@ -66,7 +66,7 @@ export default definePlugin({
name: "UserPFP",
description: "Allows you to use an animated avatar without Nitro",
tags: ["Appearance", "Customisation", "Servers"],
authors: [EquicordDevs.nexpid, Devs.thororen, EquicordDevs.soapphia],
authors: [EquicordDevs.nexpid, Devs.thororen, EquicordDevs.soapphia, EquicordDevs.sketchmyname],
settings,
data,
settingsAboutComponent: () => (
@ -130,13 +130,20 @@ export default definePlugin({
if (settings.store.preferNitro && user.avatar?.startsWith("a_")) return original(user, animated, size);
if (!data.avatars[user.id]) return original(user, animated, size);
const res = new URL(data.avatars[user.id]);
res.searchParams.set("animated", animated ? "true" : "false");
if (res && !animated) {
res.pathname = res.pathname.replaceAll(/\.gifv?/g, ".png");
}
const avatarUrl = data.avatars[user.id];
return res.toString();
if (avatarUrl.startsWith("data:")) return avatarUrl;
try {
const res = new URL(avatarUrl);
res.searchParams.set("animated", animated ? "true" : "false");
if (!animated) {
res.pathname = res.pathname.replaceAll(/\.gifv?/g, ".png");
}
return res.toString();
} catch {
return original(user, animated, size);
}
},
getAvatarServerHook: (original: any) => (config: any) => {
const { userId, avatar, size, canAnimate } = config;
@ -170,4 +177,4 @@ export default definePlugin({
.then(remote => remote?.avatars && Object.assign(data.avatars, remote.avatars))
.catch(() => null);
}
});
});

View file

@ -9,4 +9,64 @@
.vc-userpfp-settings-heart {
margin-left: 6px;
}
.vc-userpfp-preview-row {
display: flex;
align-items: center;
justify-content: center;
flex-direction: row;
gap: 24px;
margin: 12px 0;
}
.vc-userpfp-preview-box {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.vc-userpfp-preview-label {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.vc-userpfp-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
border: 3px solid var(--background-modifier-accent);
}
.vc-userpfp-avatar-active {
border: 3px solid var(--brand-experiment);
box-shadow: 0 0 12px var(--brand-experiment-30a);
}
.vc-userpfp-arrow {
font-size: 24px;
color: var(--text-muted);
margin-top: 20px;
}
.vc-userpfp-dropzone {
border: 2px dashed var(--background-modifier-accent);
border-radius: 10px;
padding: 20px;
text-align: center;
cursor: pointer;
transition: background-color 0.15s, border-color 0.15s;
user-select: none;
color: var(--text-muted);
font-size: 14px;
margin-top: 8px;
}
.vc-userpfp-dropzone-active {
border-color: var(--brand-experiment);
background: var(--brand-experiment-10a);
}

View file

@ -1317,6 +1317,10 @@ export const EquicordDevs = Object.freeze({
name: "Moowi",
id: 246128594756173824n
},
sketchmyname: {
name: "sketchmyname",
id: 1412164910443663491n
},
} satisfies Record<string, Dev>);
// iife so #__PURE__ works correctly