From 2e002107a6739c025ee6f916acaa76a3f3373bbc Mon Sep 17 00:00:00 2001
From: AutumnVN <autumnvnchino@gmail.com>
Date: Tue, 1 Aug 2023 10:27:35 +0700
Subject: customRPC: add validation & some fixes (#1481)

Signed-off-by: V <vendicated@riseup.net>
---
 src/plugins/customRPC.tsx | 332 ++++++++++++++++++++++++++++++++++++----------
 1 file changed, 261 insertions(+), 71 deletions(-)

(limited to 'src/plugins/customRPC.tsx')

diff --git a/src/plugins/customRPC.tsx b/src/plugins/customRPC.tsx
index 8d1be96..5a8a9aa 100644
--- a/src/plugins/customRPC.tsx
+++ b/src/plugins/customRPC.tsx
@@ -23,21 +23,12 @@ import { isTruthy } from "@utils/guards";
 import { useAwaiter } from "@utils/react";
 import definePlugin, { OptionType } from "@utils/types";
 import { filters, findByCodeLazy, findByPropsLazy, mapMangledModuleLazy } from "@webpack";
-import {
-    FluxDispatcher,
-    Forms,
-    GuildStore,
-    React,
-    SelectedChannelStore,
-    SelectedGuildStore,
-    UserStore
-} from "@webpack/common";
+import { FluxDispatcher, Forms, GuildStore, React, SelectedChannelStore, SelectedGuildStore, UserStore } from "@webpack/common";
 
 const ActivityComponent = findByCodeLazy("onOpenGameProfile");
 const ActivityClassName = findByPropsLazy("activity", "buttonColor");
 const Colors = findByPropsLazy("profileColors");
 
-// START yoinked from lastfm.tsx
 const assetManager = mapMangledModuleLazy(
     "getAssetImage: size must === [number, number] for Twitch",
     {
@@ -46,6 +37,7 @@ const assetManager = mapMangledModuleLazy(
 );
 
 async function getApplicationAsset(key: string): Promise<string> {
+    if (/https?:\/\/(cdn|media)\.discordapp\.(com|net)\/attachments\//.test(key)) return "mp:" + key.replace(/https?:\/\/(cdn|media)\.discordapp\.(com|net)\//, "");
     return (await assetManager.getAsset(settings.store.appID, [key, undefined]))[0];
 }
 
@@ -71,66 +63,240 @@ interface Activity {
         button_urls?: Array<string>;
     };
     type: ActivityType;
+    url?: string;
     flags: number;
 }
 
 const enum ActivityType {
     PLAYING = 0,
+    STREAMING = 1,
     LISTENING = 2,
     WATCHING = 3,
     COMPETING = 5
 }
-// END
-
-const strOpt = (description: string) => ({
-    type: OptionType.STRING,
-    description,
-    onChange: setRpc
-}) as const;
-
-const numOpt = (description: string) => ({
-    type: OptionType.NUMBER,
-    description,
-    onChange: setRpc
-}) as const;
-
-const choice = (label: string, value: any, _default?: boolean) => ({
-    label,
-    value,
-    default: _default
-}) as const;
-
-const choiceOpt = <T,>(description: string, options: T) => ({
-    type: OptionType.SELECT,
-    description,
-    onChange: setRpc,
-    options
-}) as const;
 
+const enum TimestampMode {
+    NONE,
+    NOW,
+    TIME,
+    CUSTOM,
+}
 
 const settings = definePluginSettings({
-    appID: strOpt("The ID of the application for the rich presence."),
-    appName: strOpt("The name of the presence."),
-    details: strOpt("Line 1 of rich presence."),
-    state: strOpt("Line 2 of rich presence."),
-    type: choiceOpt("Type of presence", [
-        choice("Playing", ActivityType.PLAYING, true),
-        choice("Listening", ActivityType.LISTENING),
-        choice("Watching", ActivityType.WATCHING),
-        choice("Competing", ActivityType.COMPETING)
-    ]),
-    startTime: numOpt("Unix Timestamp for beginning of activity."),
-    endTime: numOpt("Unix Timestamp for end of activity."),
-    imageBig: strOpt("Sets the big image to the specified image."),
-    imageBigTooltip: strOpt("Sets the tooltip text for the big image."),
-    imageSmall: strOpt("Sets the small image to the specified image."),
-    imageSmallTooltip: strOpt("Sets the tooltip text for the small image."),
-    buttonOneText: strOpt("The text for the first button"),
-    buttonOneURL: strOpt("The URL for the first button"),
-    buttonTwoText: strOpt("The text for the second button"),
-    buttonTwoURL: strOpt("The URL for the second button")
+    appID: {
+        type: OptionType.STRING,
+        description: "Application ID (required)",
+        restartNeeded: true,
+        onChange: setRpc,
+        isValid: (value: string) => {
+            if (!value) return "Application ID is required.";
+            if (value && !/^\d+$/.test(value)) return "Application ID must be a number.";
+            return true;
+        }
+    },
+    appName: {
+        type: OptionType.STRING,
+        description: "Application name (required)",
+        restartNeeded: true,
+        onChange: setRpc,
+        isValid: (value: string) => {
+            if (!value) return "Application name is required.";
+            if (value.length > 128) return "Application name must be not longer than 128 characters.";
+            return true;
+        }
+    },
+    details: {
+        type: OptionType.STRING,
+        description: "Details (line 1)",
+        restartNeeded: true,
+        onChange: setRpc,
+        isValid: (value: string) => {
+            if (value && value.length > 128) return "Details (line 1) must be not longer than 128 characters.";
+            return true;
+        }
+    },
+    state: {
+        type: OptionType.STRING,
+        description: "State (line 2)",
+        restartNeeded: true,
+        onChange: setRpc,
+        isValid: (value: string) => {
+            if (value && value.length > 128) return "State (line 2) must be not longer than 128 characters.";
+            return true;
+        }
+    },
+    type: {
+        type: OptionType.SELECT,
+        description: "Activity type",
+        restartNeeded: true,
+        onChange: setRpc,
+        options: [
+            {
+                label: "Playing",
+                value: ActivityType.PLAYING,
+                default: true
+            },
+            {
+                label: "Streaming",
+                value: ActivityType.STREAMING
+            },
+            {
+                label: "Listening",
+                value: ActivityType.LISTENING
+            },
+            {
+                label: "Watching",
+                value: ActivityType.WATCHING
+            },
+            {
+                label: "Competing",
+                value: ActivityType.COMPETING
+            }
+        ]
+    },
+    streamLink: {
+        type: OptionType.STRING,
+        description: "Twitch.tv or Youtube.com link (only for Streaming activity type)",
+        restartNeeded: true,
+        onChange: setRpc,
+        isDisabled: isStreamLinkDisabled,
+        isValid: isStreamLinkValid
+    },
+    timestampMode: {
+        type: OptionType.SELECT,
+        description: "Timestamp mode",
+        restartNeeded: true,
+        onChange: setRpc,
+        options: [
+            {
+                label: "None",
+                value: TimestampMode.NONE,
+                default: true
+            },
+            {
+                label: "Since discord open",
+                value: TimestampMode.NOW
+            },
+            {
+                label: "Same as your current time",
+                value: TimestampMode.TIME
+            },
+            {
+                label: "Custom",
+                value: TimestampMode.CUSTOM
+            }
+        ]
+    },
+    startTime: {
+        type: OptionType.NUMBER,
+        description: "Start timestamp (only for custom timestamp mode)",
+        restartNeeded: true,
+        onChange: setRpc,
+        isDisabled: isTimestampDisabled,
+        isValid: (value: number) => {
+            if (value && value < 0) return "Start timestamp must be greater than 0.";
+            return true;
+        }
+    },
+    endTime: {
+        type: OptionType.NUMBER,
+        description: "End timestamp (only for custom timestamp mode)",
+        restartNeeded: true,
+        onChange: setRpc,
+        isDisabled: isTimestampDisabled,
+        isValid: (value: number) => {
+            if (value && value < 0) return "End timestamp must be greater than 0.";
+            return true;
+        }
+    },
+    imageBig: {
+        type: OptionType.STRING,
+        description: "Big image key",
+        restartNeeded: true,
+        onChange: setRpc,
+        isValid: isImageKeyValid
+    },
+    imageBigTooltip: {
+        type: OptionType.STRING,
+        description: "Big image tooltip",
+        restartNeeded: true,
+        onChange: setRpc,
+        isValid: (value: string) => {
+            if (value && value.length > 128) return "Big image tooltip must be not longer than 128 characters.";
+            return true;
+        }
+    },
+    imageSmall: {
+        type: OptionType.STRING,
+        description: "Small image key",
+        restartNeeded: true,
+        onChange: setRpc,
+        isValid: isImageKeyValid
+    },
+    imageSmallTooltip: {
+        type: OptionType.STRING,
+        description: "Small image tooltip",
+        restartNeeded: true,
+        onChange: setRpc,
+        isValid: (value: string) => {
+            if (value && value.length > 128) return "Small image tooltip must be not longer than 128 characters.";
+            return true;
+        }
+    },
+    buttonOneText: {
+        type: OptionType.STRING,
+        description: "Button 1 text",
+        restartNeeded: true,
+        onChange: setRpc,
+        isValid: (value: string) => {
+            if (value && value.length > 31) return "Button 1 text must be not longer than 31 characters.";
+            return true;
+        }
+    },
+    buttonOneURL: {
+        type: OptionType.STRING,
+        description: "Button 1 URL",
+        restartNeeded: true,
+        onChange: setRpc
+    },
+    buttonTwoText: {
+        type: OptionType.STRING,
+        description: "Button 2 text",
+        restartNeeded: true,
+        onChange: setRpc,
+        isValid: (value: string) => {
+            if (value && value.length > 31) return "Button 2 text must be not longer than 31 characters.";
+            return true;
+        }
+    },
+    buttonTwoURL: {
+        type: OptionType.STRING,
+        description: "Button 2 URL",
+        restartNeeded: true,
+        onChange: setRpc
+    }
 });
 
+function isStreamLinkDisabled(): boolean {
+    return settings.store.type !== ActivityType.STREAMING;
+}
+
+function isStreamLinkValid(): boolean | string {
+    if (settings.store.type === ActivityType.STREAMING && settings.store.streamLink && !/(https?:\/\/(www\.)?(twitch\.tv|youtube\.com)\/\w+)/.test(settings.store.streamLink)) return "Streaming link must be a valid URL.";
+    return true;
+}
+
+function isTimestampDisabled(): boolean {
+    return settings.store.timestampMode !== TimestampMode.CUSTOM;
+}
+
+function isImageKeyValid(value: string) {
+    if (!/https?:\/\//.test(value)) return true;
+    if (/https?:\/\/(?!i\.)?imgur\.com\//.test(value)) return "Imgur link must be a direct link to the image. (e.g. https://i.imgur.com/...)";
+    if (/https?:\/\/(?!media\.)?tenor\.com\//.test(value)) return "Tenor link must be a direct link to the image. (e.g. https://media.tenor.com/...)";
+    return true;
+}
+
 async function createActivity(): Promise<Activity | undefined> {
     const {
         appID,
@@ -138,6 +304,7 @@ async function createActivity(): Promise<Activity | undefined> {
         details,
         state,
         type,
+        streamLink,
         startTime,
         endTime,
         imageBig,
@@ -161,13 +328,32 @@ async function createActivity(): Promise<Activity | undefined> {
         flags: 1 << 0,
     };
 
-    if (startTime) {
-        activity.timestamps = {
-            start: startTime,
-        };
-        if (endTime) {
-            activity.timestamps.end = endTime;
-        }
+    if (type === ActivityType.STREAMING) activity.url = streamLink;
+
+    switch (settings.store.timestampMode) {
+        case TimestampMode.NOW:
+            activity.timestamps = {
+                start: Math.floor(Date.now() / 1000)
+            };
+            break;
+        case TimestampMode.TIME:
+            activity.timestamps = {
+                start: Math.floor(Date.now() / 1000) - (new Date().getHours() * 3600) - (new Date().getMinutes() * 60) - new Date().getSeconds()
+            };
+            break;
+        case TimestampMode.CUSTOM:
+            if (startTime) {
+                activity.timestamps = {
+                    start: startTime,
+                };
+                if (endTime) {
+                    activity.timestamps.end = endTime;
+                }
+            }
+            break;
+        case TimestampMode.NONE:
+        default:
+            break;
     }
 
     if (buttonOneText) {
@@ -187,7 +373,7 @@ async function createActivity(): Promise<Activity | undefined> {
     if (imageBig) {
         activity.assets = {
             large_image: await getApplicationAsset(imageBig),
-            large_text: imageBigTooltip
+            large_text: imageBigTooltip || undefined
         };
     }
 
@@ -195,13 +381,13 @@ async function createActivity(): Promise<Activity | undefined> {
         activity.assets = {
             ...activity.assets,
             small_image: await getApplicationAsset(imageSmall),
-            small_text: imageSmallTooltip
+            small_text: imageSmallTooltip || undefined
         };
     }
 
 
     for (const k in activity) {
-        if (k === "type") continue; // without type, the presence is considered invalid.
+        if (k === "type") continue;
         const v = activity[k];
         if (!v || v.length === 0)
             delete activity[k];
@@ -223,7 +409,7 @@ async function setRpc(disable?: boolean) {
 export default definePlugin({
     name: "CustomRPC",
     description: "Allows you to set a custom rich presence.",
-    authors: [Devs.captain],
+    authors: [Devs.captain, Devs.AutumnVN],
     start: setRpc,
     stop: () => setRpc(true),
     settings,
@@ -232,11 +418,15 @@ export default definePlugin({
         const activity = useAwaiter(createActivity);
         return (
             <>
-                <Forms.FormTitle tag="h2">NOTE:</Forms.FormTitle>
                 <Forms.FormText>
-                    You will need to <Link href="https://discord.com/developers/applications">create an
-                        application</Link> and
-                    get its ID to use this plugin.
+                    Go to <Link href="https://discord.com/developers/applications">Discord Deverloper Portal</Link> to create an application and
+                    get the application ID.
+                </Forms.FormText>
+                <Forms.FormText>
+                    Upload images in the Rich Presence tab to get the image keys.
+                </Forms.FormText>
+                <Forms.FormText>
+                    If you want to use image link, download your image and reupload the image to <Link href="https://imgur.com">Imgur</Link> and get the image link by right-clicking the image and select "Copy image address".
                 </Forms.FormText>
                 <Forms.FormDivider />
                 <div style={{ width: "284px" }} className={Colors.profileColors}>
-- 
cgit 

