diff --git a/src/equicordplugins/recentDMSwitcher/index.tsx b/src/equicordplugins/recentDMSwitcher/index.tsx new file mode 100644 index 000000000..9819dfeda --- /dev/null +++ b/src/equicordplugins/recentDMSwitcher/index.tsx @@ -0,0 +1,374 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import "./styles.css"; + +import * as DataStore from "@api/DataStore"; +import { definePluginSettings } from "@api/Settings"; +import { classNameFactory } from "@api/Styles"; +import { EquicordDevs, IS_MAC } from "@utils/constants"; +import { closeModal, openModal } from "@utils/modal"; +import definePlugin, { OptionType } from "@utils/types"; +import { Button, ChannelRouter, ChannelStore, IconUtils, React, SelectedChannelStore, Toasts, UserStore } from "@webpack/common"; + +const STORAGE_KEY = "RDMSwitch_history"; +const MAX_HISTORY = 50; + +let rmdsDmChannelIds: string[] = []; +let isCyclingSessionActive = false; +let suppressRdmsWhileCycling = false; +let cycleSnapshot: string[] = []; +let cycleIndex = -1; + +const cl = classNameFactory("vc-rdms-"); + +const settings = definePluginSettings({ + visualStyle: { + type: OptionType.SELECT, + description: "Visual indicator style while cycling", + options: [ + { label: "Overlay (Alt+Tab style)", value: "overlay", default: true }, + { label: "Toast (status message)", value: "toast" }, + { label: "Off", value: "off" } + ] + }, + overlayMode: { + type: OptionType.SELECT, + description: "Overlay content", + options: [ + { label: "Row of recent", value: "row", default: true }, + { label: "Current only", value: "current" } + ] + }, + overlayRowLength: { + type: OptionType.SLIDER, + description: "Number of recent DMs to show in row", + markers: [3, 4, 5, 6, 7], + default: 5 + }, + overlayShowAvatars: { + type: OptionType.BOOLEAN, + description: "Show avatars in overlay", + default: true + }, + toastDurationMs: { + type: OptionType.SLIDER, + description: "Toast hide delay (ms)", + markers: [300, 500, 600, 800, 1000, 1500, 2000], + default: 600 + }, + clearRdms: { + type: OptionType.COMPONENT, + description: "Testing utility: Clear RDMS list", + component: () => React.createElement( + Button, + { + color: Button.Colors.RED, + onClick: async () => { + rmdsDmChannelIds = []; + cycleSnapshot = []; + cycleIndex = -1; + await DataStore.set(STORAGE_KEY, []); + Toasts.show({ id: Toasts.genId(), type: Toasts.Type.SUCCESS, message: "Cleared RDMS history" }); + } + }, + "Clear RDMS History" + ) + } +}); + +let activeToastId: string | null = null; +let overlayModalKey: string | null = null; +let overlayRerender: (() => void) | null = null; + +function isDirectMessageChannel(channelId: string | null | undefined): boolean { + if (!channelId) return false; + const channel = ChannelStore.getChannel(channelId); + if (!channel) return false; + try { + // Include 1:1 DMs and Group DMs + return Boolean(channel.isDM() || channel.isGroupDM()); + } catch { + return false; + } +} + +function pushChannelToFront(channelId: string) { + rmdsDmChannelIds = rmdsDmChannelIds.filter(id => id !== channelId); + rmdsDmChannelIds.unshift(channelId); + if (rmdsDmChannelIds.length > MAX_HISTORY) rmdsDmChannelIds.length = MAX_HISTORY; + void DataStore.set(STORAGE_KEY, rmdsDmChannelIds); +} + +function sanitizeHistory(ids: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const id of ids) { + if (!id || seen.has(id)) continue; + if (!ChannelStore.hasChannel(id)) continue; + if (!isDirectMessageChannel(id)) continue; + seen.add(id); + result.push(id); + if (result.length >= MAX_HISTORY) break; + } + return result; +} + +function beginCycleSession() { + if (isCyclingSessionActive) return; + isCyclingSessionActive = true; + suppressRdmsWhileCycling = true; + + const currentId = SelectedChannelStore.getChannelId(); + cycleSnapshot = sanitizeHistory([ + ...(isDirectMessageChannel(currentId) ? [currentId!] : []), + ...rmdsDmChannelIds + ]); + + cycleIndex = 0; +} + +function stepCycle(direction: 1 | -1) { + if (!isCyclingSessionActive || cycleSnapshot.length === 0) return; + const total = cycleSnapshot.length; + if (total <= 1) return; + + cycleIndex = (cycleIndex + direction + total) % total; + const targetId = cycleSnapshot[cycleIndex]; + if (!targetId || !ChannelStore.hasChannel(targetId)) return; + + const vis = (settings as any).store?.visualStyle; + if (vis === "overlay") renderOverlay(); + else if (vis === "toast") showCycleToast(); +} + +function endCycleSession() { + if (!isCyclingSessionActive) return; + isCyclingSessionActive = false; + suppressRdmsWhileCycling = false; + + if (cycleIndex >= 0 && cycleIndex < cycleSnapshot.length) { + const selected = cycleSnapshot[cycleIndex]; + if (selected) { + ChannelRouter.transitionToChannel(selected); + pushChannelToFront(selected); + } + } + + cycleSnapshot = []; + cycleIndex = -1; + activeToastId = null; + + const visEnd = (settings as any).store?.visualStyle; + if (visEnd === "overlay") unmountOverlay(); +} + +function stopEvent(e: KeyboardEvent) { + try { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + } catch { } +} + +function onKeyDown(e: KeyboardEvent) { + const hasCtrl = e.ctrlKey || (IS_MAC && e.metaKey); + if (!hasCtrl) return; + + if (e.key === "Tab") { + stopEvent(e); + if (!isCyclingSessionActive) beginCycleSession(); + stepCycle(e.shiftKey ? -1 : 1); + } +} + +function onKeyUp(e: KeyboardEvent) { + if (e.key === "Control" || (IS_MAC && e.key === "Meta")) { + stopEvent(e); + const stillHeld = (e as any).ctrlKey || (e as any).metaKey; + if (!stillHeld) endCycleSession(); + } +} + +function getDisplayForChannel(id: string) { + const ch = ChannelStore.getChannel(id); + if (!ch) return { + name: "Unknown", + avatar: "" + }; + + if (ch.isDM()) { + const uid = ch.recipients?.[0]; + const user = uid ? UserStore.getUser(uid) : null; + return { name: user?.globalName ?? user?.username ?? "DM", avatar: user ? IconUtils.getUserAvatarURL(user, true, 64) : "" }; + } + + if (ch.isGroupDM()) { + return { name: ch.name ?? "Group DM", avatar: IconUtils.getChannelIconURL?.(ch) ?? "" } as any; + } + + return { name: ch.name ?? "Channel", avatar: "" }; +} + +function openOverlayModal() { + if (overlayModalKey) return; + overlayModalKey = openModal(() => ); +} + +function renderOverlay() { + if (settings.store.visualStyle !== "overlay") return; + if (!isCyclingSessionActive) return; + openOverlayModal(); + overlayRerender?.(); +} + +function unmountOverlay() { + if (!overlayModalKey) return; + closeModal(overlayModalKey); + overlayModalKey = null; +} + +function OverlayContent(): any { + const [, setTick] = React.useState(0); + React.useEffect(() => { + overlayRerender = () => setTick(t => t + 1); + return () => { overlayRerender = null; }; + }, []); + + const mode = settings.store.overlayMode; + const showAvatars = settings.store.overlayShowAvatars; + const maxCount = Math.max(3, Math.min(7, settings.store.overlayRowLength)); + + const pageSize = mode === "current" ? 1 : maxCount; + const totalSlots = mode === "current" ? 1 : pageSize * 2; + const visibleList = mode === "current" + ? [cycleSnapshot[cycleIndex]] + : cycleSnapshot.slice(0, totalSlots); + + let pageCount = 1; + let currentPage = 0; + if (mode !== "current") { + pageCount = visibleList.length > pageSize ? 2 : 1; + currentPage = Math.min(pageCount - 1, Math.floor((cycleIndex >= 0 ? cycleIndex : 0) / pageSize)); + } + + const start = currentPage * pageSize; + const end = Math.min(start + pageSize, visibleList.length); + const pageItems = visibleList.slice(start, end); + + const cards = pageItems.map(id => { + const { name, avatar } = getDisplayForChannel(id!); + const isActive = id === cycleSnapshot[cycleIndex]; + + return ( +
+ {showAvatars && avatar && ( + + )} +
{name}
+
+ ); + }); + + const dots = + pageCount > 1 ? ( +
+ {Array.from({ length: pageCount }).map((_, i) => ( +
+ ))} +
+ ) : null; + + return ( +
+
+
+ {cards} +
+ {dots} +
+
+ ); + +} + +function showCycleToast() { + if (settings.store.visualStyle !== "toast") return; + const id = cycleSnapshot[cycleIndex]; + if (!id) return; + const { name } = getDisplayForChannel(id); + if (!activeToastId) activeToastId = Toasts.genId(); + Toasts.show({ + id: activeToastId, + message: `Switching to: ${name}`, + type: Toasts.Type.MESSAGE, + options: { position: Toasts.Position.BOTTOM, duration: settings.store.toastDurationMs } + }); +} + +export default definePlugin({ + name: "RecentDMSwitcher", + description: "Ctrl+Tab between most recently used DMs (Ctrl+Shift+Tab reverse)", + authors: [EquicordDevs.mmeta], + settings, + + flux: { + GUILD_SELECT({ guildId }: { guildId: string | null; }) { + if (!isCyclingSessionActive) return; + if (guildId) { + const targetId = cycleSnapshot[cycleIndex]; + if (targetId) ChannelRouter.transitionToChannel(targetId); + } + }, + async CHANNEL_SELECT({ channelId }: { channelId: string | null; }) { + if (suppressRdmsWhileCycling) return; + if (!channelId) return; + if (!isDirectMessageChannel(channelId)) return; + pushChannelToFront(channelId); + } + }, + + async start() { + const saved = await DataStore.get(STORAGE_KEY); + rmdsDmChannelIds = Array.isArray(saved) ? sanitizeHistory(saved) : []; + + const current = SelectedChannelStore.getChannelId(); + if (isDirectMessageChannel(current)) pushChannelToFront(current!); + + document.addEventListener("keydown", onKeyDown, true); + document.addEventListener("keyup", onKeyUp, true); + }, + + stop() { + document.removeEventListener("keydown", onKeyDown, true); + document.removeEventListener("keyup", onKeyUp, true); + isCyclingSessionActive = false; + suppressRdmsWhileCycling = false; + cycleSnapshot = []; + cycleIndex = -1; + activeToastId = null; + + const visEnd = (settings as any).store?.visualStyle; + if (visEnd === "overlay") unmountOverlay(); + } +}); + + diff --git a/src/equicordplugins/recentDMSwitcher/styles.css b/src/equicordplugins/recentDMSwitcher/styles.css new file mode 100644 index 000000000..00e029779 --- /dev/null +++ b/src/equicordplugins/recentDMSwitcher/styles.css @@ -0,0 +1,73 @@ +.vc-rdms-background { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + width: 168px; + height: 95px; + border-radius: 10px; + background: var(--background-floating); +} + +.vc-rdms-avatar { + width: 48px; + height: 48px; + border-radius: 50%; + background-size: cover; +} + +.vc-rdms-name { + margin-top: 8px; + color: var(--header-primary); + font-size: 12px; + max-width: 152px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: center; +} + +.vc-rdms-page-indicator-container { + display: flex; + gap: 8px; + align-items: center; + justify-content: center; + margin-top: 10px; +} + +.vc-rdms-page-indicator { + width: 8px; + height: 8px; + border-radius: 50%; +} + +.vc-rdms-overlay-container { + display: grid; + place-items: center; + position: fixed; + inset: 0; + background: rgb(0 0 0 / 38%); + backdrop-filter: blur(2px); + pointer-events: none; +} + +.vc-rdms-cards-container { + display: flex; + flex-direction: column; + gap: 8px; + align-items: center; + justify-content: center; + padding: 12px 16px; + border-radius: 12px; + background: color-mix(in oklab, var(--background-floating) 85%, transparent); + backdrop-filter: saturate(120%); + box-shadow: 0 6px 24px rgb(0 0 0 / 25%); +} + +.vc-rdms-cards { + display: flex; + gap: 12px; + align-items: center; + justify-content: center; +} \ No newline at end of file diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 629333e68..335498418 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1162,6 +1162,10 @@ export const EquicordDevs = Object.freeze({ name: "gii", id: 156481332652802048n, }, + mmeta: { + name: "mmeta", + id: 297075664530440192n + }, } satisfies Record); // iife so #__PURE__ works correctly