diff --git a/src/equicordplugins/jellyfinRichPresence/index.tsx b/src/equicordplugins/jellyfinRichPresence/index.tsx index 4de24d4f5..a8e2e05d6 100644 --- a/src/equicordplugins/jellyfinRichPresence/index.tsx +++ b/src/equicordplugins/jellyfinRichPresence/index.tsx @@ -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, }; diff --git a/src/equicordplugins/listenBrainzRPC/index.tsx b/src/equicordplugins/listenBrainzRPC/index.tsx new file mode 100644 index 000000000..5f87751d5 --- /dev/null +++ b/src/equicordplugins/listenBrainzRPC/index.tsx @@ -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 { + 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: () => ( + <> + About MusicBrainz API + + The MusicBrainz API does not require an API key, but it does require a{" "} + + {" "} + meaningful user-agent string + {" "} + . For most, an email address should suffice. + + + ), + + settings, + + start() { + this.updatePresence(); + this.updateInterval = setInterval(() => { + this.updatePresence(); + }, 16000); + }, + + stop() { + clearInterval(this.updateInterval); + }, + + async fetchTrackData(): Promise { + 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 { + 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, + }; + }, +}); diff --git a/src/equicordplugins/questify/utils/components.tsx b/src/equicordplugins/questify/utils/components.tsx index d182e35e0..933163320 100644 --- a/src/equicordplugins/questify/utils/components.tsx +++ b/src/equicordplugins/questify/utils/components.tsx @@ -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 { diff --git a/src/equicordplugins/voiceButtons/index.tsx b/src/equicordplugins/voiceButtons/index.tsx index dfe0bfca7..f64bdbfdb 100644 --- a/src/equicordplugins/voiceButtons/index.tsx +++ b/src/equicordplugins/voiceButtons/index.tsx @@ -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 ( -
+ {settings.store.showChatButton && } {settings.store.showMuteButton && } {settings.store.showDeafenButton && } -
+ ); } }); diff --git a/src/equicordplugins/voiceButtons/settings.ts b/src/equicordplugins/voiceButtons/settings.ts new file mode 100644 index 000000000..fb7a3cfcb --- /dev/null +++ b/src/equicordplugins/voiceButtons/settings.ts @@ -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" }, + ], + } +}); diff --git a/src/equicordplugins/voiceButtons/styles.css b/src/equicordplugins/voiceButtons/styles.css index 4dcda501c..6fe3882df 100644 --- a/src/equicordplugins/voiceButtons/styles.css +++ b/src/equicordplugins/voiceButtons/styles.css @@ -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; } \ No newline at end of file diff --git a/src/equicordplugins/voiceButtons/utils.tsx b/src/equicordplugins/voiceButtons/utils.tsx index 63f5e41bf..5422e16be 100644 --- a/src/equicordplugins/voiceButtons/utils.tsx +++ b/src/equicordplugins/voiceButtons/utils.tsx @@ -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 ( -
- +
+ {({ onMouseEnter, onMouseLeave }) => ( )} @@ -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 ( + } + 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 ( -
- - {({ onMouseEnter, onMouseLeave }) => ( - - )} - -
- ); -} - -function DeafenIcon({ user, ...props }) { - const isDeafened = - MediaEngineStore.isLocalMute(user.id) && - SoundboardStore.isLocalSoundboardMuted(user.id) && - MediaEngineStore.isLocalVideoDisabled(user.id); - - return ( - - - - {isDeafened && ( - - )} - + : } + 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 ( -
- - {({ onMouseEnter, onMouseLeave }) => ( - - )} - -
+ : } + 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" + ); + } + }} + /> ); } diff --git a/src/plugins/_core/settings.tsx b/src/plugins/_core/settings.tsx index 665687b39..974cf7d4a 100644 --- a/src/plugins/_core/settings.tsx +++ b/src/plugins/_core/settings.tsx @@ -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: { diff --git a/src/plugins/noBlockedMessages/index.ts b/src/plugins/noBlockedMessages/index.ts index 951ba2a6e..3532e7444 100644 --- a/src/plugins/noBlockedMessages/index.ts +++ b/src/plugins/noBlockedMessages/index.ts @@ -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]; } diff --git a/src/plugins/sortFriendRequests/index.tsx b/src/plugins/sortFriendRequests/index.tsx index 42af9a3af..d53e6edbf 100644 --- a/src/plugins/sortFriendRequests/index.tsx +++ b/src/plugins/sortFriendRequests/index.tsx @@ -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 ( + + {origSubtext} + +
+ + Added — {since.toDateString()} +
+
+
+ ); + }, + WrapperDateComponent: ErrorBoundary.wrap(({ user, children }: PropsWithChildren<{ user: User; }>) => { const since = getSince(user); diff --git a/src/plugins/textReplace/index.tsx b/src/plugins/textReplace/index.tsx index 3b46df9d8..bd975dfa1 100644 --- a/src/plugins/textReplace/index.tsx +++ b/src/plugins/textReplace/index.tsx @@ -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 ( <> {title} - + { rulesArray.map((rule, index) => @@ -163,24 +168,25 @@ function TextReplace({ title, rulesArray }: TextReplaceProps) { onChange={e => onChange(e, index, "onlyIfIncludes")} /> + + {(index !== rulesArray.length - 1) && +
+