Merge branch 'dev'

This commit is contained in:
thororen1234 2025-09-23 08:43:23 -04:00
commit 586b49b50b
No known key found for this signature in database
13 changed files with 725 additions and 234 deletions

View file

@ -87,6 +87,14 @@ const settings = definePluginSettings({
type: OptionType.BOOLEAN,
default: true,
},
posterSource: {
description: "Choose which poster to display in Rich Presence",
type: OptionType.SELECT,
options: [
{ label: "Jellyfin", value: "jellyfin", default: true },
{ label: "TheMovieDB", value: "tmdb" },
],
},
overrideRichPresenceType: {
description: "Override the rich presence type",
type: OptionType.SELECT,
@ -310,7 +318,14 @@ export default definePlugin({
}
const assets: ActivityAssets = {
large_image: tmdbData?.posterPath ? await getApplicationAsset(tmdbData.posterPath) : mediaData.imageUrl ? await getApplicationAsset(mediaData.imageUrl) : undefined,
large_image:
settings.store.posterSource === "tmdb"
? (tmdbData?.posterPath
? await getApplicationAsset(tmdbData.posterPath)
: undefined)
: (mediaData.imageUrl
? await getApplicationAsset(mediaData.imageUrl)
: undefined),
large_text: mediaData.seriesName || mediaData.album || undefined,
};

View file

@ -0,0 +1,371 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { Link } from "@components/Link";
import { EquicordDevs } from "@utils/constants";
import { Logger } from "@utils/Logger";
import definePlugin, { OptionType } from "@utils/types";
import { Activity, ActivityAssets, ActivityButton } from "@vencord/discord-types";
import { ActivityFlags, ActivityType } from "@vencord/discord-types/enums";
import { findByPropsLazy } from "@webpack";
import { ApplicationAssetUtils, FluxDispatcher, Forms } from "@webpack/common";
interface TrackData {
name: string;
album: string;
artist: string;
durationMs?: number;
recordingMBID?: string;
url: string;
imageUrl?: string;
}
const enum NameFormat {
StatusName = "status-name",
ArtistFirst = "artist-first",
SongFirst = "song-first",
ArtistOnly = "artist",
SongOnly = "song",
AlbumName = "album",
}
const applicationId = "1090155131007406132";
const placeholderId = "2a96cbd8b46e442fc41c2b86b821562f";
const logger = new Logger("ListenBrainzRPC");
const PresenceStore = findByPropsLazy("getLocalPresence");
async function getApplicationAsset(key: string): Promise<string> {
return (await ApplicationAssetUtils.fetchAssetIds(applicationId, [key]))[0];
}
function setActivity(activity: Activity | null) {
FluxDispatcher.dispatch({
type: "LOCAL_ACTIVITY_UPDATE",
activity,
socketId: "ListenBrainz",
});
}
const settings = definePluginSettings({
username: {
description: "ListenBrainz username",
type: OptionType.STRING,
},
mbContact: {
description: "ListenBrainz contact",
type: OptionType.STRING,
},
shareUsername: {
description:
"show link to ListenBrainz profile (may only be visible to other users)",
type: OptionType.BOOLEAN,
default: false,
},
shareSong: {
description:
"show link to song on ListenBrainz (may only be visible to other users)",
type: OptionType.BOOLEAN,
default: true,
},
hideWithSpotify: {
description: "hide ListenBrainz presence if Spotify is running",
type: OptionType.BOOLEAN,
default: true,
},
hideWithActivity: {
description: "hide ListenBrainz presence if you have any other presence",
type: OptionType.BOOLEAN,
default: false,
},
useTimeBar: {
description:
"use track duration to display a time bar (must be using Listening status)",
type: OptionType.BOOLEAN,
default: true,
},
statusName: {
description: "custom status text",
type: OptionType.STRING,
default: "some music",
},
nameFormat: {
description: "Show name of song and artist in status name",
type: OptionType.SELECT,
options: [
{
label: "Use custom status name",
value: NameFormat.StatusName,
default: true,
},
{
label: "Use format 'artist - song'",
value: NameFormat.ArtistFirst,
},
{
label: "Use format 'song - artist'",
value: NameFormat.SongFirst,
},
{
label: "Use artist name only",
value: NameFormat.ArtistOnly,
},
{
label: "Use song name only",
value: NameFormat.SongOnly,
},
{
label:
"Use album name (falls back to custom status text if song has no album)",
value: NameFormat.AlbumName,
},
],
},
useListeningStatus: {
description: 'show "Listening to" status instead of "Playing"',
type: OptionType.BOOLEAN,
default: true,
},
missingArt: {
description: "When album or album art is missing",
type: OptionType.SELECT,
options: [
{
label: "Use large ListenBrainz logo",
value: "listenbrainzLogo",
default: true,
},
{
label: "Use generic placeholder",
value: "placeholder",
},
],
},
useLogo: {
description: "Show ListenBrainz logo on album art",
type: OptionType.BOOLEAN,
default: true,
},
});
var currentRecordingMBID = "";
var currentStart = 0;
export default definePlugin({
name: "ListenBrainzRPC",
description: "Little plugin for ListenBrainz rich presence",
authors: [EquicordDevs.qouesm],
settingsAboutComponent: () => (
<>
<Forms.FormTitle tag="h3">About MusicBrainz API</Forms.FormTitle>
<Forms.FormText>
The MusicBrainz API does not require an API key, but it does require a{" "}
<Link href="https://musicbrainz.org/doc/MusicBrainz_API/Rate_Limiting#Provide_meaningful_User-Agent_strings">
{" "}
meaningful user-agent string
</Link>{" "}
. For most, an email address should suffice.
</Forms.FormText>
</>
),
settings,
start() {
this.updatePresence();
this.updateInterval = setInterval(() => {
this.updatePresence();
}, 16000);
},
stop() {
clearInterval(this.updateInterval);
},
async fetchTrackData(): Promise<TrackData | null> {
if (!settings.store.username) return null;
try {
const lbRes = await fetch(
`https://api.listenbrainz.org/1/user/${settings.store.username}/playing-now`,
);
if (!lbRes.ok) throw `${lbRes.status} ${lbRes.statusText}`;
const lbJson = await lbRes.json();
if (lbJson.error) {
logger.error(
"Error from ListenBrainz API",
`${lbJson.error}: ${lbJson.message}`,
);
return null;
}
const listen = lbJson.payload?.listens?.[0];
if (!listen?.playing_now) return null;
const trackMetadata = listen.track_metadata;
const albumName = trackMetadata.release_name || "Unknown";
const artistName = trackMetadata.artist_name || "Unknown";
const mbRes = await fetch(`https://musicbrainz.org/ws/2/release/?query=release:${encodeURIComponent(albumName)}%20AND%20artist:${encodeURIComponent(artistName)}&fmt=json`,);
if (!mbRes.ok) throw `${mbRes.status} ${mbRes.statusText}`;
const mbJson = await mbRes.json();
const releases = mbJson.releases || [];
const releaseGroup = releases[0]["release-group"].id;
const caaRes = await fetch(
`https://coverartarchive.org/release-group/${releaseGroup}`,
);
if (!caaRes.ok) throw `${caaRes.status} ${caaRes.statusText}`;
const caaJson = await caaRes.json();
const url: string = caaJson.release;
const { images } = caaJson;
let imageUrl: string = "";
for (const image of images) {
imageUrl = image.thumbnails.large || "";
if (!imageUrl) continue;
break;
}
return {
name: trackMetadata.track_name || "Unknown",
album: albumName,
artist: artistName,
durationMs: trackMetadata.additional_info.duration_ms,
recordingMBID: trackMetadata.additional_info.recording_mbid,
url: url,
imageUrl: imageUrl,
};
} catch (e) {
logger.error("Failed to query ListenBrainz API", e);
// will clear the rich presence if API fails
return null;
}
},
async updatePresence() {
setActivity(await this.getActivity());
},
getLargeImage(track: TrackData): string | undefined {
if (track.imageUrl && !track.imageUrl.includes(placeholderId))
return track.imageUrl;
if (settings.store.missingArt === "placeholder") return "placeholder";
},
async getActivity(): Promise<Activity | null> {
if (settings.store.hideWithActivity) {
if (
PresenceStore.getActivities().some(
a => a.application_id !== applicationId,
)
) {
return null;
}
}
if (settings.store.hideWithSpotify) {
if (
PresenceStore.getActivities().some(
a =>
a.type === ActivityType.LISTENING &&
a.application_id !== applicationId,
)
) {
// there is already music status because of Spotify or richerCider (probably more)
return null;
}
}
const trackData = await this.fetchTrackData();
if (!trackData) return null;
const largeImage = this.getLargeImage(trackData);
const assets: ActivityAssets = largeImage
? {
large_image: await getApplicationAsset(largeImage),
large_text: trackData.album || undefined,
small_image: settings.store.useLogo
? await getApplicationAsset("listenbrainz")
: undefined,
small_text: "ListenBrainz",
}
: {
large_image: await getApplicationAsset("listenbrainz"),
large_text: trackData.album || undefined,
};
const buttons: ActivityButton[] = [];
if (settings.store.shareUsername)
buttons.push({
label: "ListenBrainz Profile",
url: `https://www.listenbrainz.org/user/${settings.store.username}`,
});
if (settings.store.shareSong)
buttons.push({
label: "View Song",
url: trackData.url,
});
const statusName = (() => {
switch (settings.store.nameFormat) {
case NameFormat.ArtistFirst:
return trackData.artist + " - " + trackData.name;
case NameFormat.SongFirst:
return trackData.name + " - " + trackData.artist;
case NameFormat.ArtistOnly:
return trackData.artist;
case NameFormat.SongOnly:
return trackData.name;
case NameFormat.AlbumName:
return trackData.album || settings.store.statusName;
default:
return settings.store.statusName;
}
})();
if (trackData.recordingMBID && trackData.recordingMBID !== currentRecordingMBID) {
currentRecordingMBID = trackData.recordingMBID;
currentStart = Date.now();
}
return {
application_id: applicationId,
name: statusName,
details: trackData.name,
state: trackData.artist,
assets,
timestamps: {
start: settings.store.useTimeBar ? currentStart : undefined,
end: settings.store.useTimeBar
? currentStart + (trackData.durationMs ?? 0)
: undefined,
},
buttons: buttons.length ? buttons.map(v => v.label) : undefined,
metadata: {
button_urls: buttons.map(v => v.url),
},
type: settings.store.useListeningStatus
? ActivityType.LISTENING
: ActivityType.PLAYING,
flags: ActivityFlags.INSTANCE,
};
},
});

View file

@ -16,7 +16,7 @@ export const GuildlessServerListItemComponent = findComponentByCodeLazy("tooltip
export const GuildedServerListItemPillComponent = findComponentByCodeLazy('"pill":"empty"');
export const ServerListItemLowerBadgeComponent = findComponentByCodeLazy("STATUS_DANGER,disableColor:");
export const ServerListItemUpperBadgeComponent = findComponentByCodeLazy("STATUS_DANGER,shape:", "iconBadge,");
export const RadioGroup = findComponentByCodeLazy("\"label\",\"description\"]);");
export const RadioGroup = findComponentByCodeLazy('["label"', "description:");
export const QuestTile = findComponentByCodeLazy(".rowIndex,trackGuildAndChannelMetadata");
export enum QuestRewardType {

View file

@ -4,47 +4,14 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { EquicordDevs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import definePlugin from "@utils/types";
import { User } from "@vencord/discord-types";
import { React } from "@webpack/common";
import { Flex, React } from "@webpack/common";
import { settings } from "./settings";
import { UserChatButton, UserDeafenButton, UserMuteButton } from "./utils";
export const settings = definePluginSettings({
showChatButton: {
type: OptionType.BOOLEAN,
default: true,
description: "Whether you want to display the chat button",
restartNeeded: true,
},
showMuteButton: {
type: OptionType.BOOLEAN,
default: true,
description: "Whether you want to display the mute button",
restartNeeded: true,
},
showDeafenButton: {
type: OptionType.BOOLEAN,
default: true,
description: "Whether you want to display the deafen button",
restartNeeded: true,
},
muteSoundboard: {
type: OptionType.BOOLEAN,
default: true,
description: "Toggles their soundboard upon clicking deafen button.",
restartNeeded: false,
},
disableVideo: {
type: OptionType.BOOLEAN,
default: true,
description: "Toggles their video upon clicking deafen button.",
restartNeeded: false,
},
});
export default definePlugin({
name: "VoiceButtons",
description: "Quickly DM, mute, or deafen any user right from the voice-call panel.",
@ -55,20 +22,20 @@ export default definePlugin({
find: "\"avatarContainerClass\",\"userNameClassName\"",
replacement: [
{
match: /:(\i)\.username.*?flipped\]:\i\}\),children:\[/,
replace: "$&$self.renderButtons($1),"
match: /flipped\]:\i\}\),children:\[/,
replace: "$&$self.renderButtons(arguments[0].user),"
}
]
}
],
renderPing(user?: User) {
renderButtons(user: User) {
if (!user) return null;
return (
<div style={{ display: "flex", gap: "4px" }}>
<Flex direction={Flex.Direction.HORIZONTAL} className="voice-user-buttons">
{settings.store.showChatButton && <UserChatButton user={user} />}
{settings.store.showMuteButton && <UserMuteButton user={user} />}
{settings.store.showDeafenButton && <UserDeafenButton user={user} />}
</div>
</Flex>
);
}
});

View file

@ -0,0 +1,63 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { OptionType } from "@utils/types";
export const settings = definePluginSettings({
showChatButton: {
type: OptionType.BOOLEAN,
default: true,
description: "Whether you want to display the chat button",
restartNeeded: true,
},
showMuteButton: {
type: OptionType.BOOLEAN,
default: true,
description: "Whether you want to display the mute button",
restartNeeded: true,
},
showDeafenButton: {
type: OptionType.BOOLEAN,
default: true,
description: "Whether you want to display the deafen button",
restartNeeded: true,
},
muteSoundboard: {
type: OptionType.BOOLEAN,
default: true,
description: "Toggles their soundboard upon clicking deafen button.",
restartNeeded: false,
},
disableVideo: {
type: OptionType.BOOLEAN,
default: true,
description: "Toggles their video upon clicking deafen button.",
restartNeeded: false,
},
showButtonsSelf: {
type: OptionType.SELECT,
default: "display",
description: "Whether you want to display buttons for your own user. Same functionality as other button(s) except it'll open DMs panel and mute/deafen for yourself upon clicking button(s).",
restartNeeded: true,
options: [
{ label: "Display", value: "display" },
{ label: "Hide", value: "hide" },
{ label: "Disable", value: "disable" },
],
},
whichNameToShow: {
type: OptionType.SELECT,
default: "both",
description: "Choose whether to show nickname or username in tooltip.",
restartNeeded: false,
options: [
{ label: "Global Name", value: "global" },
{ label: "Default Username", value: "username" },
{ label: "Both", value: "both" },
],
}
});

View file

@ -1,3 +1,7 @@
div[class^='list_'][class*='collapsed_'] .click-to-chat-btn {
.voice-user-buttons {
flex: none !important;
}
div[class^='list_'][class*='collapsed_'] .voice-user-buttons {
display: none;
}

View file

@ -6,44 +6,42 @@
import "./styles.css";
import { openPrivateChannel } from "@utils/discord";
import { User } from "@vencord/discord-types";
import { findStoreLazy } from "@webpack";
import { Button, MediaEngineStore, Tooltip, UserStore, VoiceActions } from "@webpack/common";
import { Icon, User } from "@vencord/discord-types";
import { findComponentByCodeLazy, findStoreLazy } from "@webpack";
import { Button, ChannelStore, MediaEngineStore, NavigationRouter, Tooltip, UserStore, VoiceActions } from "@webpack/common";
import { JSX } from "react";
import { settings } from ".";
import { settings } from "./settings";
const SoundboardStore = findStoreLazy("SoundboardStore");
const DeafenIconSelf = findComponentByCodeLazy("M22.7 2.7a1", "1.4l20-20ZM17") as Icon;
const DeafenIconOther = findComponentByCodeLazy("M21.76.83a5.02", "M12.38") as Icon;
const ChatIcon = findComponentByCodeLazy(".css,d:\"M12 22a10") as Icon;
const MuteIconSelf = findComponentByCodeLazy("d:\"m2.7 22.7 20-20a1", "1.4ZM10.8") as Icon;
const MuteIconOther = findComponentByCodeLazy("M21.76.83a5.02", "M12 2c.33 0") as Icon;
export function UserChatButton({ user }: { user: User; }) {
if (user.id === UserStore.getCurrentUser().id) return null;
function VoiceUserButton({ user, tooltip, icon, onClick }: { user: User; tooltip: string; icon: JSX.Element; onClick: () => void; }) {
const isCurrent = (user.id === UserStore.getCurrentUser().id);
if (isCurrent && settings.store.showButtonsSelf === "hide") return null;
const disabled = isCurrent && settings.store.showButtonsSelf === "disable";
return (
<div className="click-to-chat-container">
<Tooltip text={`Open chat with ${user.globalName} / @${user.username}`}>
<div className="voice-user-button-container">
<Tooltip text={tooltip} shouldShow={!disabled}>
{({ onMouseEnter, onMouseLeave }) => (
<Button
size={Button.Sizes.MIN}
color={Button.Colors.TRANSPARENT}
look={Button.Looks.BLANK}
disabled={disabled}
onClick={e => {
e.preventDefault();
e.stopPropagation();
openPrivateChannel({ recipientIds: [user.id] } as any);
onClick();
}}
disabled={user.id === UserStore.getCurrentUser().id}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<svg
className="click-to-chat-button"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="16"
height="16"
fill="var(--channels-default)"
>
<path d="M12 22a10 10 0 1 0-8.45-4.64c.13.19.11.44-.04.61l-2.06 2.37A1 1 0 0 0 2.2 22H12Z"></path>
</svg>
{icon}
</Button>
)}
</Tooltip>
@ -51,125 +49,83 @@ export function UserChatButton({ user }: { user: User; }) {
);
}
function getUserName(user: User) {
const username = `@${user.username}`;
if (settings.store.whichNameToShow === "global") {
return user.globalName ?? username;
} else if (settings.store.whichNameToShow === "nickname") {
return username;
} else if (settings.store.whichNameToShow === "both" && user.globalName) {
return `${user.globalName} / ${username}`;
}
return username;
}
export function UserChatButton({ user }: { user: User; }) {
const isCurrent = (user.id === UserStore.getCurrentUser().id);
return (
<VoiceUserButton
user={user}
tooltip={isCurrent ? "Navigate to DMs" : `Open DMs with ${getUserName(user)}`}
icon={<ChatIcon size="sm" />}
onClick={() => {
if (isCurrent) {
NavigationRouter.transitionTo("/users/@me/");
return;
}
const chanId = ChannelStore.getDMFromUserId(user.id);
NavigationRouter.transitionTo(`/channels/@me/${chanId}/`);
}}
/>
);
}
export function UserMuteButton({ user }: { user: User; }) {
if (user.id === UserStore.getCurrentUser().id) return null;
const isCurrent = (user.id === UserStore.getCurrentUser().id);
const isMuted = (isCurrent && MediaEngineStore.isSelfMute()) || MediaEngineStore.isLocalMute(user.id);
const color = isMuted ? "var(--status-danger)" : "var(--channels-default)";
return (
<div className="click-to-chat-container">
<Tooltip text={`${MediaEngineStore.isLocalMute(user.id) ? "Unmute" : "Mute"} ${user.globalName} / @${user.username}`}>
{({ onMouseEnter, onMouseLeave }) => (
<Button
size={Button.Sizes.MIN}
color={Button.Colors.TRANSPARENT}
look={Button.Looks.BLANK}
onClick={e => {
e.preventDefault();
e.stopPropagation();
VoiceActions.toggleLocalMute(user.id);
}}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<svg
className="click-to-chat-button"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="16"
height="16"
fill={MediaEngineStore.isLocalMute(user.id) ? "red" : "var(--channels-default)"}
>
<path d="M10.78 17.22c-.25.25-.13.67.22.72V20H9a1 1 0 1 0 0 2h6a1 1 0 1 0 0-2h-2v-2.06A8 8 0 0 0 20 10a1 1 0 1 0-2 0 6 6 0 0 1-5.8 6 .52.52 0 0 0-.35.15l-1.07 1.07ZM15.4 4.6a.46.46 0 0 0 .1-.56 4 4 0 0 0-4.03-2c-.4.05-.47.57-.21.89A3.22 3.22 0 0 1 9.9 8l-1.16.43a.5.5 0 0 0-.3.3L8.01 9.9 8 9.94V10c0 .33.04.66.12.96.08.34.5.42.74.18L15.4 4.6ZM5.1 14.07c.17.26.53.3.75.08l.75-.75c.16-.16.19-.41.08-.62a5.99 5.99 0 0 1-.24-.51.62.62 0 0 0-.7-.35c-.23.05-.47.08-.7.08-.37 0-.7.34-.58.7.18.47.4.93.65 1.37Z"></path>
<path d="M9.2 3.86a1.21 1.21 0 0 1 0 2.28l-1.37.5a2 2 0 0 0-1.18 1.19l-.51 1.38a1.21 1.21 0 0 1-2.28 0l-.5-1.38a2 2 0 0 0-1.19-1.18L.79 6.14a1.21 1.21 0 0 1 0-2.28l1.38-.5a2 2 0 0 0 1.18-1.19L3.86.79a1.21 1.21 0 0 1 2.28 0l.5 1.38a2 2 0 0 0 1.19 1.18l1.38.51Z"></path>
<path
fillRule="evenodd"
d="M22.7 1.3a1 1 0 0 1 0 1.4l-20 20a1 1 0 0 1-1.4-1.4l20-20a1 1 0 0 1 1.4 0Z"
clipRule="evenodd"
></path>
</svg>
</Button>
)}
</Tooltip>
</div>
);
}
function DeafenIcon({ user, ...props }) {
const isDeafened =
MediaEngineStore.isLocalMute(user.id) &&
SoundboardStore.isLocalSoundboardMuted(user.id) &&
MediaEngineStore.isLocalVideoDisabled(user.id);
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={16}
height={16}
fill="none"
viewBox="0 0 24 24"
className={""}
{...props}
>
<path
fill={isDeafened ? "red" : "var(--channels-default)"}
fillRule="evenodd"
d="M21.76.83a5.02 5.02 0 0 1 .78 7.7 5 5 0 0 1-7.07 0 5.02 5.02 0 0 1 0-7.07 5 5 0 0 1 6.29-.63Zm-4.88 2.05a3 3 0 0 1 3.41-.59l-4 4a3 3 0 0 1 .59-3.41Zm4.83.83-4 4a3 3 0 0 0 4-4Z"
clipRule="evenodd"
className={""}
/>
<path
fill={isDeafened ? "red" : "var(--channels-default)"}
d="M12.38 1c.38.02.58.45.4.78-.15.3-.3.62-.4.95A.4.4 0 0 1 12 3a9 9 0 0 0-8.95 10h1.87a5 5 0 0 1 4.1 2.13l1.37 1.97a3.1 3.1 0 0 1-.17 3.78 2.85 2.85 0 0 1-3.55.74 11 11 0 0 1 5.71-20.61ZM22.22 11.22c.34-.18.76.02.77.4L23 12a11 11 0 0 1-5.67 9.62c-1.27.71-2.73.23-3.55-.74a3.1 3.1 0 0 1-.17-3.78l1.38-1.97a5 5 0 0 1 4.1-2.13h1.86c.03-.33.05-.66.05-1a.4.4 0 0 1 .27-.38c.33-.1.65-.25.95-.4Z"
className={""}
/>
{isDeafened && (
<line
x1="22"
y1="2"
x2="2"
y2="22"
stroke="var(--status-danger)"
strokeWidth="2"
/>
)}
</svg>
<VoiceUserButton
user={user}
tooltip={`${isMuted ? "Unmute" : "Mute"} ${isCurrent ? "yourself" : `${getUserName(user)}`}`}
icon={isCurrent ? <MuteIconSelf muted={isMuted} size="sm" color={color} /> : <MuteIconOther muted={isMuted} size="sm" color={color} />}
onClick={() => {
if (isCurrent) {
VoiceActions.toggleSelfMute();
return;
}
VoiceActions.toggleLocalMute(user.id);
}}
/>
);
}
export function UserDeafenButton({ user }: { user: User; }) {
if (user.id === UserStore.getCurrentUser().id) return null;
const isDeafened =
MediaEngineStore.isLocalMute(user.id) &&
SoundboardStore.isLocalSoundboardMuted(user.id) &&
MediaEngineStore.isLocalVideoDisabled(user.id);
const isCurrent = (user.id === UserStore.getCurrentUser().id);
const isDeafened = isCurrent && MediaEngineStore.isSelfDeaf() || MediaEngineStore.isLocalMute(user.id) && SoundboardStore.isLocalSoundboardMuted(user.id) && MediaEngineStore.isLocalVideoDisabled(user.id);
const color = isDeafened ? "var(--status-danger)" : "var(--channels-default)";
return (
<div className="click-to-deafen-container">
<Tooltip text={`${isDeafened ? "Undeafen" : "Deafen"} ${user.globalName} / @${user.username}`}>
{({ onMouseEnter, onMouseLeave }) => (
<Button
size={Button.Sizes.MIN}
color={Button.Colors.TRANSPARENT}
look={Button.Looks.BLANK}
onClick={e => {
e.preventDefault();
e.stopPropagation();
VoiceActions.toggleLocalMute(user.id);
if (settings.store.muteSoundboard) {
VoiceActions.toggleLocalSoundboardMute(user.id);
}
if (settings.store.disableVideo) {
VoiceActions.setDisableLocalVideo(
user.id,
MediaEngineStore.isLocalVideoDisabled(user.id) ? "ENABLED" : "DISABLED",
"default"
);
}
}}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<DeafenIcon user={user} width={16} height={16} />
</Button>
)}
</Tooltip>
</div>
<VoiceUserButton
user={user}
tooltip={`${isDeafened ? "Undeafen" : "Deafen"} ${isCurrent ? "yourself" : `${getUserName(user)}`}`}
icon={isCurrent ? <DeafenIconSelf muted={isDeafened} size="sm" color={color} /> : <DeafenIconOther muted={isDeafened} size="sm" color={color} />}
onClick={() => {
if (isCurrent) {
VoiceActions.toggleSelfDeaf();
return;
}
VoiceActions.toggleLocalMute(user.id);
if (settings.store.muteSoundboard) {
VoiceActions.toggleLocalSoundboardMute(user.id);
}
if (settings.store.disableVideo) {
VoiceActions.setDisableLocalVideo(
user.id,
MediaEngineStore.isLocalVideoDisabled(user.id) ? "ENABLED" : "DISABLED",
"default"
);
}
}}
/>
);
}

View file

@ -65,6 +65,15 @@ export default definePlugin({
}
]
},
{
find: ".DEVELOPER_SECTION,",
replacement: [
{
match: /\i\.\i\.isDeveloper/,
replace: "true"
},
]
},
{
find: "#{intl::USER_SETTINGS_ACTIONS_MENU_LABEL}",
replacement: {

View file

@ -56,6 +56,12 @@ const settings = definePluginSettings({
default: false,
restartNeeded: false
},
allowAutoModMessages: {
description: "Allow messages sent by AutoMod to bypass filtering.",
type: OptionType.BOOLEAN,
default: true,
restartNeeded: false,
},
hideBlockedUserReplies: {
description: "Hide replies to blocked/ignored users.",
type: OptionType.BOOLEAN,
@ -121,6 +127,7 @@ export default definePlugin({
const blocked = this.isBlocked(message);
const replyToBlocked = this.isReplyToBlocked(message);
if (message.type === 24 && settings.store.allowAutoModMessages) return [true, blocked];
if (blocked) return [this.hideBlockedMessage(message.author.id), true];
if (replyToBlocked) return [this.hideBlockedMessage(replyToBlocked.author.id), true];
@ -140,7 +147,7 @@ export default definePlugin({
},
filterStream(channelStream: [ChannelStreamGroupProps | ChannelStreamMessageProps | ChannelStreamDividerProps]) {
const { alsoHideIgnoredUsers, disableNotifications, hideBlockedUserReplies, defaultHideUsers, overrideUsers } = settings.use();
const { alsoHideIgnoredUsers, disableNotifications, hideBlockedUserReplies, allowAutoModMessages, defaultHideUsers, overrideUsers } = settings.use();
const newChannelStream = channelStream.map(item => {
if (item.type === "MESSAGE_GROUP_BLOCKED" || (item.type === "MESSAGE_GROUP_IGNORED" && alsoHideIgnoredUsers)) {
@ -158,9 +165,9 @@ export default definePlugin({
let lastItem = newChannelStream[newChannelStream.length - 1];
// Remove the NEW Message divider if it is the last item,
// implying the messages it was announcing got filtered.
while (lastItem && lastItem.type === "DIVIDER" && lastItem.unreadId !== undefined) {
// Remove the NEW Message and Date dividers if they are the last
// item, implying the messages they were separating got filtered.
while (lastItem && lastItem.type === "DIVIDER") {
newChannelStream.pop();
lastItem = newChannelStream[newChannelStream.length - 1];
}

View file

@ -21,10 +21,10 @@ import "./styles.css";
import { definePluginSettings } from "@api/Settings";
import { classNameFactory } from "@api/Styles";
import ErrorBoundary from "@components/ErrorBoundary";
import { Devs } from "@utils/constants";
import { Devs, EquicordDevs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import { User } from "@vencord/discord-types";
import { DateUtils, RelationshipStore, Text, TooltipContainer } from "@webpack/common";
import { DateUtils, Flex, RelationshipStore, Text, TooltipContainer } from "@webpack/common";
import { PropsWithChildren } from "react";
const formatter = new Intl.DateTimeFormat(undefined, {
@ -50,24 +50,34 @@ const settings = definePluginSettings({
export default definePlugin({
name: "SortFriendRequests",
authors: [Devs.Megu],
authors: [Devs.Megu, EquicordDevs.CallMeGii],
description: "Sorts friend requests by date of receipt",
settings,
patches: [{
find: "getRelationshipCounts(){",
replacement: {
match: /\}\)\.sortBy\((.+?)\)\.value\(\)/,
replace: "}).sortBy(row => $self.wrapSort(($1), row)).value()"
patches: [
{
find: "getRelationshipCounts(){",
replacement: {
match: /\}\)\.sortBy\((.+?)\)\.value\(\)/,
replace: "}).sortBy(row => $self.wrapSort(($1), row)).value()"
}
}, {
find: "#{intl::FRIEND_REQUEST_CANCEL}",
replacement: {
predicate: () => settings.store.showDates,
match: /(?<=\.listItemContents,children:\[)\(0,.+?(?=,\(0)(?<=user:(\i).+?)/,
replace: (children, user) => `$self.WrapperDateComponent({user:${user},children:${children}})`
}
},
{
find: "peopleListItemRef",
replacement: {
predicate: () => settings.store.showDates,
match: /(?<=children:.*user:(\i),.*subText:).+?(?=,hovered:\i,showAccountIdentifier)/,
replace: "$self.makeSubtext($1, $&)"
}
}
}, {
find: "#{intl::FRIEND_REQUEST_CANCEL}",
replacement: {
predicate: () => settings.store.showDates,
match: /(?<=\.listItemContents,children:\[)\(0,.+?(?=,\(0)(?<=user:(\i).+?)/,
replace: (children, user) => `$self.WrapperDateComponent({user:${user},children:${children}})`
}
}],
],
wrapSort(comparator: Function, row: any) {
return row.type === 3 || row.type === 4
@ -75,6 +85,44 @@ export default definePlugin({
: comparator(row);
},
makeSubtext(user: User, origSubtext: any) {
const since = this.getSince(user);
if (isNaN(since.getTime())) {
return null;
}
return (
<Flex
flexDirection="column"
style={{ gap: "0px", flexWrap: "wrap", lineHeight: "0.9rem" }}
>
<span>{origSubtext}</span>
<span>
<div className="" style={{ display: "flex", alignItems: "center" }}>
<svg
aria-hidden="true"
role="img"
xmlns="http://www.w3.org/2000/svg"
width="8"
height="8"
fill="none"
viewBox="0 0 24 24"
style={{ marginRight: "4px", display: "inline-block" }}
>
<path
fill="var(--input-placeholder-text)"
fillRule="evenodd"
d="M12 23a11 11 0 1 0 0-22 11 11 0 0 0 0 22Zm1-18a1 1 0 1 0-2 0v7c0 .27.1.52.3.7l3 3a1 1 0 0 0 1.4-1.4L13 11.58V5Z"
clipRule="evenodd"
></path>
</svg>
<span>Added &mdash; {since.toDateString()}</span>
</div>
</span>
</Flex>
);
},
WrapperDateComponent: ErrorBoundary.wrap(({ user, children }: PropsWithChildren<{ user: User; }>) => {
const since = getSince(user);

View file

@ -19,15 +19,13 @@
import { definePluginSettings } from "@api/Settings";
import { Flex } from "@components/Flex";
import { DeleteIcon } from "@components/Icons";
import { Devs } from "@utils/constants";
import { Devs, EquicordDevs } from "@utils/constants";
import { Logger } from "@utils/Logger";
import definePlugin, { OptionType } from "@utils/types";
import { Button, Forms, React, TextInput, useState } from "@webpack/common";
import { Message } from "@vencord/discord-types";
import { Button, Forms, React, Select, TextInput, UserStore, useState } from "@webpack/common";
const STRING_RULES_KEY = "TextReplace_rulesString";
const REGEX_RULES_KEY = "TextReplace_rulesRegex";
type Rule = Record<"find" | "replace" | "onlyIfIncludes", string>;
type Rule = Record<"find" | "replace" | "onlyIfIncludes" | "scope", string>;
interface TextReplaceProps {
title: string;
@ -37,7 +35,8 @@ interface TextReplaceProps {
const makeEmptyRule: () => Rule = () => ({
find: "",
replace: "",
onlyIfIncludes: ""
onlyIfIncludes: "",
scope: "myMessages"
});
const makeEmptyRuleArray = () => [makeEmptyRule()];
@ -65,12 +64,12 @@ const settings = definePluginSettings({
stringRules: {
type: OptionType.CUSTOM,
default: makeEmptyRuleArray(),
description: "",
description: "Rules for replacing text using string matching."
},
regexRules: {
type: OptionType.CUSTOM,
default: makeEmptyRuleArray(),
description: "",
description: "Rules for replacing text using regular expressions."
}
});
@ -138,10 +137,16 @@ function TextReplace({ title, rulesArray }: TextReplaceProps) {
}
}
const scopeOptions = [
{ label: "Apply to your messages (visible to everyone)", value: "myMessages" },
{ label: "Apply to others' messages (only visible to you)", value: "othersMessages" },
{ label: "Apply to all messages", value: "allMessages" }
];
return (
<>
<Forms.FormTitle tag="h4">{title}</Forms.FormTitle>
<Flex flexDirection="column" style={{ gap: "0.5em" }}>
<Flex flexDirection="column" style={{ gap: "0.5em", paddingBottom: "1.25em" }}>
{
rulesArray.map((rule, index) =>
<React.Fragment key={`${rule.find}-${index}`}>
@ -163,24 +168,25 @@ function TextReplace({ title, rulesArray }: TextReplaceProps) {
onChange={e => onChange(e, index, "onlyIfIncludes")}
/>
</Flex>
</Flex>
{(index !== rulesArray.length - 1) && <Flex flexDirection="row" style={{ gap: "0.5em" }}>
<div style={{ flex: 0.9 }}>
<Select
options={scopeOptions}
isSelected={e => e === rule.scope}
select={e => onChange(e, index, "scope")}
serialize={e => e}
/>
</div>
<Button
size={Button.Sizes.MIN}
onClick={() => onClickRemove(index)}
style={{
background: "none",
color: "var(--status-danger)",
...(index === rulesArray.length - 1
? {
visibility: "hidden",
pointerEvents: "none"
}
: {}
)
}}
style={{ flex: 0.10, backgroundColor: "var(--input-background)", border: "1px solid var(--input-border)", color: "var(--status-danger)" }}
>
<DeleteIcon />
<DeleteIcon style={{ verticalAlign: "middle" }} />
</Button>
</Flex>
</Flex>}
{(index !== rulesArray.length - 1) && <Forms.FormDivider style={{ width: "unset", margin: "0.5em 0" }}></Forms.FormDivider>}
{isRegexRules && renderFindError(rule.find)}
</React.Fragment>
)
@ -196,12 +202,12 @@ function TextReplaceTesting() {
<>
<Forms.FormTitle tag="h4">Test Rules</Forms.FormTitle>
<TextInput placeholder="Type a message" onChange={setValue} />
<TextInput placeholder="Message with rules applied" editable={false} value={applyRules(value)} />
<TextInput placeholder="Message with rules applied" editable={false} value={applyRules(value, "allMessages")} />
</>
);
}
function applyRules(content: string): string {
function applyRules(content: string, scope: "myMessages" | "othersMessages" | "allMessages"): string {
if (content.length === 0) {
return content;
}
@ -209,6 +215,7 @@ function applyRules(content: string): string {
for (const rule of settings.store.stringRules) {
if (!rule.find) continue;
if (rule.onlyIfIncludes && !content.includes(rule.onlyIfIncludes)) continue;
if (rule.scope !== "allMessages" && rule.scope !== scope && scope !== "allMessages") continue;
content = ` ${content} `.replaceAll(rule.find, rule.replace.replaceAll("\\n", "\n")).replace(/^\s|\s$/g, "");
}
@ -216,6 +223,7 @@ function applyRules(content: string): string {
for (const rule of settings.store.regexRules) {
if (!rule.find) continue;
if (rule.onlyIfIncludes && !content.includes(rule.onlyIfIncludes)) continue;
if (rule.scope !== "allMessages" && rule.scope !== scope && scope !== "allMessages") continue;
try {
const regex = stringToRegex(rule.find);
@ -229,18 +237,49 @@ function applyRules(content: string): string {
return content;
}
const TEXT_REPLACE_RULES_CHANNEL_ID = "1102784112584040479";
function modifyIncomingMessage(message: Message) {
const currentUser = UserStore.getCurrentUser();
const messageAuthor = message.author;
if (!message.content || !currentUser?.id || !messageAuthor?.id || messageAuthor.id === currentUser.id) {
return message.content;
}
return applyRules(message.content, "othersMessages");
}
const TEXT_REPLACE_RULES_EXEMPT_CHANNEL_IDS = [
"1102784112584040479", // Vencord's Text Replace Rules Channel
"1419347113745059961", // Equicord's Requests Channel
];
export default definePlugin({
name: "TextReplace",
description: "Replace text in your messages. You can find pre-made rules in the #textreplace-rules channel in Vencord's Server",
authors: [Devs.AutumnVN, Devs.TheKodeToad],
description: "Replace text in your or others' messages. You can find pre-made rules in the #textreplace-rules channel in Vencord's Server.",
authors: [Devs.AutumnVN, Devs.TheKodeToad, EquicordDevs.Etorix],
settings,
modifyIncomingMessage,
patches: [
{
find: "!1,hideSimpleEmbedContent",
replacement: {
match: /(let{toAST:.{0,125}?)\(null!=\i\?\i:\i\).content/,
replace: "const textReplaceContent=$self.modifyIncomingMessage(arguments[2]?.contentMessage??arguments[1]);$1textReplaceContent"
}
},
],
start() {
const { stringRules, regexRules } = settings.store;
stringRules.forEach(rule => { if (!rule.scope) rule.scope = "myMessages"; });
regexRules.forEach(rule => { if (!rule.scope) rule.scope = "myMessages"; });
},
onBeforeMessageSend(channelId, msg) {
// Channel used for sharing rules, applying rules here would be messy
if (channelId === TEXT_REPLACE_RULES_CHANNEL_ID) return;
msg.content = applyRules(msg.content);
// Replacing text in channels used for sharing/requesting rules may be messy.
if (TEXT_REPLACE_RULES_EXEMPT_CHANNEL_IDS.includes(channelId)) return;
msg.content = applyRules(msg.content, "myMessages");
}
});

View file

@ -68,7 +68,7 @@ function TypingIndicator({ channelId, guildId }: { channelId: string; guildId: s
const myId = UserStore.getCurrentUser()?.id;
const typingUsersArray = Object.keys(typingUsers).filter(id =>
id !== myId && !(RelationshipStore.isBlocked(id) && !settings.store.includeBlockedUsers)
id !== myId && !(RelationshipStore.isBlocked(id) && !settings.store.includeBlockedUsers) && !(RelationshipStore.isIgnored(id) && !settings.store.includeIgnoredUsers)
);
const [a, b, c] = typingUsersArray;
let tooltipText: string;
@ -145,6 +145,11 @@ const settings = definePluginSettings({
description: "Whether to show the typing indicator for muted channels.",
default: false
},
includeIgnoredUsers: {
type: OptionType.BOOLEAN,
description: "Whether to show the typing indicator for ignored users.",
default: false
},
includeBlockedUsers: {
type: OptionType.BOOLEAN,
description: "Whether to show the typing indicator for blocked users.",
@ -168,17 +173,16 @@ export default definePlugin({
settings,
patches: [
// Normal channel
{
// Normal channel.
find: "UNREAD_IMPORTANT:",
replacement: {
match: /\.Children\.count.+?:null(?<=,channel:(\i).+?)/,
replace: "$&,$self.TypingIndicator($1.id,$1.getGuildId())"
}
},
// Theads
{
// This is the thread "spine" that shows in the left
// Thread "spine" that shows in the left.
find: "M0 15H2c0 1.6569",
replacement: {
match: /mentionsCount:\i.+?null(?<=channel:(\i).+?)/,

View file

@ -1152,7 +1152,15 @@ export const EquicordDevs = Object.freeze({
nicola02nb: {
name: "nicola02nb",
id: 257900031351193600n
}
},
qouesm: {
name: "qouesm",
id: 130388483494641664n,
},
CallMeGii: {
name: "gii",
id: 156481332652802048n,
},
} satisfies Record<string, Dev>);
// iife so #__PURE__ works correctly