Merge branch 'dev'

This commit is contained in:
thororen1234 2026-02-11 20:09:34 -05:00
commit 38c7fc13fe
No known key found for this signature in database
29 changed files with 2138 additions and 420 deletions

View file

@ -5,97 +5,15 @@
*/
import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu";
import { definePluginSettings } from "@api/Settings";
import { EquicordDevs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import definePlugin from "@utils/types";
import { Menu } from "@webpack/common";
import { SettingsComponent } from "./settings";
import { settings } from "./settings";
import { serviceLabels, ServiceType } from "./types";
import { getMediaUrl } from "./utils/getMediaUrl";
import { uploadFile } from "./utils/upload";
export const settings = definePluginSettings({
serviceType: {
type: OptionType.SELECT,
description: "",
options: [
{ label: "Zipline", value: ServiceType.ZIPLINE, default: true },
{ label: "E-Z Host", value: ServiceType.EZHOST },
{ label: "Nest", value: ServiceType.NEST },
{ label: "Catbox.moe", value: ServiceType.CATBOX },
{ label: "0x0.st", value: ServiceType.ZEROX0 },
{ label: "Litterbox", value: ServiceType.LITTERBOX }
],
hidden: true
},
serviceUrl: {
type: OptionType.STRING,
description: "",
default: "",
hidden: true
},
ezHostKey: {
type: OptionType.STRING,
description: "E-Z Host API key",
default: "",
hidden: true
},
ziplineToken: {
type: OptionType.STRING,
description: "",
default: "",
hidden: true
},
nestToken: {
type: OptionType.STRING,
description: "",
default: "",
hidden: true
},
litterboxExpiry: {
type: OptionType.SELECT,
description: "",
options: [
{ label: "1 hour", value: "1h" },
{ label: "12 hours", value: "12h" },
{ label: "24 hours", value: "24h", default: true },
{ label: "72 hours", value: "72h" }
],
default: "24h",
hidden: true
},
folderId: {
type: OptionType.STRING,
description: "",
default: "",
hidden: true
},
stripQueryParams: {
type: OptionType.BOOLEAN,
description: "",
default: false,
hidden: true
},
apngToGif: {
type: OptionType.BOOLEAN,
description: "",
default: false,
hidden: true
},
autoCopy: {
type: OptionType.BOOLEAN,
description: "",
default: true,
hidden: true
},
settingsComponent: {
type: OptionType.COMPONENT,
description: "Settings",
component: SettingsComponent
}
});
const messageContextMenuPatch: NavContextMenuPatchCallback = (children, props) => {
if (!props) return;

View file

@ -82,6 +82,60 @@ export async function uploadToEzHost(
}
}
export async function uploadTo0x0(
_: IpcMainInvokeEvent,
fileBuffer: ArrayBuffer,
filename: string
): Promise<NativeUploadResult> {
try {
const formData = new FormData();
formData.append("file", new Blob([fileBuffer]), filename);
const response = await fetch("https://0x0.st", {
method: "POST",
body: formData
});
if (!response.ok) {
const errorText = await response.text();
return { success: false, error: `Upload failed: ${response.status} ${errorText}` };
}
const text = (await response.text()).trim();
if (!text) {
return { success: false, error: "No URL returned from upload" };
}
return { success: true, url: text };
} catch (e) {
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
}
}
export async function uploadToS3(
_: IpcMainInvokeEvent,
fileBuffer: ArrayBuffer,
uploadUrl: string,
headers: Record<string, string>
): Promise<NativeUploadResult> {
try {
const response = await fetch(uploadUrl, {
method: "PUT",
headers,
body: new Blob([fileBuffer])
});
if (!response.ok) {
const errorText = await response.text();
return { success: false, error: `Upload failed: ${response.status} ${errorText}` };
}
return { success: true, url: uploadUrl };
} catch (e) {
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
}
}
export async function fetchFile(
_: IpcMainInvokeEvent,

View file

@ -4,31 +4,184 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { SettingsSection } from "@components/settings/tabs/plugins/components/Common";
import { Switch } from "@components/Switch";
import { useForceUpdater } from "@utils/react";
import { OptionType } from "@utils/types";
import { Select, TextInput } from "@webpack/common";
import { settings } from "./index";
import { ServiceType } from "./types";
const serviceOptions = [
{ label: "Zipline", value: ServiceType.ZIPLINE, default: true },
{ label: "E-Z Host", value: ServiceType.EZHOST },
{ label: "Nest", value: ServiceType.NEST },
{ label: "S3-Compatible", value: ServiceType.S3 },
{ label: "Catbox.moe", value: ServiceType.CATBOX },
...(IS_DISCORD_DESKTOP ? [{ label: "0x0.st", value: ServiceType.ZEROX0 }] : []),
{ label: "Litterbox", value: ServiceType.LITTERBOX }
];
const litterboxOptions = [
{ label: "1 hour", value: "1h" },
{ label: "12 hours", value: "12h" },
{ label: "24 hours", value: "24h", default: true },
{ label: "72 hours", value: "72h" }
];
export const settings = definePluginSettings({
serviceType: {
type: OptionType.SELECT,
description: "Selected uploader service",
options: serviceOptions,
hidden: true
},
serviceUrl: {
type: OptionType.STRING,
description: "Zipline service URL",
default: "",
hidden: true
},
ziplineToken: {
type: OptionType.STRING,
description: "Zipline auth token",
default: "",
hidden: true
},
folderId: {
type: OptionType.STRING,
description: "Optional Zipline folder ID",
default: "",
hidden: true
},
ezHostKey: {
type: OptionType.STRING,
description: "E-Z Host API key",
default: "",
hidden: true
},
nestToken: {
type: OptionType.STRING,
description: "Nest API token",
default: "",
hidden: true
},
s3Endpoint: {
type: OptionType.STRING,
description: "S3-compatible endpoint URL",
default: "",
hidden: true
},
s3Bucket: {
type: OptionType.STRING,
description: "S3 bucket name",
default: "",
hidden: true
},
s3Region: {
type: OptionType.STRING,
description: "S3 region (use auto for R2)",
default: "auto",
hidden: true
},
s3AccessKeyId: {
type: OptionType.STRING,
description: "S3 access key ID",
default: "",
hidden: true
},
s3SecretAccessKey: {
type: OptionType.STRING,
description: "S3 secret access key",
default: "",
hidden: true
},
s3SessionToken: {
type: OptionType.STRING,
description: "Optional S3 session token",
default: "",
hidden: true
},
s3PublicUrl: {
type: OptionType.STRING,
description: "Optional public base URL",
default: "",
hidden: true
},
s3Prefix: {
type: OptionType.STRING,
description: "Optional S3 object key prefix",
default: "",
hidden: true
},
s3ForcePathStyle: {
type: OptionType.BOOLEAN,
description: "Use path-style S3 URLs",
default: true,
hidden: true
},
litterboxExpiry: {
type: OptionType.SELECT,
description: "Litterbox retention window",
options: litterboxOptions,
default: "24h",
hidden: true
},
stripQueryParams: {
type: OptionType.BOOLEAN,
description: "Strip query params from uploaded URLs",
default: false,
hidden: true
},
apngToGif: {
type: OptionType.BOOLEAN,
description: "Convert APNG uploads to GIF",
default: false,
hidden: true
},
autoCopy: {
type: OptionType.BOOLEAN,
description: "Auto copy upload URL",
default: true,
hidden: true
},
settingsComponent: {
type: OptionType.COMPONENT,
description: "Settings",
component: SettingsComponent
}
});
function SettingTextInput(props: {
description: string;
name: string;
onChange: (value: string) => void;
placeholder: string;
value: string;
}) {
const { description, name, onChange, placeholder, value } = props;
return (
<SettingsSection name={name} description={description}>
<TextInput
value={value}
onChange={onChange}
placeholder={placeholder}
/>
</SettingsSection>
);
}
export function SettingsComponent() {
const update = useForceUpdater();
const { store } = settings;
const isNest = store.serviceType === ServiceType.NEST;
const isEzHost = store.serviceType === ServiceType.EZHOST;
const isS3 = store.serviceType === ServiceType.S3;
const isZipline = store.serviceType === ServiceType.ZIPLINE;
const isLitterbox = store.serviceType === ServiceType.LITTERBOX;
const serviceOptions = [
{ label: "Zipline", value: ServiceType.ZIPLINE },
{ label: "E-Z Host", value: ServiceType.EZHOST },
{ label: "Nest", value: ServiceType.NEST },
{ label: "Catbox.moe", value: ServiceType.CATBOX },
{ label: "0x0.st", value: ServiceType.ZEROX0 },
{ label: "Litterbox", value: ServiceType.LITTERBOX }
];
return (
<>
<SettingsSection name="Service Type" description="The upload service to use">
@ -45,64 +198,122 @@ export function SettingsComponent() {
</SettingsSection>
{isZipline && (
<SettingsSection name="Service URL" description="The URL of your Zipline instance">
<TextInput
<>
<SettingTextInput
name="Service URL"
description="The URL of your Zipline instance"
value={store.serviceUrl}
onChange={v => store.serviceUrl = v}
placeholder="https://your-zipline-instance.com"
/>
</SettingsSection>
)}
{isZipline && (
<SettingsSection name="Zipline Token" description="Your Zipline API authorization token">
<TextInput
<SettingTextInput
name="Zipline Token"
description="Your Zipline API authorization token"
value={store.ziplineToken}
onChange={v => store.ziplineToken = v}
placeholder="Your Zipline API token"
/>
</SettingsSection>
)}
{isEzHost && (
<SettingsSection name="E-Z Host API Key" description="Your E-Z Host API key">
<TextInput
value={(store as { ezHostKey?: string }).ezHostKey || ""}
onChange={v => (store as { ezHostKey?: string }).ezHostKey = v}
placeholder="Your E-Z Host API key"
/>
</SettingsSection>
)}
{isNest && (
<SettingsSection name="Nest Token" description="Your Nest API authorization token">
<TextInput
value={store.nestToken}
onChange={v => store.nestToken = v}
placeholder="Your Nest API token"
/>
</SettingsSection>
)}
{isZipline && (
<SettingsSection name="Folder ID" description="Folder ID for uploads (leave empty for no folder)">
<TextInput
<SettingTextInput
name="Folder ID"
description="Folder ID for uploads (leave empty for no folder)"
value={store.folderId}
onChange={v => store.folderId = v}
placeholder="Leave empty for no folder"
/>
</SettingsSection>
</>
)}
{isEzHost && (
<SettingTextInput
name="E-Z Host API Key"
description="Your E-Z Host API key"
value={store.ezHostKey}
onChange={v => store.ezHostKey = v}
placeholder="Your E-Z Host API key"
/>
)}
{isNest && (
<SettingTextInput
name="Nest Token"
description="Your Nest API authorization token"
value={store.nestToken}
onChange={v => store.nestToken = v}
placeholder="Your Nest API token"
/>
)}
{isS3 && (
<>
<SettingTextInput
name="S3 Endpoint URL"
description="S3-compatible endpoint (e.g. https://<accountid>.r2.cloudflarestorage.com)"
value={store.s3Endpoint}
onChange={v => store.s3Endpoint = v}
placeholder="https://your-endpoint.example.com"
/>
<SettingTextInput
name="Bucket Name"
description="Bucket to upload into"
value={store.s3Bucket}
onChange={v => store.s3Bucket = v}
placeholder="my-bucket"
/>
<SettingTextInput
name="Region"
description="AWS region or auto for Cloudflare R2"
value={store.s3Region}
onChange={v => store.s3Region = v}
placeholder="auto"
/>
<SettingTextInput
name="Access Key ID"
description="S3-compatible access key"
value={store.s3AccessKeyId}
onChange={v => store.s3AccessKeyId = v}
placeholder="Your access key ID"
/>
<SettingTextInput
name="Secret Access Key"
description="S3-compatible secret key"
value={store.s3SecretAccessKey}
onChange={v => store.s3SecretAccessKey = v}
placeholder="Your secret access key"
/>
<SettingTextInput
name="Session Token"
description="Optional temporary credential token"
value={store.s3SessionToken}
onChange={v => store.s3SessionToken = v}
placeholder="Optional session token"
/>
<SettingTextInput
name="Public Base URL"
description="Optional public URL base to use for returned links"
value={store.s3PublicUrl}
onChange={v => store.s3PublicUrl = v}
placeholder="https://cdn.example.com"
/>
<SettingTextInput
name="Object Key Prefix"
description="Optional folder/prefix inside the bucket"
value={store.s3Prefix}
onChange={v => store.s3Prefix = v}
placeholder="uploads/discord"
/>
<SettingsSection tag="label" name="Use Path-Style Endpoint" description="Use endpoint/bucket/key format (recommended for R2)" inlineSetting>
<Switch
checked={store.s3ForcePathStyle}
onChange={v => store.s3ForcePathStyle = v}
/>
</SettingsSection>
</>
)}
{isLitterbox && (
<SettingsSection name="Litterbox Expiry" description="How long uploads are retained">
<Select
options={[
{ label: "1 hour", value: "1h" },
{ label: "12 hours", value: "12h" },
{ label: "24 hours", value: "24h" },
{ label: "72 hours", value: "72h" }
]}
options={litterboxOptions}
isSelected={v => v === store.litterboxExpiry}
select={v => {
store.litterboxExpiry = v;

View file

@ -8,6 +8,7 @@ export enum ServiceType {
ZIPLINE = "zipline",
NEST = "nest",
EZHOST = "ezhost",
S3 = "s3",
CATBOX = "catbox",
ZEROX0 = "0x0",
LITTERBOX = "litterbox"
@ -17,6 +18,7 @@ export const serviceLabels: Record<ServiceType, string> = {
[ServiceType.ZIPLINE]: "Zipline",
[ServiceType.NEST]: "Nest",
[ServiceType.EZHOST]: "E-Z Host",
[ServiceType.S3]: "S3-Compatible",
[ServiceType.CATBOX]: "Catbox",
[ServiceType.ZEROX0]: "0x0.st",
[ServiceType.LITTERBOX]: "Litterbox"

View file

@ -55,8 +55,8 @@ const extensionToMime: Record<string, string> = {
"mkv": "video/x-matroska"
};
export function getMimeFromExtension(ext: string): string {
return extensionToMime[ext.toLowerCase()] || "application/octet-stream";
export function getMimeFromExtension(ext?: string): string {
return extensionToMime[ext?.toLowerCase() ?? ""] || "application/octet-stream";
}
export async function getExtensionFromBytes(blob: Blob): Promise<string | undefined> {
@ -113,7 +113,7 @@ export function isSupported(url: string): boolean {
return ext ? supportedExtensions.includes(ext) : false;
}
export function getMediaUrl(props: { src?: string; href?: string; itemSrc?: string; itemHref?: string; target?: any }): string | null {
export function getMediaUrl(props: { src?: string; href?: string; itemSrc?: string; itemHref?: string; target?: any; }): string | null {
const url = props.src || props.href || props.itemSrc || props.itemHref;
if (url && isSupported(url)) return url;

View file

@ -0,0 +1,212 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { settings } from "@equicordplugins/fileUpload/settings";
import { PluginNative } from "@utils/types";
type S3Store = {
s3Endpoint?: string;
s3Bucket?: string;
s3Region?: string;
s3AccessKeyId?: string;
s3SecretAccessKey?: string;
s3SessionToken?: string;
s3PublicUrl?: string;
s3Prefix?: string;
s3ForcePathStyle?: boolean;
};
const textEncoder = new TextEncoder();
function toArrayBuffer(data: ArrayBuffer | Uint8Array | string): ArrayBuffer {
if (typeof data === "string") {
return textEncoder.encode(data).buffer as ArrayBuffer;
}
if (data instanceof Uint8Array) {
return new Uint8Array(data).buffer;
}
return data;
}
function toHex(buffer: ArrayBuffer): string {
return Array.from(new Uint8Array(buffer), b => b.toString(16).padStart(2, "0")).join("");
}
function encodeRfc3986(value: string): string {
return encodeURIComponent(value).replace(/[!'()*]/g, char => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
}
function buildS3Path(...parts: string[]): string {
const normalized = parts
.filter(Boolean)
.flatMap(part => part.split("/"))
.filter(Boolean)
.map(encodeRfc3986)
.join("/");
return normalized ? `/${normalized}` : "/";
}
function getTimestampParts(date: Date): { amzDate: string; dateStamp: string; } {
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
const day = String(date.getUTCDate()).padStart(2, "0");
const hour = String(date.getUTCHours()).padStart(2, "0");
const minute = String(date.getUTCMinutes()).padStart(2, "0");
const second = String(date.getUTCSeconds()).padStart(2, "0");
return {
amzDate: `${year}${month}${day}T${hour}${minute}${second}Z`,
dateStamp: `${year}${month}${day}`
};
}
async function sha256Hex(data: ArrayBuffer | Uint8Array | string): Promise<string> {
return toHex(await crypto.subtle.digest("SHA-256", toArrayBuffer(data)));
}
async function hmacSha256(key: ArrayBuffer | Uint8Array | string, value: string): Promise<ArrayBuffer> {
const cryptoKey = await crypto.subtle.importKey("raw", toArrayBuffer(key), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
return await crypto.subtle.sign("HMAC", cryptoKey, toArrayBuffer(value));
}
function makeRandomHex(length = 16): string {
const bytes = new Uint8Array(length);
crypto.getRandomValues(bytes);
return Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("");
}
export function isS3Configured(): boolean {
const { s3Endpoint, s3Bucket, s3Region, s3AccessKeyId, s3SecretAccessKey } = settings.store as S3Store;
return Boolean(s3Endpoint && s3Bucket && s3Region && s3AccessKeyId && s3SecretAccessKey);
}
export async function uploadToS3(
fileBlob: Blob,
filename: string,
native: PluginNative<typeof import("../native")> | null
): Promise<string> {
const {
s3Endpoint,
s3Bucket,
s3Region = "auto",
s3AccessKeyId,
s3SecretAccessKey,
s3SessionToken,
s3PublicUrl,
s3Prefix,
s3ForcePathStyle = true
} = settings.store as S3Store;
if (!s3Endpoint || !s3Bucket || !s3Region || !s3AccessKeyId || !s3SecretAccessKey) {
throw new Error("S3 endpoint, bucket, region, access key ID, and secret key are required");
}
const endpoint = new URL(s3Endpoint);
if (!/^https?:$/.test(endpoint.protocol)) {
throw new Error("S3 endpoint must be a valid HTTP(S) URL");
}
const extension = filename.includes(".") ? filename.slice(filename.lastIndexOf(".")) : "";
const prefixSegments = (s3Prefix || "").split("/").filter(Boolean);
const objectName = `${Date.now()}-${makeRandomHex(8)}${extension}`;
const objectKeyRaw = [...prefixSegments, objectName].join("/");
const objectKeyPath = objectKeyRaw.split("/").filter(Boolean).map(encodeRfc3986).join("/");
const host = s3ForcePathStyle ? endpoint.host : `${s3Bucket}.${endpoint.host}`;
const canonicalUri = s3ForcePathStyle
? buildS3Path(endpoint.pathname, s3Bucket, objectKeyRaw)
: buildS3Path(endpoint.pathname, objectKeyRaw);
const uploadUrl = new URL(endpoint.toString());
uploadUrl.host = host;
uploadUrl.pathname = canonicalUri;
uploadUrl.search = "";
const now = new Date();
const { amzDate, dateStamp } = getTimestampParts(now);
const payloadHash = await sha256Hex(await fileBlob.arrayBuffer());
const canonicalHeadersMap: Record<string, string> = {
"content-type": fileBlob.type || "application/octet-stream",
"host": host,
"x-amz-content-sha256": payloadHash,
"x-amz-date": amzDate
};
if (s3SessionToken) {
canonicalHeadersMap["x-amz-security-token"] = s3SessionToken;
}
const signedHeaderEntries = Object.entries(canonicalHeadersMap).sort(([a], [b]) => a.localeCompare(b));
const signedHeaders = signedHeaderEntries.map(([key]) => key).join(";");
const canonicalHeaders = signedHeaderEntries.map(([key, value]) => `${key}:${value.trim()}\n`).join("");
const canonicalRequest = [
"PUT",
canonicalUri,
"",
canonicalHeaders,
signedHeaders,
payloadHash
].join("\n");
const scope = `${dateStamp}/${s3Region}/s3/aws4_request`;
const stringToSign = [
"AWS4-HMAC-SHA256",
amzDate,
scope,
await sha256Hex(canonicalRequest)
].join("\n");
const kDate = await hmacSha256(`AWS4${s3SecretAccessKey}`, dateStamp);
const kRegion = await hmacSha256(kDate, s3Region);
const kService = await hmacSha256(kRegion, "s3");
const kSigning = await hmacSha256(kService, "aws4_request");
const signature = toHex(await hmacSha256(kSigning, stringToSign));
const requestHeaders: Record<string, string> = {
"Authorization": `AWS4-HMAC-SHA256 Credential=${s3AccessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
"Content-Type": canonicalHeadersMap["content-type"],
"x-amz-content-sha256": payloadHash,
"x-amz-date": amzDate
};
if (s3SessionToken) {
requestHeaders["x-amz-security-token"] = s3SessionToken;
}
if (native) {
const arrayBuffer = await fileBlob.arrayBuffer();
const result = await native.uploadToS3(arrayBuffer, uploadUrl.toString(), requestHeaders);
if (!result.success) {
throw new Error(result.error || "Upload failed");
}
} else {
const response = await fetch(uploadUrl.toString(), {
method: "PUT",
headers: requestHeaders,
body: fileBlob
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Upload failed: ${response.status} ${text}`);
}
}
if (s3PublicUrl) {
const base = s3PublicUrl.replace(/\/+$/, "");
return `${base}/${objectKeyPath}`;
}
const publicBase = s3ForcePathStyle
? `${endpoint.origin}${buildS3Path(endpoint.pathname, s3Bucket)}`
: `${endpoint.protocol}//${host}${buildS3Path(endpoint.pathname)}`;
return `${publicBase.replace(/\/+$/, "")}/${objectKeyPath}`;
}

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { settings } from "@equicordplugins/fileUpload/index";
import { settings } from "@equicordplugins/fileUpload/settings";
import { ServiceType, UploadResponse } from "@equicordplugins/fileUpload/types";
import { copyToClipboard } from "@utils/clipboard";
import { PluginNative } from "@utils/types";
@ -12,6 +12,7 @@ import { showToast, Toasts } from "@webpack/common";
import { convertApngToGif } from "./apngToGif";
import { getExtensionFromBytes, getExtensionFromMime, getMimeFromExtension, getUrlExtension } from "./getMediaUrl";
import { isS3Configured, uploadToS3 } from "./s3";
const Native = IS_DISCORD_DESKTOP
? VencordNative.pluginHelpers.FileUpload as PluginNative<typeof import("../native")>
@ -116,14 +117,28 @@ async function uploadToNest(fileBlob: Blob, filename: string): Promise<string> {
}
export function isConfigured(): boolean {
const { serviceType, serviceUrl, ziplineToken, nestToken } = settings.store;
const {
serviceType,
serviceUrl,
ziplineToken,
nestToken
} = settings.store as {
serviceType: ServiceType;
serviceUrl?: string;
ziplineToken?: string;
nestToken?: string;
};
switch (serviceType) {
case ServiceType.NEST:
return Boolean(nestToken);
case ServiceType.EZHOST:
return Boolean((settings.store as { ezHostKey?: string }).ezHostKey);
case ServiceType.S3:
return isS3Configured();
case ServiceType.CATBOX:
return true;
case ServiceType.ZEROX0:
return Boolean(Native);
case ServiceType.LITTERBOX:
return true;
case ServiceType.ZIPLINE:
@ -182,8 +197,11 @@ async function uploadToCatbox(fileBlob: Blob, filename: string): Promise<string>
formData.append("reqtype", "fileupload");
formData.append("fileToUpload", fileBlob, filename);
const proxiedUrl = `${CORS_PROXY}?url=${encodeURIComponent("https://catbox.moe/user/api.php")}`;
const response = await fetch(proxiedUrl, {
const uploadUrl = Native
? "https://catbox.moe/user/api.php"
: `${CORS_PROXY}?url=${encodeURIComponent("https://catbox.moe/user/api.php")}`;
const response = await fetch(uploadUrl, {
method: "POST",
body: formData
});
@ -202,26 +220,22 @@ async function uploadToCatbox(fileBlob: Blob, filename: string): Promise<string>
}
async function uploadTo0x0(fileBlob: Blob, filename: string): Promise<string> {
const formData = new FormData();
formData.append("file", fileBlob, filename);
const proxiedUrl = `${CORS_PROXY}?url=${encodeURIComponent("https://0x0.st")}`;
const response = await fetch(proxiedUrl, {
method: "POST",
body: formData
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Upload failed: ${response.status} ${text}`);
if (!Native) {
throw new Error("0x0.st uploads are only supported on the desktop client");
}
const text = (await response.text()).trim();
if (!text) {
const arrayBuffer = await fileBlob.arrayBuffer();
const result = await Native.uploadTo0x0(arrayBuffer, filename);
if (!result.success) {
throw new Error(result.error || "Upload failed");
}
if (!result.url) {
throw new Error("No URL returned from upload");
}
return text;
return result.url;
}
async function uploadToLitterbox(fileBlob: Blob, filename: string): Promise<string> {
@ -230,8 +244,11 @@ async function uploadToLitterbox(fileBlob: Blob, filename: string): Promise<stri
formData.append("time", settings.store.litterboxExpiry || "24h");
formData.append("fileToUpload", fileBlob, filename);
const proxiedUrl = `${CORS_PROXY}?url=${encodeURIComponent("https://litterbox.catbox.moe/resources/internals/api.php")}`;
const response = await fetch(proxiedUrl, {
const uploadUrl = Native
? "https://litterbox.catbox.moe/resources/internals/api.php"
: `${CORS_PROXY}?url=${encodeURIComponent("https://litterbox.catbox.moe/resources/internals/api.php")}`;
const response = await fetch(uploadUrl, {
method: "POST",
body: formData
});
@ -324,6 +341,9 @@ export async function uploadFile(url: string): Promise<void> {
case ServiceType.EZHOST:
uploadedUrl = await uploadToEzHost(typedBlob, filename);
break;
case ServiceType.S3:
uploadedUrl = await uploadToS3(typedBlob, filename, Native);
break;
case ServiceType.CATBOX:
uploadedUrl = await uploadToCatbox(typedBlob, filename);
break;

View file

@ -18,16 +18,13 @@
import { Button } from "@components/Button";
import { Heading } from "@components/Heading";
import { classNameFactory } from "@utils/css";
import { cl, Native, settings } from "@equicordplugins/messageLoggerEnhanced/index";
import { DEFAULT_IMAGE_CACHE_DIR } from "@equicordplugins/messageLoggerEnhanced/utils/constants";
import { copyWithToast } from "@utils/discord";
import { classes } from "@utils/misc";
import { findCssClassesLazy } from "@webpack";
import { Toasts } from "@webpack/common";
import { Native, settings } from "..";
import { DEFAULT_IMAGE_CACHE_DIR } from "../utils/constants";
const cl = classNameFactory("folder-upload");
const inputClasses = findCssClassesLazy("input", "inputWrapper", "editable") as Record<string, string>;
function createDirSelector(settingKey: "logsDir" | "imageCacheDir", successMessage: string) {
@ -83,18 +80,18 @@ export function SelectFolderInput({ settingsKey, successMessage }: Props) {
}
return (
<div className={classes(cl("-container"), inputClasses.input)}>
<div onClick={() => copyWithToast(path)} className={cl("-input")}>
<div className={classes(cl("folder-upload-container"), inputClasses.input)}>
<div onClick={() => copyWithToast(path)} className={cl("folder-upload-input")}>
{path == null || path === DEFAULT_IMAGE_CACHE_DIR ? "Choose Folder" : getDirName(path)}
</div>
<Button
className={cl("-button")}
className={cl("folder-upload-button")}
size="small"
onClick={onFolderSelect}
>
Browse
</Button>
</div>
</div >
);
}

View file

@ -17,6 +17,7 @@
*/
import { HeaderBarButton } from "@api/HeaderBar";
import { cl } from "@equicordplugins/messageLoggerEnhanced/index";
import { findComponentByCodeLazy } from "@webpack";
import { openLogModal } from "./LogsModal";
@ -26,7 +27,7 @@ const Icon = findComponentByCodeLazy("0-1.27-.97l-2.5.7a3");
export function OpenLogsButton() {
return (
<HeaderBarButton
className="vc-log-toolbox-btn"
className={cl("toolbox-btn")}
onClick={() => openLogModal()}
tooltip={"Open Logs"}
icon={Icon}

View file

@ -9,7 +9,6 @@ import { Button } from "@components/Button";
import { Flex } from "@components/Flex";
import { InfoIcon } from "@components/Icons";
import { Link } from "@components/Link";
import { classNameFactory } from "@utils/css";
import { copyWithToast, openUserProfile } from "@utils/discord";
import { closeAllModals, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { LazyComponent } from "@utils/react";
@ -18,7 +17,7 @@ import { find, findByCode, findByCodeLazy } from "@webpack";
import { Alerts, ChannelStore, ContextMenuApi, FluxDispatcher, GuildStore, Menu, NavigationRouter, React, TabBar, TextInput, Tooltip, useMemo, useRef, useState } from "@webpack/common";
import { clearMessagesIDB, DBMessageRecord, deleteMessageIDB, deleteMessagesBulkIDB } from "../db";
import { settings } from "../index";
import { cl, settings } from "../index";
import { LoggedMessage, LoggedMessageJSON } from "../types";
import { messageJsonToMessageClass } from "../utils";
import { importLogs } from "../utils/settingsUtils";
@ -56,8 +55,6 @@ const PrivateChannelRecord = findByCodeLazy(".is_message_request_timestamp,");
const MessagePreview = LazyComponent<MessagePreviewProps>(() => find(m => m?.type?.toString().includes("previewLinkTarget:") && !m?.type?.toString().includes("HAS_THREAD")));
const ChildrenAccessories = LazyComponent<ChildrenAccProops>(() => findByCode("channelMessageProps:{message:"));
const cl = classNameFactory("msg-logger-modal-");
export enum LogTabs {
DELETED = "Deleted",
EDITED = "Edited",
@ -79,13 +76,13 @@ export function LogsModal({ modalProps, initalQuery }: Props) {
const { messages, total, statusTotal, pending, reset } = useMessages(queryEh, currentTab, sortNewest, numDisplayedMessages);
return (
<ModalRoot className={cl("root")} {...modalProps} size={ModalSize.LARGE}>
<ModalHeader className={cl("header")}>
<ModalRoot className={cl("modal-root")} {...modalProps} size={ModalSize.LARGE}>
<ModalHeader className={cl("modal-header")}>
<TextInput value={queryEh} onChange={e => setQuery(e)} style={{ width: "100%" }} placeholder="Filter Messages" />
<TabBar
type="top"
look="brand"
className={cl("tab-bar")}
className={cl("modal-tab-bar")}
selectedItem={currentTab}
onItemSelect={e => {
setCurrentTab(e);
@ -94,30 +91,30 @@ export function LogsModal({ modalProps, initalQuery }: Props) {
}}
>
<TabBar.Item
className={cl("tab-bar-item")}
className={cl("modal-tab-bar-item")}
id={LogTabs.DELETED}
>
Deleted
</TabBar.Item>
<TabBar.Item
className={cl("tab-bar-item")}
className={cl("modal-tab-bar-item")}
id={LogTabs.EDITED}
>
Edited
</TabBar.Item>
<TabBar.Item
className={cl("tab-bar-item")}
className={cl("modal-tab-bar-item")}
id={LogTabs.GHOST_PING}
>
Ghost Pinged
</TabBar.Item>
</TabBar>
</ModalHeader>
<div style={{ opacity: modalProps.transitionState === 1 ? "1" : "0" }} className={cl("content-container")} ref={contentRef}>
<div style={{ opacity: modalProps.transitionState === 1 ? "1" : "0" }} className={cl("modal-content-container")} ref={contentRef}>
{
modalProps.transitionState === 1 &&
<ModalContent
className={cl("content")}
className={cl("modal-content")}
>
{messages != null && total === 0 && (
<EmptyLogs
@ -209,7 +206,7 @@ function LogsContent({ visibleMessages, canLoadMore, sortNewest, tab, reset, han
return <NoResults tab={tab} />;
return (
<div className={cl("content-inner")}>
<div className={cl("modal-content-inner")}>
{visibleMessages
.map(({ message }, i) => (
<LMessage
@ -251,7 +248,7 @@ function NoResults({ tab }: { tab: LogTabs; }) {
const { nextTab, lastTab } = generateSuggestedTabs(tab);
return (
<div className={cl("empty-logs", "content-inner")} style={{ textAlign: "center" }}>
<div className={cl("modal-empty-logs", "modal-content-inner")} style={{ textAlign: "center" }}>
<BaseText size="lg">
No results in <b>{tab}</b>.
</BaseText>
@ -264,7 +261,7 @@ function NoResults({ tab }: { tab: LogTabs; }) {
function EmptyLogs({ hasQuery, reset: forceUpdate }: { hasQuery: boolean; reset: () => void; }) {
return (
<div className={cl("empty-logs", "content-inner")} style={{ textAlign: "center" }}>
<div className={cl("modal-empty-logs", "modal-content-inner")} style={{ textAlign: "center" }}>
<Flex flexDirection="column" style={{ position: "relative" }}>
<BaseText size="lg">
@ -276,7 +273,7 @@ function EmptyLogs({ hasQuery, reset: forceUpdate }: { hasQuery: boolean; reset:
<Tooltip text="ML Enhanced now stores logs in indexeddb. You need to import your old logs from the logs directory. Importing wont overwrite existing logs">
{({ onMouseEnter, onMouseLeave }) => (
<div
className={cl("info-icon")}
className={cl("modal-info-icon")}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
@ -392,7 +389,7 @@ function LMessage({ log, isGroupStart, reset, }: LMessageProps) {
);
}}>
<MessagePreview
className={`${cl("msg-preview")} ${message.deleted ? "messagelogger-deleted" : ""}`}
className={`${cl("modal-msg-preview")} ${message.deleted ? "messagelogger-deleted" : ""}`}
author={message.author}
message={message}
compact={false}
@ -419,13 +416,13 @@ function LMessage({ log, isGroupStart, reset, }: LMessageProps) {
}
/>
{settings.store.ShowWhereMessageIsFrom && channel?.isDM() && message?.author && (
<span className={`${cl("from")} ${message.deleted ? cl("from-deleted") : cl("from-edited")}`}>From {message.author.username}'s DMs</span>
<span className={`${cl("modal-from")} ${message.deleted ? cl("modal-from-deleted") : cl("modal-from-edited")}`}>From {message.author.username}'s DMs</span>
)}
{settings.store.ShowWhereMessageIsFrom && channel?.isGroupDM() && channel?.name && (
<span className={`${cl("from")} ${message.deleted ? cl("from-deleted") : cl("from-edited")}`}>From {channel.name} Group DM</span>
<span className={`${cl("modal-from")} ${message.deleted ? cl("modal-from-deleted") : cl("modal-from-edited")}`}>From {channel.name} Group DM</span>
)}
{settings.store.ShowWhereMessageIsFrom && !channel?.isDM() && !channel?.isGroupDM() && channel?.name && guild?.name && (
<span className={`${cl("from")} ${message.deleted ? cl("from-deleted") : cl("from-edited")}`}>From {channel.name} in {guild.name}</span>
<span className={`${cl("modal-from")} ${message.deleted ? cl("modal-from-deleted") : cl("modal-from-edited")}`}>From {channel.name} in {guild.name}</span>
)}
</div>
);

View file

@ -9,6 +9,7 @@ export const Native = getNative();
import "./styles.css";
import { Devs, EquicordDevs } from "@utils/constants";
import { classNameFactory } from "@utils/css";
import { Logger } from "@utils/Logger";
import definePlugin from "@utils/types";
import { findByPropsLazy } from "@webpack";
@ -33,6 +34,7 @@ export { settings };
export const Flogger = new Logger("MessageLoggerEnhanced", "#f26c6c");
export const cacheSentMessages = new LimitedMap<string, LoggedMessageJSON>();
export const cl = classNameFactory("vc-msg-logger-enhanced-");
const cacheThing = findByPropsLazy("commit", "getOrCreate");

View file

@ -1,17 +1,17 @@
.msg-logger-modal-root {
.vc-msg-logger-enhanced-modal-root {
max-height: 80vh;
min-height: 80vh;
min-width: 85vw;
max-width: 85vw;
}
.msg-logger-modal-empty-logs {
.vc-msg-logger-enhanced-modal-empty-logs {
display: grid;
place-content: center;
height: 100%;
}
.msg-logger-modal-info-icon {
.vc-msg-logger-enhanced-modal-info-icon {
position: absolute;
top: -24px;
right: -24px;
@ -19,63 +19,67 @@
cursor: pointer;
}
.msg-logger-modal-header {
.vc-msg-logger-enhanced-modal-header {
flex-direction: column;
/* width: 100%; */
}
.msg-logger-modal-footer {
align-items: center;
}
.msg-logger-modal-content-container {
.vc-msg-logger-enhanced-modal-content-container {
/* max-height: 80vh; */
overflow: hidden;
height: 100%;
transition: opacity 100ms ease-in;
}
.msg-logger-modal-content {
.vc-msg-logger-enhanced-modal-content {
padding-bottom: 20px;
height: 100%;
}
.msg-logger-modal-content-inner {
.vc-msg-logger-enhanced-modal-content-inner {
padding-top: 1rem;
padding-bottom: 1rem;
height: 100%;
}
.msg-logger-modal-header > div:has(input) {
.vc-msg-logger-enhanced-modal-header > div:has(input) {
width: 100%;
}
.msg-logger-modal-tab-bar-item {
.vc-msg-logger-enhanced-modal-tab-bar-item {
padding-bottom: 16px;
margin-bottom: -2px;
}
.msg-logger-modal-tab-bar {
.vc-msg-logger-enhanced-modal-tab-bar {
margin-top: 20px;
width: 100%;
justify-content: space-around;
}
.msg-logger-modal-danger-btn {
color: var(--white-500);
background-color: var(--button-danger-background);
.vc-msg-logger-enhanced-modal-from {
font-size: 14px;
margin-left: 12px;
}
.vc-log-toolbox-btn svg {
.msg-logger-modal-from-deleted {
color: var(--status-danger);
}
.vc-msg-logger-enhanced-modal-from-edited {
color: var(--white-600);
}
.vc-msg-logger-enhanced-toolbox-btn svg {
color: var(--interactive-icon-default);
}
:is(.vc-log-toolbox-btn:hover, .vc-log-toolbox-btn[class*="selected"]) svg {
:is(.vc-msg-logger-enhanced-toolbox-btn:hover, .vc-msg-logger-enhanced-toolbox-btn[class*="selected"]) svg {
color: var(--interactive-icon-active);
}
.folder-upload-container {
.vc-msg-logger-enhanced-folder-upload-container {
display: flex;
justify-content: space-between;
align-items: center;
@ -83,13 +87,13 @@
background-color: var(--background-secondary-alt);
}
.folder-upload-input {
.vc-msg-logger-enhanced-folder-upload-input {
cursor: pointer;
padding: 10px 0 10px 10px;
color: var(--text-default);
}
.folder-upload-button {
.vc-msg-logger-enhanced-folder-upload-button {
margin: 6px;
padding: 4px 8px;
}

View file

@ -17,7 +17,7 @@ import definePlugin from "@utils/types";
import { Activity, ApplicationStream, Channel, Message, OnlineStatus, User } from "@vencord/discord-types";
import { MessageFlags } from "@vencord/discord-types/enums";
import { findByCodeLazy, findByPropsLazy, findComponentByCodeLazy, findCssClassesLazy, findExportedComponentLazy, findLazy } from "@webpack";
import { ChannelStore, MessageStore, RelationshipStore, SnowflakeUtils, UserStore, useStateFromStores } from "@webpack/common";
import { ChannelStore, MessageStore, Parser, RelationshipStore, SnowflakeUtils, UserStore, useStateFromStores } from "@webpack/common";
const cl = classNameFactory("vc-message-peek-");
@ -70,7 +70,7 @@ interface PrivateChannelProps extends ActivityCheckProps {
}
interface MessageContent {
text: string;
text: React.ReactNode;
icon?: IconType;
}
@ -115,7 +115,7 @@ function getMessageContent(message: Message): MessageContent | null {
if (/https?:\/\/(\S+\.gif|tenor\.com|giphy\.com|klipy\.com)/i.test(message.content)) {
return { text: "sent a GIF", icon: "gif" };
}
return { text: message.content };
return { text: Parser.parseInlineReply(message.content) };
}
if (message.flags & MessageFlags.IS_VOICE_MESSAGE) {
@ -151,14 +151,13 @@ function MessagePreviewContent({ channel, user }: { channel: Channel; user: User
return <>Official Discord Message</>;
}
if (channel.isMultiUserDM()) {
return <>{channel.recipients.length + 1} Members</>;
const smynName = isPluginEnabled(showMeYourName.name) ? showMeYourName.getMemberListProfilesReactionsVoiceNameText({ user: user ?? lastMessage?.author, type: "membersList" }) : null;
if (!lastMessage) {
if (channel.isMultiUserDM()) return <>{channel.recipients.length + 1} Members</>;
return null;
}
const smynName = (isPluginEnabled(showMeYourName.name) && showMeYourName.getMemberListProfilesReactionsVoiceNameText({ user: user ?? lastMessage?.author, type: "membersList" }));
if (!lastMessage) return null;
const content = getMessageContent(lastMessage);
if (!content) return null;

View file

@ -44,10 +44,10 @@
gap: 4px;
}
.vc-message-peek-activity-row > [class*="textXs"] {
.vc-message-peek-activity-row>[class*="textXs"] {
min-width: 0;
}
.vc-message-peek-activity-row > [class*="vc-bactivities-row"] {
.vc-message-peek-activity-row>[class*="vc-bactivities-row"] {
flex-shrink: 0;
}
}

View file

@ -18,7 +18,12 @@ import { getStickerPack, getStickerPackMetas } from "./stickers";
import { StickerPack, StickerPackMeta } from "./types";
import { cl, FFmpegStateContext, loadFFmpeg } from "./utils";
const settings = definePluginSettings({
export const settings = definePluginSettings({
promptToUpload: {
type: OptionType.BOOLEAN,
description: "Inserts the sticker into your chatbar instead of sending immediately",
default: false
},
packs: {
type: OptionType.COMPONENT,
description: "Packs",
@ -72,13 +77,6 @@ export default definePlugin({
}
]
},
{
find: '==="remove_text"',
replacement: {
match: /,\i\.insertText=\i=>{[\w ;]*?1===\i\.length&&.+?==="remove_text"/,
replace: ",$self.textEditor=arguments[0]$&"
}
}
],
stickerButton({
innerClassName,

View file

@ -4,17 +4,23 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { getMimeFromExtension } from "@equicordplugins/fileUpload/utils/getMediaUrl";
import { FFmpeg } from "@ffmpeg/ffmpeg";
import { fetchFile } from "@ffmpeg/util";
import { findByPropsLazy } from "@webpack";
import { ChannelStore, UploadHandler } from "@webpack/common";
import { insertTextIntoChatInputBox, MessageOptions } from "@utils/discord";
import { CloudUploadPlatform } from "@vencord/discord-types/enums";
import { ChannelStore, CloudUploader, Constants, DraftStore, FluxDispatcher, MessageActions, PendingReplyStore, RestAPI, showToast, SnowflakeUtils, Toasts, UploadHandler } from "@webpack/common";
import moreStickers from ".";
import { settings } from ".";
import { FFmpegState, Sticker } from "./types";
const PendingReplyStore = findByPropsLazy("getPendingReply");
const MessageUtils = findByPropsLazy("sendMessage");
const DraftStore = findByPropsLazy("getDraft", "getState");
type SendStickerOptions = {
channelId: string;
sticker: Sticker;
ctrlKey: boolean;
shiftKey: boolean;
ffmpegState?: FFmpegState;
};
export const ffmpeg = new FFmpeg();
@ -97,88 +103,64 @@ async function toGIF(url: string, ffmpeg: FFmpeg): Promise<File> {
});
}
export async function sendSticker({
channelId,
sticker,
sendAsLink,
ctrlKey,
shiftKey,
ffmpegState
}: { channelId: string; sticker: Sticker; sendAsLink?: boolean; ctrlKey: boolean; shiftKey: boolean; ffmpegState?: FFmpegState; }) {
export async function sendSticker({ channelId, sticker, ctrlKey, shiftKey, ffmpegState }: SendStickerOptions) {
const reply = PendingReplyStore.getPendingReply(channelId);
let content = DraftStore.getDraft(channelId, 0);
let options: Partial<MessageOptions> = {};
let file: File;
let messageContent = "";
const { textEditor } = moreStickers;
if (DraftStore) {
messageContent = DraftStore.getDraft(channelId, 0);
if (reply) {
FluxDispatcher.dispatch({ type: "DELETE_PENDING_REPLY", channelId });
options = MessageActions.getSendMessageOptionsForReply(reply);
}
let messageOptions = {};
if (PendingReplyStore) {
const pendingReply = PendingReplyStore.getPendingReply(channelId);
if (pendingReply) {
messageOptions = MessageUtils.getSendMessageOptionsForReply(pendingReply);
}
if (shiftKey) {
if (!content.endsWith(" ") && !content.endsWith("\n")) content = " ";
content += sticker.image;
return ctrlKey
? insertTextIntoChatInputBox(content)
: MessageActions._sendMessage(channelId, { content: sticker.image }, options);
}
if ((ctrlKey || !sendAsLink) && !shiftKey) {
let file: File | null = null;
if (sticker?.isAnimated) {
if (!ffmpegState) {
throw new Error("FFmpeg state is not provided");
}
if (!ffmpegState?.ffmpeg) {
throw new Error("FFmpeg is not provided");
}
if (!ffmpegState?.isLoaded) {
throw new Error("FFmpeg is not loaded");
}
file = await toGIF(sticker.image, ffmpegState.ffmpeg);
}
else {
const url = new URL(sticker.image);
url.searchParams.set("t", Date.now().toString()); // To prevent caching, in order to avoid CORS bug in Chrome
const response = await fetch(sticker.image);
const orgImageUrl = URL.createObjectURL(await response.blob());
const processedImage = await resizeImage(orgImageUrl);
const filename = sticker.filename ?? (new URL(sticker.image)).pathname.split("/").pop();
let mimeType = "image/png";
switch (filename?.split(".").pop()?.toLowerCase()) {
case "jpg":
case "jpeg":
mimeType = "image/jpeg";
break;
case "gif":
mimeType = "image/gif";
break;
case "webp":
mimeType = "image/webp";
break;
case "svg":
mimeType = "image/svg+xml";
break;
}
file = new File([processedImage], filename!, { type: mimeType });
}
UploadHandler.promptToUpload([file], ChannelStore.getChannel(channelId), 0);
return;
} else if (shiftKey) {
if (!messageContent.endsWith(" ") || !messageContent.endsWith("\n")) messageContent += " ";
messageContent += sticker.image;
if (ctrlKey && textEditor && textEditor.insertText && typeof textEditor.insertText === "function") {
textEditor.insertText(messageContent);
} else {
MessageUtils._sendMessage(channelId, {
content: sticker.image
}, messageOptions || {});
}
if (sticker?.isAnimated) {
if (!ffmpegState?.ffmpeg || !ffmpegState.isLoaded) throw new Error("FFmpeg not ready");
file = await toGIF(sticker.image, ffmpegState.ffmpeg);
} else {
MessageUtils._sendMessage(channelId, {
content: `${messageContent} ${sticker.image}`.trim()
}, messageOptions || {});
const res = await fetch(sticker.image);
const blobUrl = URL.createObjectURL(await res.blob());
const processed = await resizeImage(blobUrl);
const filename = sticker.filename ?? new URL(sticker.image).pathname.split("/").pop()!;
const mimeType = getMimeFromExtension(filename.split(".").pop());
file = new File([processed], filename, { type: mimeType });
}
if (settings.store.promptToUpload || content) return UploadHandler.promptToUpload([file], ChannelStore.getChannel(channelId), 0);
const upload = new CloudUploader({ file, platform: CloudUploadPlatform.WEB }, channelId);
upload.on("complete", () => {
RestAPI.post({
url: Constants.Endpoints.MESSAGES(channelId),
body: {
flags: 0,
channel_id: channelId,
content: "",
nonce: SnowflakeUtils.fromTimestamp(Date.now()),
sticker_ids: [],
type: 0,
attachments: [{
id: "0",
filename: upload.filename,
uploaded_filename: upload.uploadedFilename,
}],
message_reference: reply ? options?.messageReference : null,
}
});
});
upload.on("error", () => showToast("Failed to upload sticker", Toasts.Type.FAILURE));
upload.upload();
}

View file

@ -6,18 +6,14 @@
import * as DataStore from "@api/DataStore";
import { Logger } from "@utils/Logger";
import { CloudUpload, Message } from "@vencord/discord-types";
import { Message } from "@vencord/discord-types";
import { CloudUploadPlatform } from "@vencord/discord-types/enums";
import { findLazy } from "@webpack";
import { ChannelStore, Constants, FluxDispatcher, GuildStore, IconUtils, MessageActions, MessageStore, RestAPI, showToast, SnowflakeUtils, Toasts, UserStore } from "@webpack/common";
import { ChannelStore, CloudUploader, Constants, FluxDispatcher, GuildStore, IconUtils, MessageActions, MessageStore, RestAPI, showToast, SnowflakeUtils, Toasts, UserStore } from "@webpack/common";
import { settings } from ".";
import { PhantomMessageData, ScheduledAttachment, ScheduledMessage, ScheduledReaction } from "./types";
const logger = new Logger("ScheduledMessages");
const CloudUploader = findLazy(m => m.prototype?.trackUploadFinished) as typeof CloudUpload;
const STORAGE_KEY = "ScheduledMessages_queue";
let scheduledMessages: ScheduledMessage[] = [];
@ -297,7 +293,7 @@ async function uploadAttachment(channelId: string, att: ScheduledAttachment): Pr
const file = new File([bytes], att.filename, { type: att.type });
const upload = new CloudUploader({ file, platform: CloudUploadPlatform.WEB }, channelId);
upload.on("complete", () => resolve({ id: "", filename: upload.filename, uploaded_filename: upload.uploadedFilename }));
upload.on("complete", () => resolve({ id: "0", filename: upload.filename, uploaded_filename: upload.uploadedFilename }));
upload.on("error", () => resolve(null));
upload.upload();
});

View file

@ -0,0 +1,38 @@
/*
* 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 { Paragraph } from "@components/Paragraph";
import { ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalRoot, ModalSize } from "@utils/modal";
import { ClearAliasesConfirmModalProps } from "./types";
export function ClearAliasesConfirmModal({ modalProps, onConfirm }: ClearAliasesConfirmModalProps) {
return (
<ModalRoot {...modalProps} size={ModalSize.SMALL}>
<ModalHeader>
<Heading style={{ flexGrow: 1 }}>Delete all aliases</Heading>
<ModalCloseButton onClick={modalProps.onClose} />
</ModalHeader>
<ModalContent>
<Paragraph>This will remove every emoji alias you saved.</Paragraph>
</ModalContent>
<ModalFooter>
<Button
variant="dangerPrimary"
style={{ marginLeft: "auto" }}
onClick={async () => {
await onConfirm();
modalProps.onClose();
}}
>
Delete all aliases
</Button>
</ModalFooter>
</ModalRoot>
);
}

View file

@ -0,0 +1,74 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { BaseText } from "@components/BaseText";
import { Button } from "@components/Button";
import { Heading } from "@components/Heading";
import { Paragraph } from "@components/Paragraph";
import { ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalRoot, ModalSize } from "@utils/modal";
import { TextInput, useState } from "@webpack/common";
import { SetAliasModalProps } from "./types";
export function SetAliasModal({
modalProps,
emojiDisplayName,
initialAlias,
getValidationError,
isDuplicateAlias,
onSave
}: SetAliasModalProps) {
const [input, setInput] = useState(initialAlias);
const [error, setError] = useState<string | null>(null);
const validationError = getValidationError(input);
const duplicateAlias = isDuplicateAlias(input);
const finalError = duplicateAlias ? "Duplicate alias" : error ?? validationError;
const canSave = !validationError && !duplicateAlias;
return (
<ModalRoot {...modalProps} size={ModalSize.SMALL}>
<ModalHeader>
<Heading style={{ flexGrow: 1 }}>Set alias</Heading>
<ModalCloseButton onClick={modalProps.onClose} />
</ModalHeader>
<ModalContent style={{ overflowY: "hidden" }}>
<Paragraph style={{ margin: 0, marginBottom: 8 }}>Set an alias for {emojiDisplayName}</Paragraph>
<TextInput
value={input}
onChange={value => {
setInput(value);
setError(null);
}}
placeholder='Alias, e.g. "sob"'
/>
{finalError && (
<BaseText style={{ color: "var(--text-feedback-critical)", marginTop: 8 }}>
{finalError}
</BaseText>
)}
</ModalContent>
<ModalFooter>
<Button
variant="primary"
disabled={!canSave}
onClick={async () => {
const result = await onSave(input);
if (!result.ok) {
setError(result.error);
return;
}
modalProps.onClose();
}}
>
Save
</Button>
</ModalFooter>
</ModalRoot>
);
}

View file

@ -0,0 +1,23 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { ModalProps } from "@utils/modal";
export type SetAliasSaveResult = { ok: true; } | { ok: false; error: string; };
export interface SetAliasModalProps {
modalProps: ModalProps;
emojiDisplayName: string;
initialAlias: string;
getValidationError: (input: string) => string | null;
isDuplicateAlias: (input: string) => boolean;
onSave: (input: string) => Promise<SetAliasSaveResult>;
}
export interface ClearAliasesConfirmModalProps {
modalProps: ModalProps;
onConfirm: () => Promise<void>;
}

View file

@ -1,88 +0,0 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Devs } from "@utils/constants";
import definePlugin from "@utils/types";
import { Emoji } from "@vencord/discord-types";
import { EmojiStore } from "@webpack/common";
interface EmojiAutocompleteState {
query?: {
type: string;
typeInfo: {
sentinel: string;
};
results: {
emojis: Emoji[] & { sliceTo?: number; };
};
};
}
export default definePlugin({
name: "FavoriteEmojiFirst",
authors: [Devs.Aria, Devs.Ven],
description: "Puts your favorite emoji first in the emoji autocomplete.",
patches: [
{
find: "renderResults({results:",
replacement: [
{
// https://regex101.com/r/N7kpLM/1
match: /let \i=.{1,100}renderResults\({results:(\i)\.query\.results,/,
replace: "$self.sortEmojis($1);$&"
},
],
},
{
find: "numEmojiResults:",
replacement: [
// set maxCount to Infinity so our sortEmojis callback gets the entire list, not just the first 10
// and remove Discord's emojiResult slice, storing the endIndex on the array for us to use later
{
// https://regex101.com/r/x2mobQ/1
// searchEmojis(...,maxCount: stuff) ... endEmojis = emojis.slice(0, maxCount - gifResults.length)
match: /,maxCount:(\i)(.{1,500}\i)=(\i)\.slice\(0,(Math\.max\(\i,\i(?:-\i\.length){2}\))\)/,
// ,maxCount:Infinity ... endEmojis = (emojis.sliceTo = n, emojis)
replace: ",maxCount:Infinity$2=($3.sliceTo = $4, $3)"
}
]
}
],
sortEmojis({ query }: EmojiAutocompleteState) {
if (
query?.type !== "EMOJIS_AND_STICKERS"
|| query.typeInfo?.sentinel !== ":"
|| !query.results?.emojis?.length
) return;
const emojiContext = EmojiStore.getDisambiguatedEmojiContext();
query.results.emojis = query.results.emojis.sort((a, b) => {
const aIsFavorite = emojiContext.isFavoriteEmojiWithoutFetchingLatest(a);
const bIsFavorite = emojiContext.isFavoriteEmojiWithoutFetchingLatest(b);
if (aIsFavorite && !bIsFavorite) return -1;
if (!aIsFavorite && bIsFavorite) return 1;
return 0;
}).slice(0, query.results.emojis.sliceTo ?? Infinity);
}
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,39 @@
.vc-emoji-alias-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px;
border-radius: 8px;
background: var(--background-secondary);
}
.vc-emoji-alias-row-editing {
display: flex;
align-items: center;
gap: 8px;
}
.vc-emoji-alias-row-editing-text-input {
width: 180px;
}
.vc-emoji-alias-row-arrow {
color: var(--text-muted);
}
.vc-emoji-alias-row-error {
color: var(--text-feedback-critical);
}
.vc-emoji-alias-row-button {
display: flex;
align-items: center;
gap: 8px;
}
.vc-emoji-alias-settings {
display: flex;
flex-direction: column;
gap: 8px;
}

View file

@ -161,10 +161,10 @@ export default definePlugin({
break;
}
const cl = useCompact ? classNameFactory("vc-indicators-compact") : classNameFactory("vc-indicators");
const cl = useCompact ? classNameFactory("vc-indicators-compact-") : classNameFactory("vc-indicators-");
return <ErrorBoundary noop>
<div id={cl("-container")}>
<div id={cl("container")}>
<Tooltip text={text} position="right">
{({ onMouseEnter, onMouseLeave }) => (
<div

View file

@ -122,7 +122,7 @@ export default definePlugin({
patches: [
{
find: "#{intl::THREE_USERS_TYPING}",
find: "#{intl::Q8lUnE::raw}",
group: true,
replacement: [
{

View file

@ -33,10 +33,8 @@ import { ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, openModa
import { useAwaiter } from "@utils/react";
import definePlugin, { OptionType } from "@utils/types";
import { chooseFile } from "@utils/web";
import { CloudUpload } from "@vencord/discord-types";
import { CloudUploadPlatform } from "@vencord/discord-types/enums";
import { findLazy, findStoreLazy } from "@webpack";
import { Button, Constants, FluxDispatcher, Forms, lodash, Menu, MessageActions, PermissionsBits, PermissionStore, RestAPI, SelectedChannelStore, showToast, SnowflakeUtils, Toasts, useEffect, useState } from "@webpack/common";
import { Button, CloudUploader, Constants, FluxDispatcher, Forms, lodash, Menu, MessageActions, PendingReplyStore, PermissionsBits, PermissionStore, RestAPI, SelectedChannelStore, showToast, SnowflakeUtils, Toasts, useEffect, useState } from "@webpack/common";
import { VoiceRecorderDesktop } from "./components/DesktopRecorder";
import { VoicePreview } from "./components/VoicePreview";
@ -56,9 +54,6 @@ const EMPTY_META: AudioMetadata = {
duration: DEFAULT_DURATION,
};
const CloudUploadConstructor = findLazy(m => m.prototype?.trackUploadFinished) as typeof CloudUpload;
const PendingReplyStore = findStoreLazy("PendingReplyStore");
export const cl = classNameFactory("vc-vmsg-");
export type VoiceRecorder = React.ComponentType<{
@ -108,7 +103,7 @@ function sendAudio(blob: Blob, meta: AudioMetadata) {
const reply = PendingReplyStore.getPendingReply(channelId);
if (reply) FluxDispatcher.dispatch({ type: "DELETE_PENDING_REPLY", channelId });
const upload = new CloudUploadConstructor({
const upload = new CloudUploader({
file: new File([blob], "voice-message.ogg", { type: "audio/ogg; codecs=opus" }),
isThumbnail: false,
platform: CloudUploadPlatform.WEB,

View file

@ -124,7 +124,7 @@ export async function copyWithToast(text: string, toastMessage = "Copied to clip
});
}
interface MessageOptions {
export interface MessageOptions {
messageReference: Message["messageReference"];
allowedMentions: {
parse: string[];

View file

@ -82,6 +82,7 @@ export let PopoutWindowStore: t.PopoutWindowStore;
export let ApplicationCommandIndexStore: t.ApplicationCommandIndexStore;
export let EditMessageStore: t.EditMessageStore;
export let QuestStore: t.QuestStore;
export let PendingReplyStore: GenericStore;
/**
* @see jsdoc of {@link t.useStateFromStores}
@ -135,6 +136,7 @@ waitForStore("SoundboardStore", m => SoundboardStore = m);
waitForStore("PopoutWindowStore", m => PopoutWindowStore = m);
waitForStore("ApplicationCommandIndexStore", m => ApplicationCommandIndexStore = m);
waitForStore("EditMessageStore", m => EditMessageStore = m);
waitForStore("PendingReplyStore", m => PendingReplyStore = m);
waitForStore("QuestStore", m => QuestStore = m);
waitForStore("ThemeStore", m => {
ThemeStore = m;

View file

@ -226,3 +226,5 @@ export const DateUtils: t.DateUtils = mapMangledModuleLazy("millisecondsInUnit:"
export const MessageTypeSets: t.MessageTypeSets = findByPropsLazy("REPLYABLE", "FORWARDABLE");
export const fetchApplicationsRPC = findByCodeLazy('"Invalid Origin"', ".application");
export const CloudUploader = findLazy(m => m.prototype?.trackUploadFinished) as typeof t.CloudUpload;