Fix timestamp

This commit is contained in:
kmmiio99o 2025-09-27 15:36:59 +02:00
parent e70872d84d
commit e213c562a5
22 changed files with 791 additions and 0 deletions

View file

@ -0,0 +1,6 @@
{
"name": "BetterSpotifyIntegration",
"description": "Shrimply better integration with Spotify",
"vendetta": { },
"main": "src/index.tsx"
}

View file

@ -0,0 +1,39 @@
import { findByProps, findByStoreName } from "@vendetta/metro";
import { getAssetIDByName } from "@vendetta/ui/assets";
import { Image, View } from "react-native";
const FluxUtils = findByProps("useStateFromStores");
const SpotifyStore = findByStoreName("SpotifyStore");
const Button = findByProps("Sizes", "Looks") as any;
const SpotifySyncUtil = findByProps("play", "sync");
type ListenAlongButtonProps = Partial<{
activity: any;
user: any;
isCurrentUser: boolean;
activityButtonColor: string;
[key: string]: any;
}>
export default function ListenAlongButton(props: ListenAlongButtonProps) {
const currentPartyId = FluxUtils.useStateFromStores([SpotifyStore], () => SpotifyStore.getActivity()?.party?.id);
const alreadySync = props.isCurrentUser || props.activity?.party?.id === currentPartyId;
return <View pointerEvents={alreadySync ? "none" : "auto"}>
<Button
text={!alreadySync ? "Listen Along" : "You are already along for this ride"}
size={Button.Sizes.SMALL}
style={{
marginTop: 12,
backgroundColor: props.activityButtonColor,
opacity: alreadySync ? 0.5 : 1
}}
renderIcon={() => <Image
style={{ height: 16, width: 16, marginRight: 8, tintColor: "white" }}
source={getAssetIDByName("ic_headphone")}
/>}
onPress={() => void SpotifySyncUtil.sync(props.activity, props.user.id)}
/>
</View>;
}

View file

@ -0,0 +1,33 @@
import { findByName } from "@vendetta/metro";
import { i18n } from "@vendetta/metro/common";
import { before } from "@vendetta/patcher";
import { findInReactTree } from "@vendetta/utils";
import ListenAlongButton from "./components/ListenAlongButton";
let sectionPatch: () => void;
export default {
onLoad() {
const getListeningHeader = () => i18n.Messages.USER_ACTIVITY_HEADER_LISTENING?.intlMessage?.format({ name: "Spotify" });
sectionPatch = before("default", findByName("UserProfileSection", false), ([props]) => {
if (props.title !== getListeningHeader()) return;
const spotifyElement = findInReactTree(props.children, e => e?.style?.padding === 0);
const actionsIndex = spotifyElement?.children?.findIndex?.(e => e?.type?.name === "Actions");
if (actionsIndex && actionsIndex !== -1) {
const actions = spotifyElement.children[actionsIndex];
spotifyElement.children[actionsIndex] = <>
{actions}
{<ListenAlongButton {...actions.props} />}
</>;
}
});
},
onUnload() {
sectionPatch?.();
}
};

View file

@ -0,0 +1,15 @@
import { findByProps, findByStoreName } from "@vendetta/metro";
const FluxUtils = findByProps("useStateFromStores");
const SpotifyStore = findByStoreName("SpotifyStore");
const GamePartyStore = findByStoreName("GamePartyStore");
const UserStore = findByStoreName("UserStore");
export default function useListeningParty() {
const partySet = FluxUtils.useStateFromStores([GamePartyStore, SpotifyStore], () => {
const party = GamePartyStore.getParty(SpotifyStore.getActivity()?.party?.id);
return party?.size > 1 ? party : null;
});
return partySet ? [...partySet].map(id => UserStore.getUser(id)) : [];
}

View file

@ -0,0 +1,7 @@
{
"name": "HideGiftButton",
"description": "Hides the gift button in the chat input.",
"vendetta": {
"icon": "ic_gift_24px"
}
}

View file

@ -0,0 +1,19 @@
import { findByName } from "@vendetta/metro";
import { after } from "@vendetta/patcher";
import { getAssetIDByName } from "@vendetta/ui/assets";
import { findInReactTree } from "@vendetta/utils";
const ChatInput = findByName("ChatInput");
let unpatch: () => boolean;
export default {
onLoad() {
const blockList = ["ic_thread_normal_24px", "ic_gift"].map(n => getAssetIDByName(n));
unpatch = after("render", ChatInput.prototype, (_, ret) => {
const input = findInReactTree(ret, t => "forceAnimateButtons" in t.props && t.props.actions);
input.props.actions = input.props.actions.filter(a => !blockList.includes(a.source));
});
},
onUnload: unpatch
};

View file

@ -0,0 +1,7 @@
{
"name": "HoldGIFSend",
"description": "Hold GIF links to chat input before sending them.",
"vendetta": {
"icon": "gif"
}
}

View file

@ -0,0 +1,13 @@
import { findByProps } from "@vendetta/metro";
import { instead } from "@vendetta/patcher";
const messageInputUtil = findByProps("selectGIF");
export const onUnload = instead("selectGIF", messageInputUtil, (args) => {
// args[1] never exists on older version, so we use it as an indicator
// prev: selectGIF([GIF]) now: selectGIF(currentChannelId, [GIF]);
if (args[1]) args[1] = args[1].url;
else args[0] = args[0].url;
messageInputUtil.insertText(...args);
});

View file

@ -0,0 +1,7 @@
{
"name": "MoreConfirm",
"description": "Prompts confirmations before making irreversible actions.",
"vendetta": {
"icon": "check"
}
}

View file

@ -0,0 +1,88 @@
/*!
* https://github.com/amsyarasyiq/aliucordrn-plugins/blob/6c0be39ca673c6ebcf48c0f397246b76d578aa26/MoarConfirm/index.tsx
*
* Copyright (c) 2022 Amsyar Rasyiq
*
* 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 { logger, patcher } from "@vendetta";
import { findByProps, findByStoreName } from "@vendetta/metro";
const dialog = findByProps("show", "confirm", "close");
const relationshipManager = findByProps("addRelationship");
const callManager = findByProps("handleStartCall");
const actionSheetManager = findByProps("hideActionSheet");
const UserStore = findByStoreName("UserStore");
export default new class MoreConfirm {
patches = [] as (() => void)[];
onLoad() {
this.patches.push(patcher.instead("handleStartCall", callManager, (args, orig) => {
const [{ rawRecipients: [{ username, discriminator }, multiple] }, isVideo] = args;
const action = isVideo ? "video call" : "call";
// if `multiple` is defined, it's probably a group call
dialog.show({
title: multiple ? `Start a group ${action}?` : `Start a ${action} with ${username}#${discriminator}?`,
body: multiple ? "Are you sure you want to start the group call?" : `Are you sure you want to ${action} ${username}#${discriminator}?`,
confirmText: "Yes",
cancelText: "Cancel",
confirmColor: "brand",
onConfirm: () => {
try {
orig(...args);
} catch (e) {
logger.error("Failed to start call", e);
}
},
});
}));
this.patches.push(patcher.instead("addRelationship", relationshipManager, (args, orig) => {
if (typeof args[0] !== "object" || !args[0].userId) return orig.apply(this, args);
const { username, discriminator } = UserStore.getUser(args[0].userId);
// This is hacky, but it *works*
const hideASInterval = setInterval(() => actionSheetManager.hideActionSheet(), 100);
setTimeout(() => clearInterval(hideASInterval), 3000);
const block = args[0].type === 2;
return new Promise(r => {
dialog.show({
title: `${block ? "Block" : "Friend"} ${username}#${discriminator}?`,
body: `Are you sure you want to ${block ? "block" : "friend"} ${username}#${discriminator}?`,
confirmText: "Yes",
cancelText: "Cancel",
confirmColor: "brand",
onConfirm: () => {
try {
r(orig.apply(this, args));
} catch (e) {
logger.error("Failed to add relationship", e);
} finally {
clearInterval(hideASInterval);
}
},
onCancel: () => void clearInterval(hideASInterval)
});
});
}));
}
onUnload() {
for (const unpatch of this.patches) unpatch();
}
};

View file

@ -0,0 +1,7 @@
{
"name": "NoAutoReplyMention",
"description": "Turns off auto mention when replying to someone.",
"vendetta": {
"icon": "ic_notif_off"
}
}

View file

@ -0,0 +1,110 @@
import { findByStoreName } from "@vendetta/metro";
import { NavigationNative, React, stylesheet } from "@vendetta/metro/common";
import { useProxy } from "@vendetta/storage";
import { getAssetIDByName } from "@vendetta/ui/assets";
import { Forms } from "@vendetta/ui/components";
import { useEffect, useState } from "react";
import { FlatList, Image, ScrollView, TouchableOpacity } from "react-native";
import { SettingsSchema, settings } from ".";
const UserStore = findByStoreName("UserStore");
const { FormInput, FormDivider, FormText, FormIcon, FormRow, FormSection, FormSwitchRow } = Forms;
const styles = stylesheet.createThemedStyleSheet({
avatar: {
height: 36,
width: 36,
borderRadius: 18,
},
button: {
marginRight: 12,
}
});
function AddButton({ callback }: { callback: () => void }) {
return <TouchableOpacity onPress={callback}>
<FormText style={styles.button}>ADD</FormText>
</TouchableOpacity>;
}
function AddRow({ onFinish }: { onFinish: () => void }) {
const [value, setValue] = useState("");
const onPressCallback = () => {
if (!isNaN(parseInt(value)) && UserStore.getUser(value) && !settings.exempted.includes(value)) {
settings.exempted = [...settings.exempted, value];
setValue("");
}
onFinish();
};
return (
<FormRow
leading={<FormIcon source={getAssetIDByName("ic_add_friend")} />}
label={<FormInput autoFocus
title="User ID"
placeholder="123456789012345678"
value={value}
keyboardType="numeric"
onChangeText={(value: string) => setValue(value.replace(/[^0-9]/g, "").trim())}
returnKeyType="done"
/>}
trailing={(
<TouchableOpacity onPress={onPressCallback}>
<FormIcon source={getAssetIDByName("ic_add_24px")} />
</TouchableOpacity>
)}
/>
);
}
export default function Settings() {
useProxy(settings) as SettingsSchema;
const [shouldShowAdd, setShouldShowAdd] = useState(false);
const navigation = NavigationNative.useNavigation();
useEffect(() => {
navigation.setOptions({
headerRight: () => <AddButton callback={() => setShouldShowAdd(true)} />
});
}, []);
return (
<ScrollView>
<FormSwitchRow
label="Enable blacklist mode"
subLabel="If enabled, only users in the exempted list will be affected by the plugin."
leading={<FormIcon source={getAssetIDByName("ic_block")} />}
value={settings.isBlacklistMode}
onValueChange={(value: boolean) => settings.isBlacklistMode = value}
/>
<FormSection title="Exempted Users">
{settings.exempted?.length > 0 && <FlatList
data={settings.exempted}
keyExtractor={(item) => item}
renderItem={({ item }) => {
const user = UserStore.getUser(item);
if (!user) throw new Error("User not found/is not cached");
return (<FormRow
label={user.username}
leading={<Image style={styles.avatar} source={{ uri: user.getAvatarURL() }} />}
trailing={<TouchableOpacity onPress={() => {
settings.exempted = settings.exempted.filter((id) => id !== item);
}}>
<FormIcon source={getAssetIDByName("Small")} disableColor />
</TouchableOpacity>}
/>);
}}
ItemSeparatorComponent={FormDivider}
/>}
{shouldShowAdd && <>
<FormDivider />
<AddRow onFinish={() => setShouldShowAdd(false)} />
</>}
</FormSection>
</ScrollView>
);
}

View file

@ -0,0 +1,26 @@
// export const onUnload = globalThis.vendetta.patcher.before("createPendingReply", globalThis.vendetta.metro.findByProps("createPendingReply"), ([arg]) => arg.shouldMention &&= false);
import { patcher } from "@vendetta";
import { findByProps } from "@vendetta/metro";
import { storage } from "@vendetta/plugin";
import Settings from "./Settings";
export interface SettingsSchema {
isBlacklistMode: boolean;
exempted: string[];
}
storage.isBlacklistMode ??= false;
storage.exempted ??= [];
export const settings = storage as SettingsSchema;
const ReplyManager = findByProps("createPendingReply");
export default {
settings: Settings,
onUnload: patcher.before("createPendingReply", ReplyManager, ([arg]) => {
const isListed = storage.exempted.includes(arg.message?.author?.id);
arg.shouldMention &&= storage.isBlacklistMode ? !isListed : isListed;
})
};

View file

@ -0,0 +1,7 @@
{
"name": "NoIdle",
"description": "Prevent status from idling when Discord is in the background.",
"vendetta": {
"icon": "StatusIdle"
}
}

View file

@ -0,0 +1,8 @@
import { patcher } from "@vendetta";
import { FluxDispatcher } from "@vendetta/metro/common";
export const onUnload = patcher.before("dispatch", FluxDispatcher, ([{ type }]) => {
if (type !== "IDLE") return;
return [{ type: "IDLE", idle: false }];
});

View file

@ -0,0 +1,4 @@
{
"name": "PyoncordInstaller",
"description": "installs pyoncord.exe (virus)"
}

View file

@ -0,0 +1,28 @@
import { plugin, plugins } from "@vendetta";
import { config } from "@vendetta/loader";
import { showConfirmationAlert } from "@vendetta/ui/alerts";
export default {
onLoad() {
plugins.removePlugin(plugin.id);
setTimeout(() => showConfirmationAlert({
title: "Install Pyoncord?",
content: "Loader's settings will be overriden to load Pyoncord instead. You may not be able to revert to Vendetta whenever Pyoncord breaks!",
confirmText: "Continue and Restart",
cancelText: "Nevermind",
onConfirm: this.install
}), 300);
},
async install() {
if (config.customLoadUrl) {
config.customLoadUrl.enabled = true;
config.customLoadUrl.url = "https://raw.githubusercontent.com/pyoncord/pyoncord/builds/pyoncord.js";
setTimeout(globalThis.nativeModuleProxy.BundleUpdaterManager.reload, 100);
} else {
alert("not a proper vendetta loader!");
}
}
};

View file

@ -0,0 +1,7 @@
{
"name": "RichPresence",
"description": "Sets Discord Rich Presence for your profile.",
"vendetta": {
"icon": "games"
}
}

View file

@ -0,0 +1,174 @@
import { findByProps, findByStoreName } from "@vendetta/metro";
import { React, stylesheet } from "@vendetta/metro/common";
import { storage } from "@vendetta/plugin";
import { useProxy } from "@vendetta/storage";
import { semanticColors } from "@vendetta/ui";
import { Button, Forms } from "@vendetta/ui/components";
import { ScrollView } from "react-native";
const { FormSection, FormInput, FormRow, FormSwitch, FormText } = Forms;
const UserStore = findByStoreName("UserStore");
const profiles = findByProps("showUserProfile");
const styles = stylesheet.createThemedStyleSheet({
subText: {
fontSize: 14,
marginLeft: 16,
marginRight: 16,
color: semanticColors.TEXT_MUTED
},
textLink: {
color: semanticColors.TEXT_LINK,
}
});
export default function ConfigEditor({ selection }: { selection: string }) {
const settings = useProxy(storage.selections[selection]) as Activity;
return (
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ paddingBottom: 38 }}>
<Button
style={{ margin: 16 }}
color={"brand"}
size={Button.Sizes.MEDIUM}
look={Button.Looks.FILLED}
onPress={async () => {
profiles.showUserProfile({ userId: UserStore.getCurrentUser().id });
}}
text="Preview your profile"
/>
<FormSection title="Basic" titleStyleType="no_border">
<FormInput required autoFocus
title="Application Name"
value={settings.name}
placeholder="Discord"
onChange={v => settings.name = v}
/>
<FormInput required
title="Application ID"
value={settings.application_id}
placeholder="1054951789318909972"
onChange={v => settings.application_id = v}
keyboardType="numeric"
/>
<FormInput
title="Type"
value={settings.type}
placeholder="0"
onChange={v => settings.type = v}
keyboardType="numeric"
/>
<FormInput
title="Details"
value={settings.details}
placeholder="Competitive"
onChange={v => settings.details = v}
/>
<FormInput
title="State"
value={settings.state}
placeholder="Playing Solo"
onChange={v => settings.state = v}
/>
</FormSection>
<FormSection title="Images">
<FormInput
title="Large Image Asset Key or URL"
value={settings.assets.large_image}
placeholder="large_image_here"
onChange={v => settings.assets.large_image = v}
/>
<FormInput
title="Large Image Text"
value={settings.assets.large_text}
placeholder="Playing on Joe's lobby"
disabled={!settings.assets.large_image}
onChange={v => settings.assets.large_text = v}
/>
<FormInput
title="Small Image Asset Key or URL"
value={settings.assets.small_image}
placeholder="small_image_here"
onChange={v => settings.assets.small_image = v}
/>
<FormInput
title="Small Image Text"
value={settings.assets.small_text}
placeholder="Solo"
disabled={!settings.assets.small_image}
onChange={v => settings.assets.small_text = v}
/>
</FormSection>
<FormText style={styles.subText}>
{"Image assets key can be either a Discord app asset's name or a URL to an image."}
</FormText>
<FormSection title="Timestamps">
<FormRow
label="Enable timestamps"
subLabel="Set whether to show timestamps or not"
trailing={<FormSwitch
value={settings.timestamps._enabled}
onValueChange={v => settings.timestamps._enabled = v}
/>}
/>
<FormInput
title="Start Timestamp (milliseconds)"
value={settings.timestamps.start}
placeholder="1234567890"
disabled={!settings.timestamps._enabled}
onChange={v => settings.timestamps.start = v}
keyboardType="numeric"
/>
<FormInput
title="End Timestamp (milliseconds)"
value={settings.timestamps.end}
placeholder="1234567890"
disabled={!settings.timestamps._enabled}
onChange={v => settings.timestamps.end = v}
keyboardType="numeric"
/>
<FormRow
label="Use current time as start timestamp"
subLabel="This will override the start timestamp you set above"
disabled={!settings.timestamps._enabled}
onPress={() => settings.timestamps.start = String(Date.now())}
trailing={FormRow.Arrow}
/>
</FormSection>
<FormText style={styles.subText}>
Leaving start timestamp blank will use the time the Discord started.
</FormText>
<FormSection title="Buttons">
<FormInput
title="First Button Text"
value={settings.buttons[0].label}
placeholder="random link #1"
onChange={v => settings.buttons[0].label = v}
/>
<FormInput
title="First Button URL"
value={settings.buttons[0].url}
placeholder="https://discord.com/vanityurl/dotcom/steakpants/flour/flower/index11.html"
disabled={!settings.buttons[0].label}
onChange={v => settings.buttons[0].url = v}
/>
<FormInput
title="Second Button Text"
value={settings.buttons[1].label}
placeholder="random link #2"
onChange={v => settings.buttons[1].label = v}
/>
<FormInput
title="Second Button URL"
value={settings.buttons[1].url}
placeholder="https://youtu.be/w0AOGeqOnFY"
disabled={!settings.buttons[1].label}
onChange={v => settings.buttons[1].url = v}
/>
</FormSection>
</ScrollView>
);
}

View file

@ -0,0 +1,40 @@
import RPInstance from ".";
import { NavigationNative } from "@vendetta/metro/common";
import plugin from "@vendetta/plugin";
import { Forms } from "@vendetta/ui/components";
import ConfigEditor from "./ConfigEditor";
import { useEffect } from "react";
import { TouchableOpacity } from "react-native";
const { FormText } = Forms;
const storage = plugin.storage as typeof plugin.storage & {
selected: string;
selections: Record<string, Activity>;
};
function UpdateButton() {
async function onPressCallback() {
RPInstance.onUnload();
RPInstance.onLoad();
}
return <TouchableOpacity onPress={onPressCallback}>
<FormText style={{ marginRight: 12 }}>UPDATE</FormText>
</TouchableOpacity>;
}
export default function Settings() {
const navigation = NavigationNative.useNavigation();
useEffect(() => {
navigation.setOptions({
headerRight: UpdateButton
});
}, []);
return (
<ConfigEditor selection={storage.selected} />
);
}

View file

@ -0,0 +1,121 @@
import { logger, plugin } from "@vendetta";
import { findByProps } from "@vendetta/metro";
import { FluxDispatcher } from "@vendetta/metro/common";
import Settings from "./Settings";
import { cloneAndFilter } from "./utils";
enum ActivityTypes {
PLAYING = 0,
STREAMING = 1,
LISTENING = 2,
WATCHING = 3,
// CUSTOM = 4,
COMPETING = 5
}
const assetManager = findByProps("getAssetIds");
const pluginStartSince = Date.now();
const storage = plugin.storage as typeof plugin.storage & {
selected: string;
selections: Record<string, Activity>;
};
if (typeof storage.selected?.length !== "number") {
Object.assign(storage, {
selected: "default",
selections: {
default: createDefaultSelection()
}
});
}
function createDefaultSelection(): Activity {
return {
name: "Discord",
application_id: "1054951789318909972",
flags: 0,
type: ActivityTypes.PLAYING,
timestamps: {
_enabled: false,
start: pluginStartSince
},
assets: {},
// @ts-ignore
buttons: [{}, {}]
};
}
async function sendRequest(activity: Activity | null): Promise<{ [K in keyof Activity]: any } & Record<string, any>> {
if (typeof activity !== "object") throw new Error("Invalid activity");
if (activity) {
const timestampEnabled = activity?.timestamps?._enabled;
activity = cloneAndFilter(activity);
if (timestampEnabled) {
activity.timestamps.start ||= pluginStartSince;
} else {
delete activity.timestamps;
}
if (activity.assets) {
const args = [activity.application_id, [activity.assets.large_image, activity.assets.small_image]];
let assetIds = assetManager.getAssetIds(...args);
if (!assetIds.length) assetIds = await assetManager.fetchAssetIds(...args);
activity.assets.large_image = assetIds[0];
activity.assets.small_image = assetIds[1];
}
if (activity.buttons?.length) {
activity.buttons = activity.buttons.filter(x => x.label && x.url);
activity.buttons.length
? Object.assign(activity, {
metadata: { button_urls: activity.buttons.map(x => x.url) },
buttons: activity.buttons.map(x => x.label)
})
: delete activity.buttons;
} else {
delete activity.buttons;
}
}
FluxDispatcher.dispatch({
type: "LOCAL_ACTIVITY_UPDATE",
activity: activity,
pid: 1608,
socketId: "RichPresence@Vendetta"
});
return activity;
}
export default new class RichPresence {
onLoad() {
logger.log("Sending RPC request");
const currentActivity = storage.selections[storage.selected];
if (!currentActivity) {
throw new Error("Selected activity does not exist");
}
sendRequest(currentActivity)
.then(x => {
logger.log("RPC request sent");
logger.log(x);
}).catch(e => {
logger.error("An error occured while sending RPC request :(");
logger.error(e?.stack ?? e);
});
}
onUnload() {
logger.log("Stopping RPC request");
sendRequest(null);
}
settings = Settings;
};

View file

@ -0,0 +1,25 @@
function isValid(val: any) {
if (val && typeof val.length === "number") return val.length > 0;
return val || val === 0 || val === false;
}
// Deep clone and filter empty objects/values or keys starting with "_"
export function cloneAndFilter<T extends object>(obj: T): T {
const filter = (k: PropertyKey, v: any) => {
if (v === obj) return v;
if (typeof k === "string" && k.startsWith("_")) return undefined;
switch (typeof v) {
case "object": {
if (Array.isArray(v) && v.length === 0) return undefined;
const filteredEntries = Object.entries(v).filter((e) => isValid(e[1]));
return filteredEntries.length > 0 ? Object.fromEntries(filteredEntries) : undefined;
}
default:
return isValid(v) ? v : undefined;
}
};
return JSON.parse(JSON.stringify(obj, filter));
}