diff --git a/src/plugins/dmSearch/README.md b/src/plugins/dmSearch/README.md new file mode 100644 index 0000000000..32920140aa --- /dev/null +++ b/src/plugins/dmSearch/README.md @@ -0,0 +1,7 @@ +# DMSearch + +Vencord plugin that adds global cross DM and Groups message search to the desktop Quick Switcher. + +See results from every DM and group. + +image diff --git a/src/plugins/dmSearch/api/navigation.ts b/src/plugins/dmSearch/api/navigation.ts new file mode 100644 index 0000000000..11299ce920 --- /dev/null +++ b/src/plugins/dmSearch/api/navigation.ts @@ -0,0 +1,53 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { TYPE_DM, TYPE_GROUP_DM } from "@plugins/dmSearch/constants"; +import { ChannelMeta } from "@plugins/dmSearch/types"; +import { findByCode } from "@webpack"; +import { ChannelActionCreators, ChannelStore, FluxDispatcher, NavigationRouter } from "@webpack/common"; + +interface PrivateChannel { + fromServer(api_data: unknown): unknown; +} + +let pc_cache: PrivateChannel | null = null; + +function private_channel(): PrivateChannel | null { + if (pc_cache) return pc_cache; + try { + pc_cache = findByCode("rawRecipients", "recipientFlags", "fromServer") as PrivateChannel; + } catch { } + return pc_cache; +} + +export async function jump_to(channel_id: string, message_id: string, guild_id?: string | null, meta?: ChannelMeta): Promise { + let channel = ChannelStore.getChannel(channel_id); + + if (!channel && ChannelActionCreators?.fetchChannel) { + try { + const data = await ChannelActionCreators.fetchChannel(channel_id) as { type?: number; }; + if (data) { + if (data.type === TYPE_DM || data.type === TYPE_GROUP_DM) { + const pc = private_channel(); + if (pc?.fromServer) { + FluxDispatcher.dispatch({ type: "CHANNEL_CREATE", channel: pc.fromServer(data) }); + channel = ChannelStore.getChannel(channel_id); + } + } else { + FluxDispatcher.dispatch({ type: "CHANNEL_CREATE", channel: data }); + channel = ChannelStore.getChannel(channel_id); + } + } + } catch { } + } + + const target = guild_id ?? channel?.guild_id ?? meta?.guild_id ?? "@me"; + NavigationRouter.transitionTo(`/channels/${target}/${channel_id}/${message_id}`); +} + +export function close_switcher(): void { + FluxDispatcher.dispatch({ type: "QUICKSWITCHER_HIDE" }); +} diff --git a/src/plugins/dmSearch/api/search.ts b/src/plugins/dmSearch/api/search.ts new file mode 100644 index 0000000000..1e4bde548f --- /dev/null +++ b/src/plugins/dmSearch/api/search.ts @@ -0,0 +1,109 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { HARD_LIMIT } from "@plugins/dmSearch/constants"; +import { settings } from "@plugins/dmSearch/settings"; +import { + ChannelMeta, + HasFilter, + MessageHit, + SearchCursor, + SearchTab, + SearchTabsBody, + SearchTabsResponse, + TabRequest, + TabResult, + TabResults +} from "@plugins/dmSearch/types"; +import { RestAPI } from "@webpack/common"; + +const ENDPOINT = "/users/@me/messages/search/tabs"; + +const TAB_FILTERS: Record = { + messages: {}, + media: { has: ["image", "video"] }, + pins: { pinned: true }, + links: { has: ["link"] }, + files: { has: ["file"] } +}; + +let in_flight: AbortController | null = null; + +interface RunOpts { + query: string; + tabs: SearchTab[]; + cursors?: Partial>; + abort?: AbortSignal; +} + +async function run(opts: RunOpts): Promise { + const sort: TabRequest["sort_by"] = settings.store.sortBy === "relevance" ? "relevance" : "timestamp"; + const limit = Math.min(settings.store.limit, HARD_LIMIT); + + const body: SearchTabsBody = { tabs: {}, track_exact_total_hits: false }; + for (const tab of opts.tabs) { + const req: TabRequest = { + sort_by: sort, + sort_order: "desc", + content: opts.query, + cursor: opts.cursors?.[tab] ?? null, + limit + }; + const filter = TAB_FILTERS[tab]; + if (filter.has) req.has = filter.has; + if (filter.pinned) req.pinned = true; + body.tabs[tab] = req; + } + + try { + const res = await RestAPI.post({ + url: ENDPOINT, + body: body as unknown as Record, + oldFormErrors: true, + signal: opts.abort + } as Parameters[0]); + + const data = res.body as SearchTabsResponse; + const out = empty_results(opts.tabs); + for (const tab of opts.tabs) { + const td = data.tabs?.[tab]; + const raw = (td?.messages?.flat?.() ?? []) as MessageHit[]; + out[tab] = { + hits: raw, + cursor: td?.cursor ?? null, + total_results: td?.total_results ?? raw.length + }; + for (const c of td?.channels ?? []) { + if (!out.channels.has(c.id)) out.channels.set(c.id, c); + } + } + return out; + } catch { + return empty_results(opts.tabs); + } +} + +export async function search(query: string, tabs: SearchTab[]): Promise { + in_flight?.abort(); + const ctl = new AbortController(); + in_flight = ctl; + try { + return await run({ query, tabs, abort: ctl.signal }); + } finally { + if (in_flight === ctl) in_flight = null; + } +} + +export async function load_page(query: string, tab: SearchTab, cursor: SearchCursor | null): Promise { + const r = await run({ query, tabs: [tab], cursors: { [tab]: cursor } }); + return r[tab]; +} + +function empty_results(tabs: SearchTab[]): TabResults { + const out = { channels: new Map() } as TabResults; + for (const tab of tabs) out[tab] = { hits: [], cursor: null, total_results: 0 }; + return out; +} diff --git a/src/plugins/dmSearch/components/HitRow.tsx b/src/plugins/dmSearch/components/HitRow.tsx new file mode 100644 index 0000000000..332cdcf86a --- /dev/null +++ b/src/plugins/dmSearch/components/HitRow.tsx @@ -0,0 +1,127 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { close_switcher, jump_to } from "@plugins/dmSearch/api/navigation"; +import { settings } from "@plugins/dmSearch/settings"; +import { ChannelMeta, MessageHit, SearchTab } from "@plugins/dmSearch/types"; +import { avatar_url } from "@plugins/dmSearch/utils/avatar"; +import { channel_info } from "@plugins/dmSearch/utils/channel"; +import { fmt_bytes, fmt_time } from "@plugins/dmSearch/utils/format"; +import { highlight } from "@plugins/dmSearch/utils/highlight"; +import { ChannelStore, UserStore } from "@webpack/common"; + +interface Props { + hit: MessageHit; + query: string; + tab: SearchTab; + channel_meta: ChannelMeta | undefined; + on_keep_open: () => void; +} + +export function HitRow({ hit, query, tab, channel_meta, on_keep_open }: Props) { + const channel = ChannelStore.getChannel(hit.channel_id); + const me = UserStore.getCurrentUser?.(); + const is_self = !!me && hit.author?.id === me.id; + const info = channel_info(hit.channel_id, channel_meta); + const author = hit.author?.global_name || hit.author?.username || "Unknown"; + const is_bot = !!hit.author?.bot; + + const open = () => { + if (settings.store.keepOpenAfterJump) { + on_keep_open(); + } else { + close_switcher(); + } + void jump_to(hit.channel_id, hit.id, channel?.guild_id, channel_meta); + }; + + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + }} + onClick={e => { + e.preventDefault(); + e.stopPropagation(); + open(); + }} + > + +
+
+ {author} + {info.kind === "dm" && DM} + {info.kind === "dm" && is_self && to {info.target}} + {info.kind === "group" && GROUP} + {info.kind === "group" && {info.target}} + {info.kind === "server" && {info.target}} + {info.kind === "server" && info.server && {info.server}} + {is_bot && BOT} + {fmt_time(hit.timestamp)} +
+ +
+
+ ); +} + +function Body({ hit, query, tab }: { hit: MessageHit; query: string; tab: SearchTab; }) { + if (tab === "media") return ; + if (tab === "files") return ; + return ; +} + +function TextBody({ content, query }: { content: string; query: string; }) { + if (!content) { + return
[no text]
; + } + return
{highlight(content, query)}
; +} + +function MediaBody({ hit, query }: { hit: MessageHit; query: string; }) { + const items = (hit.attachments ?? []).filter(a => + a.content_type?.startsWith?.("image/") || a.content_type?.startsWith?.("video/") + ); + return ( +
+ {items.length > 0 && ( +
+ {items.slice(0, 4).map(a => a.content_type?.startsWith?.("video/") + ?
+ )} + {hit.content && } +
+ ); +} + +function FilesBody({ hit, query }: { hit: MessageHit; query: string; }) { + const files = (hit.attachments ?? []).filter(f => + !f.content_type?.startsWith?.("image/") + && !f.content_type?.startsWith?.("video/") + && !f.content_type?.startsWith?.("audio/") + ); + return ( +
+ {files.map(f => ( +
+ {f.filename ?? "file"} + {`${f.content_type ?? "file"} · ${fmt_bytes(f.size ?? 0)}`} +
+ ))} + {hit.content && } +
+ ); +} \ No newline at end of file diff --git a/src/plugins/dmSearch/components/InlinePreview.tsx b/src/plugins/dmSearch/components/InlinePreview.tsx new file mode 100644 index 0000000000..29524190f6 --- /dev/null +++ b/src/plugins/dmSearch/components/InlinePreview.tsx @@ -0,0 +1,98 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { SCROLL_SAVE_MS } from "@plugins/dmSearch/constants"; +import { get_scroll, save_scroll } from "@plugins/dmSearch/state"; +import { ChannelMeta, MessageHit } from "@plugins/dmSearch/types"; +import { ReactDOM, useEffect, useState } from "@webpack/common"; + +import { HitRow } from "./HitRow"; + +const SCROLLER_SELECTOR = "[class^='quickswitcher_'] [class^='resultsArea_'] [class^='scroller_']"; + +interface Props { + query: string; + hits: MessageHit[]; + total: number; + limit: number; + channels: Map; + on_show_all: () => void; + on_keep_open: () => void; +} + +export function InlinePreview({ query, hits, total, limit, channels, on_show_all, on_keep_open }: Props) { + const [target, set_target] = useState(null); + + useEffect(() => { + let canceled = false; + let attempts = 0; + const find = () => { + if (canceled) return; + const node = document.querySelector(SCROLLER_SELECTOR) as HTMLElement | null; + if (node) { + set_target(node); + const saved = get_scroll("all"); + if (saved > 0) requestAnimationFrame(() => { node.scrollTop = saved; }); + return; + } + if (++attempts < 20) window.setTimeout(find, 50); + }; + find(); + return () => { canceled = true; }; + }, []); + + useEffect(() => { + if (!target) return; + let timer: number | null = null; + const on_scroll = () => { + if (timer != null) clearTimeout(timer); + timer = window.setTimeout(() => save_scroll("all", target.scrollTop), SCROLL_SAVE_MS); + }; + target.addEventListener("scroll", on_scroll, { passive: true }); + return () => { + target.removeEventListener("scroll", on_scroll); + if (timer != null) clearTimeout(timer); + }; + }, [target]); + + if (!hits.length || !target) return null; + const shown = hits.slice(0, limit); + + return ReactDOM.createPortal( +
+
+ Messages + {total} +
+
+ {shown.map(hit => ( + + ))} +
+ {total > shown.length && ( + + )} +
, + target + ); +} diff --git a/src/plugins/dmSearch/components/Overlay.tsx b/src/plugins/dmSearch/components/Overlay.tsx new file mode 100644 index 0000000000..7cd114aba5 --- /dev/null +++ b/src/plugins/dmSearch/components/Overlay.tsx @@ -0,0 +1,182 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { search } from "@plugins/dmSearch/api/search"; +import { DEBOUNCE_MS, MIN_QUERY_LENGTH, SEARCHABLE_TABS } from "@plugins/dmSearch/constants"; +import { settings } from "@plugins/dmSearch/settings"; +import { load_session, save_session } from "@plugins/dmSearch/state"; +import { Bag, MessageHit, SearchCursor, SearchTab, TabKey } from "@plugins/dmSearch/types"; +import { FluxDispatcher, useCallback, useEffect, useRef, useState } from "@webpack/common"; +import type { ReactNode } from "react"; + +import { InlinePreview } from "./InlinePreview"; +import { TabContent } from "./TabContent"; +import { Tabs } from "./Tabs"; + +interface Props { + query: string; + discord_matches: number; + default_results: ReactNode; + protip: ReactNode; + tutorial: ReactNode; +} + +const empty_bag = (): Bag => ({ + hits: { messages: [], media: [], pins: [], links: [], files: [] }, + cursors: { messages: null, media: null, pins: null, links: null, files: null }, + totals: { messages: 0, media: 0, pins: 0, links: 0, files: 0 }, + channels: new Map() +}); + +function take_initial() { + if (!settings.store.restoreLastSession) return null; + return load_session(); +} + +export function Overlay({ query: raw_query, discord_matches, default_results, protip, tutorial }: Props) { + const { sortBy, limit } = settings.use(["sortBy", "limit"]); + const live_query = raw_query.trim(); + const [shadow, set_shadow] = useState(() => take_initial()?.query ?? ""); + const [tab, set_tab] = useState(() => take_initial()?.tab ?? "all"); + const [loading, set_loading] = useState(false); + const [bag, set_bag] = useState(() => take_initial()?.bag ?? empty_bag()); + const auto_switched = useRef(false); + const restored_query = useRef(shadow || null); + + useEffect(() => { + if (!shadow) return; + window.setTimeout(() => { + try { + FluxDispatcher.dispatch({ type: "QUICKSWITCHER_SEARCH", query: shadow, queryMode: null }); + } catch { } + }, 80); + }, []); + + useEffect(() => { + if (live_query.length > 0 && shadow) set_shadow(""); + }, [live_query, shadow]); + + const query = live_query || shadow; + + useEffect(() => { + if (query.length < MIN_QUERY_LENGTH) { + if (restored_query.current !== null) return; + set_bag(empty_bag()); + set_loading(false); + set_tab("all"); + auto_switched.current = false; + return; + } + + if (restored_query.current === query) { + restored_query.current = null; + return; + } + restored_query.current = null; + + auto_switched.current = false; + let cancelled = false; + set_loading(true); + + const handle = window.setTimeout(async () => { + const result = await search(query, SEARCHABLE_TABS); + if (cancelled) return; + + const next = empty_bag(); + for (const t of SEARCHABLE_TABS) { + next.hits[t] = result[t].hits; + next.cursors[t] = result[t].cursor; + next.totals[t] = result[t].total_results; + } + next.channels = result.channels; + set_bag(next); + set_loading(false); + }, DEBOUNCE_MS); + + return () => { + cancelled = true; + clearTimeout(handle); + }; + }, [query, sortBy, limit]); + + useEffect(() => { + if (!settings.store.autoOpenMessagesTab) return; + if (auto_switched.current) return; + if (loading) return; + if (bag.hits.messages.length === 0) return; + if (discord_matches > 0) return; + auto_switched.current = true; + set_tab("messages"); + }, [discord_matches, bag.hits.messages.length, loading]); + + useEffect(() => { + if (!settings.store.restoreLastSession) return; + if (query.length < MIN_QUERY_LENGTH) return; + const has_data = SEARCHABLE_TABS.some(t => bag.hits[t].length > 0); + if (!has_data) return; + save_session(query, tab, bag); + }, [query, tab, bag]); + + const append_page = useCallback((t: SearchTab, more: MessageHit[], next_cursor: SearchCursor | null) => { + set_bag(prev => ({ + ...prev, + hits: { ...prev.hits, [t]: [...prev.hits[t], ...more] }, + cursors: { ...prev.cursors, [t]: next_cursor } + })); + }, []); + + const pick_tab = useCallback((t: TabKey) => { + auto_switched.current = true; + set_tab(t); + }, []); + + const lock_query_ref = useRef<() => void>(() => { }); + lock_query_ref.current = () => { + if (query) set_shadow(query); + }; + const lock_query = useCallback(() => lock_query_ref.current(), []); + + if (tab === "all") { + return ( + <> + + {default_results} + {settings.store.showInlinePreview && query && bag.totals.messages > 0 && ( + pick_tab("messages")} + on_keep_open={lock_query} + /> + )} + {protip} + {tutorial} + + ); + } + + const focused = tab as SearchTab; + return ( + <> + + + {tutorial} + + ); +} diff --git a/src/plugins/dmSearch/components/TabContent.tsx b/src/plugins/dmSearch/components/TabContent.tsx new file mode 100644 index 0000000000..776f1e6e67 --- /dev/null +++ b/src/plugins/dmSearch/components/TabContent.tsx @@ -0,0 +1,126 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { load_page } from "@plugins/dmSearch/api/search"; +import { SCROLL_GAP_PX, SCROLL_SAVE_MS } from "@plugins/dmSearch/constants"; +import { get_scroll, save_scroll } from "@plugins/dmSearch/state"; +import { ChannelMeta, MessageHit, SearchCursor, SearchTab } from "@plugins/dmSearch/types"; +import { useEffect, useRef, useState } from "@webpack/common"; + +import { HitRow } from "./HitRow"; + +const EMPTY_TEXT: Record = { + messages: "No matching messages.", + media: "No matching images or videos.", + pins: "No pinned messages match this search.", + links: "No messages contain a matching link.", + files: "No matching file attachments." +}; + +interface Props { + tab: SearchTab; + query: string; + hits: MessageHit[]; + cursor: SearchCursor | null; + loading: boolean; + channels: Map; + on_more: (tab: SearchTab, more: MessageHit[], next: SearchCursor | null) => void; + on_keep_open: () => void; +} + +export function TabContent({ tab, query, hits, cursor, loading, channels, on_more, on_keep_open }: Props) { + const scroller = useRef(null); + const [paging, set_paging] = useState(false); + + useEffect(() => { + const node = scroller.current; + if (!node) return; + const saved = get_scroll(tab); + if (saved > 0) requestAnimationFrame(() => { node.scrollTop = saved; }); + }, [tab]); + + useEffect(() => { + const node = scroller.current; + if (!node) return; + let save_timer: number | null = null; + + const handler = () => { + if (save_timer != null) clearTimeout(save_timer); + save_timer = window.setTimeout(() => save_scroll(tab, node.scrollTop), SCROLL_SAVE_MS); + + if (paging || !cursor) return; + const { scrollTop, scrollHeight, clientHeight } = node; + if (scrollHeight - scrollTop - clientHeight > SCROLL_GAP_PX) return; + + set_paging(true); + const captured = query; + void (async () => { + const next = await load_page(query, tab, cursor); + if (captured === query) on_more(tab, next.hits, next.cursor); + set_paging(false); + })(); + }; + + node.addEventListener("scroll", handler, { passive: true }); + return () => { + node.removeEventListener("scroll", handler); + if (save_timer != null) clearTimeout(save_timer); + }; + }, [tab, query, cursor, paging, on_more]); + + if (loading && !hits.length) { + return ( +
+
+ Searching + +
+
+ ); + } + + if (!hits.length) { + return ( +
+
{EMPTY_TEXT[tab]}
+
+ ); + } + + return ( +
+
+ {hits.map(hit => ( + + ))} +
+ {paging + ? ( +
+ Loading more + +
+ ) + : !cursor && hits.length > 0 &&
End of results
+ } +
+ ); +} + +export function Dots() { + return ( + + + + ); +} diff --git a/src/plugins/dmSearch/components/Tabs.tsx b/src/plugins/dmSearch/components/Tabs.tsx new file mode 100644 index 0000000000..63c2ff8e49 --- /dev/null +++ b/src/plugins/dmSearch/components/Tabs.tsx @@ -0,0 +1,63 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { SearchTab, TabKey } from "@plugins/dmSearch/types"; + +import { Dots } from "./TabContent"; + +const ORDER: TabKey[] = ["all", "messages", "media", "pins", "links", "files"]; + +const LABELS: Record = { + all: "All", + messages: "Messages", + media: "Media", + pins: "Pins", + links: "Links", + files: "Files" +}; + +interface Props { + query: string; + totals: Record; + loading: boolean; + active: TabKey; + on_pick: (tab: TabKey) => void; +} + +export function Tabs({ query, totals, loading, active, on_pick }: Props) { + if (!query) return null; + + const visible = ORDER.filter(t => t === "all" || totals[t] > 0); + const tabs_with_hits = visible.length - 1; + if (!tabs_with_hits && !loading) return null; + + const all_total = (Object.keys(totals) as SearchTab[]).reduce((sum, t) => sum + totals[t], 0); + + return ( +
+ {visible.map(tab => { + const count = tab === "all" ? all_total : totals[tab]; + const is_active = tab === active; + return ( + + ); + })} + {loading && } +
+ ); +} diff --git a/src/plugins/dmSearch/constants.ts b/src/plugins/dmSearch/constants.ts new file mode 100644 index 0000000000..e7054655bc --- /dev/null +++ b/src/plugins/dmSearch/constants.ts @@ -0,0 +1,18 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { SearchTab } from "./types"; + +export const MIN_QUERY_LENGTH = 2; +export const DEBOUNCE_MS = 300; +export const SCROLL_GAP_PX = 200; +export const SCROLL_SAVE_MS = 150; +export const HARD_LIMIT = 25; + +export const TYPE_DM = 1; +export const TYPE_GROUP_DM = 3; + +export const SEARCHABLE_TABS: SearchTab[] = ["messages", "media", "pins", "links", "files"]; diff --git a/src/plugins/dmSearch/index.tsx b/src/plugins/dmSearch/index.tsx new file mode 100644 index 0000000000..d5274063be --- /dev/null +++ b/src/plugins/dmSearch/index.tsx @@ -0,0 +1,48 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import "./styles.css"; + +import ErrorBoundary from "@components/ErrorBoundary"; +import { Devs } from "@utils/constants"; +import definePlugin from "@utils/types"; +import type { ReactNode } from "react"; + +import { Overlay } from "./components/Overlay"; +import { settings } from "./settings"; + +export default definePlugin({ + name: "DMSearch", + description: "Adds a global DM and Groups search to the Quick Switcher (Ctrl+K) with Messages, Media, Pins, Links and Files tabs.", + authors: [Devs.QDave], + tags: ["Chat", "Utility", "Shortcuts"], + settings, + + patches: [ + { + find: '"QUICK_SWITCHER_MODAL_KEY"', + replacement: { + match: /\[this\.renderInput\(\),this\.renderResults\(\),this\.renderProtip\(\),this\.renderTutorial\(\)\]/, + replace: '[this.renderInput(),$self.render_overlay(this.state?.query??"",this.props?.results?.length??0,this.renderResults(),this.renderProtip(),this.renderTutorial())]' + } + } + ], + + render_overlay(query: string, discord_matches: number, default_results: ReactNode, protip: ReactNode, tutorial: ReactNode) { + return ( + + + + ); + } +}); diff --git a/src/plugins/dmSearch/settings.ts b/src/plugins/dmSearch/settings.ts new file mode 100644 index 0000000000..17ffeda21d --- /dev/null +++ b/src/plugins/dmSearch/settings.ts @@ -0,0 +1,53 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 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({ + limit: { + type: OptionType.SLIDER, + description: "Maximum number of hits loaded per category in one request.", + markers: [10, 15, 20, 25], + default: 20, + stickToMarkers: true + }, + inlinePreviewLimit: { + type: OptionType.SLIDER, + description: "How many message hits to display directly on the All tab before the \"Show all\" button.", + markers: [3, 5, 8, 10], + default: 5, + stickToMarkers: true + }, + sortBy: { + type: OptionType.SELECT, + description: "Order results by newest first or by how closely they match your query.", + options: [ + { label: "Most recent", value: "timestamp", default: true }, + { label: "Relevance", value: "relevance" } + ] + }, + showInlinePreview: { + type: OptionType.BOOLEAN, + description: "Show a preview of message hits inline on the All tab next to Discord's regular channel matches.", + default: true + }, + autoOpenMessagesTab: { + type: OptionType.BOOLEAN, + description: "Automatically switch to the Messages tab when your query has message hits but Discord found no channel matches.", + default: false + }, + keepOpenAfterJump: { + type: OptionType.BOOLEAN, + description: "Keep the Quick Switcher open after clicking a result instead of closing it. Lets you jump and keep browsing other hits.", + default: false + }, + restoreLastSession: { + type: OptionType.BOOLEAN, + description: "When you reopen the Quick Switcher, restore your last query, active tab, and results from the previous session.", + default: false + } +}); diff --git a/src/plugins/dmSearch/state.ts b/src/plugins/dmSearch/state.ts new file mode 100644 index 0000000000..9c0d5335a9 --- /dev/null +++ b/src/plugins/dmSearch/state.ts @@ -0,0 +1,48 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Bag, TabKey } from "./types"; + +const TTL_MS = 30 * 60 * 1000; + +interface Snapshot { + query: string; + tab: TabKey; + bag: Bag; + scrolls: Partial>; + ts: number; +} + +let snap: Snapshot | null = null; + +export function save_session(query: string, tab: TabKey, bag: Bag): void { + snap = { + query, + tab, + bag, + scrolls: snap?.scrolls ?? {}, + ts: Date.now() + }; +} + +export function save_scroll(tab: TabKey, top: number): void { + if (!snap) return; + snap.scrolls[tab] = top; + snap.ts = Date.now(); +} + +export function get_scroll(tab: TabKey): number { + return snap?.scrolls?.[tab] ?? 0; +} + +export function load_session(): Snapshot | null { + if (!snap) return null; + if (Date.now() - snap.ts > TTL_MS) { + snap = null; + return null; + } + return snap; +} diff --git a/src/plugins/dmSearch/styles.css b/src/plugins/dmSearch/styles.css new file mode 100644 index 0000000000..5a64fdd162 --- /dev/null +++ b/src/plugins/dmSearch/styles.css @@ -0,0 +1,408 @@ +.vc-dms-tabs { + display: flex; + align-items: center; + gap: 2px; + padding: 0 8px; + border-bottom: 1px solid var(--background-modifier-accent); + flex-shrink: 0; + overflow: auto hidden; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.vc-dms-tabs::-webkit-scrollbar { + display: none; +} + +.vc-dms-tab { + appearance: none; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + padding: 10px 10px 8px; + margin: 0; + color: var(--interactive-normal); + cursor: pointer; + font-size: 13px; + font-weight: 500; + line-height: 1; + display: inline-flex; + align-items: center; + gap: 5px; + transition: color .12s ease, border-color .12s ease, background .12s ease; + font-family: inherit; + white-space: nowrap; + flex-shrink: 0; + border-radius: 4px 4px 0 0; +} + +.vc-dms-tab:hover { + color: var(--interactive-hover); + background: var(--background-modifier-hover); +} + +.vc-dms-tab-active { + color: var(--interactive-active); + border-bottom-color: var(--brand-experiment, var(--brand-500, #5865f2)); +} + +.vc-dms-tab-active:hover { + background: transparent; +} + +.vc-dms-tab-count { + font-size: 10px; + color: var(--interactive-active); + background: var(--brand-experiment-15a, rgb(88 101 242 / 15%)); + padding: 1px 6px; + border-radius: 8px; + min-width: 16px; + text-align: center; + font-weight: 600; + line-height: 1.4; +} + +.vc-dms-tab-active .vc-dms-tab-count { + background: var(--brand-experiment-15a, rgb(88 101 242 / 15%)); + color: var(--interactive-active); +} + +.vc-dms-content { + overflow-y: auto; + flex: 1 1 auto; + min-height: 55vh; + max-height: 55vh; + padding: 4px 0 8px; +} + +.vc-dms-content::-webkit-scrollbar { + width: 8px; +} + +.vc-dms-content::-webkit-scrollbar-thumb { + background: var(--scrollbar-thin-thumb, var(--background-modifier-accent)); + border-radius: 4px; +} + +.vc-dms-content::-webkit-scrollbar-track { + background: transparent; +} + +[class^="quickswitcher_"]:has(.vc-dms-tabs) { + max-height: 70vh; +} + +.vc-dms-inline { + border-top: 1px solid var(--background-modifier-accent); + margin: 6px 4px 4px; + padding-top: 2px; +} + +.vc-dms-end { + padding: 12px 16px 16px; + text-align: center; + color: var(--text-muted); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + font-weight: 600; +} + +.vc-dms-section-header { + display: flex; + align-items: baseline; + gap: 6px; + padding: 10px 16px 4px; +} + +.vc-dms-section-title { + color: var(--header-secondary); + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.vc-dms-section-count { + color: var(--text-muted); + font-size: 12px; + font-weight: 500; +} + +.vc-dms-list { + display: flex; + flex-direction: column; + padding: 0 4px; +} + +.vc-dms-list-inline { + padding-bottom: 4px; +} + +.vc-dms-row { + display: flex; + gap: 12px; + padding: 8px 12px; + cursor: pointer; + align-items: flex-start; + border-radius: 4px; + margin: 1px 4px; + outline: none; +} + +.vc-dms-row:hover, +.vc-dms-row:focus-visible { + background: var(--background-modifier-hover); +} + +.vc-dms-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + flex-shrink: 0; + object-fit: cover; + margin-top: 2px; +} + +.vc-dms-body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.vc-dms-meta { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.vc-dms-author { + color: var(--header-primary); + font-size: 14px; + font-weight: 600; + flex-shrink: 0; + line-height: 1.2; +} + +.vc-dms-bot-tag { + background: var(--brand-experiment, var(--brand-500, #5865f2)); + color: white; + font-size: 10px; + font-weight: 700; + padding: 1px 5px 0; + border-radius: 3px; + height: 14px; + line-height: 14px; + align-self: center; + text-transform: uppercase; +} + +.vc-dms-time { + color: var(--text-muted); + font-size: 11px; + margin-left: auto; + flex-shrink: 0; + font-weight: 500; +} + +.vc-dms-context { + color: var(--text-muted); + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.vc-dms-context-muted { + color: var(--text-muted); + font-size: 12px; + flex-shrink: 0; + opacity: 0.7; +} + +.vc-dms-context-muted::before { + content: "· "; + margin-right: 2px; +} + +.vc-dms-tag { + color: var(--text-muted); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + flex-shrink: 0; + align-self: center; + opacity: 0.65; +} + +.vc-dms-text { + color: var(--text-normal); + font-size: 14px; + line-height: 1.4; + overflow-wrap: anywhere; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + margin-top: 2px; +} + +.vc-dms-text mark { + background: var(--mention-background, rgb(88 101 242 / 30%)); + color: var(--mention-foreground, var(--text-normal)); + border-radius: 3px; + padding: 0 2px; + font-weight: 500; +} + +.vc-dms-muted { + color: var(--text-muted); + font-style: italic; +} + +.vc-dms-list-inline .vc-dms-row { + padding: 5px 10px; + gap: 10px; +} + +.vc-dms-list-inline .vc-dms-avatar { + width: 28px; + height: 28px; + margin-top: 1px; +} + +.vc-dms-list-inline .vc-dms-text { + -webkit-line-clamp: 1; + font-size: 13px; +} + +.vc-dms-list-inline .vc-dms-context { + font-size: 11px; +} + +.vc-dms-media { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 2px; +} + +.vc-dms-thumbs { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); + gap: 4px; +} + +.vc-dms-thumb { + width: 100%; + aspect-ratio: 1; + object-fit: cover; + border-radius: 4px; + background: var(--background-secondary); + display: block; +} + +.vc-dms-files { + display: flex; + flex-direction: column; + gap: 3px; + margin-top: 2px; +} + +.vc-dms-file { + display: flex; + flex-direction: column; + padding: 6px 10px; + background: var(--background-secondary); + border-radius: 4px; + border: 1px solid var(--background-modifier-accent); +} + +.vc-dms-file-name { + font-weight: 600; + color: var(--text-normal); + font-size: 13px; + word-break: break-all; +} + +.vc-dms-file-meta { + color: var(--text-muted); + font-size: 11px; +} + +.vc-dms-empty { + padding: 32px 16px; + text-align: center; + color: var(--text-muted); + font-size: 14px; +} + +.vc-dms-loading { + padding: 32px 16px; + text-align: center; + color: var(--text-muted); + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + gap: 4px; +} + +.vc-dms-loading-dots { + display: inline-flex; + gap: 3px; + margin-left: 6px; +} + +.vc-dms-loading-dots > span { + width: 5px; + height: 5px; + border-radius: 50%; + background: currentcolor; + opacity: 0.4; + animation: vc-dms-pulse 1.2s infinite ease-in-out; +} + +.vc-dms-loading-dots > span:nth-child(2) { + animation-delay: 0.2s; +} + +.vc-dms-loading-dots > span:nth-child(3) { + animation-delay: 0.4s; +} + +@keyframes vc-dms-pulse { + 0%, 60%, 100% { + opacity: 0.3; + transform: scale(0.85); + } + + 30% { + opacity: 1; + transform: scale(1); + } +} + +.vc-dms-more { + display: block; + margin: 6px auto; + background: var(--background-modifier-accent); + color: var(--text-normal); + border: none; + padding: 6px 14px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + font-weight: 500; + font-family: inherit; + transition: background .12s ease; +} + +.vc-dms-more:hover { + background: var(--background-modifier-selected); +} diff --git a/src/plugins/dmSearch/types.ts b/src/plugins/dmSearch/types.ts new file mode 100644 index 0000000000..74c8747e6e --- /dev/null +++ b/src/plugins/dmSearch/types.ts @@ -0,0 +1,109 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +export type SearchTab = "messages" | "media" | "links" | "files" | "pins"; + +export type TabKey = "all" | SearchTab; + +export type HasFilter = "image" | "video" | "file" | "audio" | "link" | "embed" | "sound" | "snapshot" | "sticker"; + +export interface SearchCursor { + timestamp: string; + type: "timestamp"; +} + +export interface TabRequest { + sort_by: "timestamp" | "relevance"; + sort_order: "asc" | "desc"; + content?: string; + cursor?: SearchCursor | null; + limit: number; + has?: HasFilter[]; + pinned?: boolean; +} + +export interface SearchTabsBody { + tabs: Partial>; + track_exact_total_hits: boolean; +} + +interface RawTabResponse { + messages?: unknown[][]; + cursor?: SearchCursor | null; + total_results?: number; + analytics_id?: string; + channels?: ChannelMeta[]; +} + +export interface SearchTabsResponse { + tabs: Partial>; +} + +interface MessageAttachment { + id: string; + filename: string; + content_type?: string; + url: string; + proxy_url: string; + size: number; + width?: number; + height?: number; +} + +export interface MessageHit { + id: string; + content: string; + channel_id: string; + author: { + id: string; + username?: string; + global_name?: string | null; + avatar?: string | null; + bot?: boolean; + }; + timestamp: string; + attachments?: MessageAttachment[]; + embeds?: unknown[]; + pinned?: boolean; + flags?: number; +} + +export interface ChannelMeta { + id: string; + type: number; + name?: string; + recipients?: { + id: string; + username?: string; + global_name?: string | null; + avatar?: string | null; + }[]; + guild_id?: string; + icon?: string | null; +} + +export interface TabResult { + hits: MessageHit[]; + cursor: SearchCursor | null; + total_results: number; +} + +export type TabResults = Record & { + channels: Map; +}; + +export interface Bag { + hits: Record; + cursors: Record; + totals: Record; + channels: Map; +} + +export interface ChannelInfo { + kind: "dm" | "group" | "server" | "unknown"; + target: string; + server?: string; +} diff --git a/src/plugins/dmSearch/utils/avatar.ts b/src/plugins/dmSearch/utils/avatar.ts new file mode 100644 index 0000000000..c8a1c847bb --- /dev/null +++ b/src/plugins/dmSearch/utils/avatar.ts @@ -0,0 +1,18 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +const FALLBACK_BUCKETS = 5; + +export function avatar_url(user_id?: string, hash?: string | null): string { + if (!user_id) return fallback(0); + if (!hash) return fallback(parseInt(user_id, 10) % FALLBACK_BUCKETS); + const ext = hash.startsWith("a_") ? "gif" : "png"; + return `https://cdn.discordapp.com/avatars/${user_id}/${hash}.${ext}?size=80`; +} + +function fallback(idx: number): string { + return `https://cdn.discordapp.com/embed/avatars/${idx}.png?size=80`; +} diff --git a/src/plugins/dmSearch/utils/channel.ts b/src/plugins/dmSearch/utils/channel.ts new file mode 100644 index 0000000000..ab81478fc8 --- /dev/null +++ b/src/plugins/dmSearch/utils/channel.ts @@ -0,0 +1,62 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { TYPE_DM, TYPE_GROUP_DM } from "@plugins/dmSearch/constants"; +import { ChannelInfo, ChannelMeta } from "@plugins/dmSearch/types"; +import { ChannelStore, GuildStore, UserStore } from "@webpack/common"; + +export function channel_info(channel_id: string, meta?: ChannelMeta): ChannelInfo { + const live = ChannelStore.getChannel(channel_id); + if (live) return from_live(live, channel_id); + if (meta) return from_meta(meta, channel_id); + return { kind: "unknown", target: channel_id }; +} + +function from_live(channel: any, channel_id: string): ChannelInfo { + if (channel.isDM?.()) { + const recipient = UserStore.getUser(channel.recipients?.[0]); + return { + kind: "dm", + target: `@${recipient?.globalName ?? recipient?.username ?? channel_id}` + }; + } + if (channel.isGroupDM?.()) { + return { kind: "group", target: channel.name?.length ? channel.name : group_label(channel.recipients, id => UserStore.getUser(id)?.username) }; + } + const guild = channel.guild_id ? GuildStore.getGuild(channel.guild_id) : null; + return { + kind: "server", + target: `#${channel.name ?? channel_id}`, + server: guild?.name + }; +} + +function from_meta(meta: ChannelMeta, channel_id: string): ChannelInfo { + if (meta.type === TYPE_DM) { + const r = meta.recipients?.[0]; + return { + kind: "dm", + target: `@${r?.global_name ?? r?.username ?? channel_id}` + }; + } + if (meta.type === TYPE_GROUP_DM) { + return { kind: "group", target: meta.name?.length ? meta.name : group_label(meta.recipients, r => r.username) }; + } + if (meta.guild_id) { + const guild = GuildStore.getGuild(meta.guild_id); + return { + kind: "server", + target: `#${meta.name ?? channel_id}`, + server: guild?.name + }; + } + return { kind: "server", target: `#${meta.name ?? channel_id}` }; +} + +function group_label(recipients: T[] | undefined, name_of: (r: T) => string | undefined): string { + if (!recipients?.length) return "Group DM"; + return recipients.map(name_of).filter(Boolean).join(", ") || "Group DM"; +} diff --git a/src/plugins/dmSearch/utils/format.ts b/src/plugins/dmSearch/utils/format.ts new file mode 100644 index 0000000000..69772bc326 --- /dev/null +++ b/src/plugins/dmSearch/utils/format.ts @@ -0,0 +1,23 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +const DAY_MS = 86_400_000; + +export function fmt_time(ts: string): string { + const d = new Date(ts); + const diff = Date.now() - d.getTime(); + if (diff < DAY_MS) return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + if (diff < DAY_MS * 7) return d.toLocaleDateString([], { weekday: "short" }); + if (diff < DAY_MS * 365) return d.toLocaleDateString([], { day: "numeric", month: "short" }); + return d.toLocaleDateString(); +} + +export function fmt_bytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`; + return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`; +} diff --git a/src/plugins/dmSearch/utils/highlight.tsx b/src/plugins/dmSearch/utils/highlight.tsx new file mode 100644 index 0000000000..a89946eb20 --- /dev/null +++ b/src/plugins/dmSearch/utils/highlight.tsx @@ -0,0 +1,31 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import type { ReactNode } from "react"; + +const RE_SPECIALS = /[.*+?^${}()|[\]\\]/g; + +export function highlight(content: string, query: string): ReactNode { + if (!query) return content; + + const re = new RegExp(escape(query), "ig"); + const parts: ReactNode[] = []; + let last = 0; + let key = 0; + let m: RegExpExecArray | null; + while ((m = re.exec(content)) !== null) { + if (m.index > last) parts.push(content.slice(last, m.index)); + parts.push({m[0]}); + last = re.lastIndex; + if (m.index === re.lastIndex) re.lastIndex++; + } + if (last < content.length) parts.push(content.slice(last)); + return parts; +} + +function escape(s: string): string { + return s.replace(RE_SPECIALS, "\\$&"); +} diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 61550c00a0..9c30230488 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -638,6 +638,10 @@ export const Devs = /* #__PURE__*/ Object.freeze({ name: "prism", id: 390884143749136386n, }, + QDave: { + name: "QDave", + id: 209290217717235712n, + }, } satisfies Record); // iife so #__PURE__ works correctly