Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/plugins/dmSearch/README.md
Original file line number Diff line number Diff line change
@@ -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.

<img width="697" height="472" alt="image" src="https://github.com/user-attachments/assets/787633ab-7508-48b4-a9a8-1798de36a9fa" />
53 changes: 53 additions & 0 deletions src/plugins/dmSearch/api/navigation.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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" });
}
109 changes: 109 additions & 0 deletions src/plugins/dmSearch/api/search.ts
Original file line number Diff line number Diff line change
@@ -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<SearchTab, { has?: HasFilter[]; pinned?: boolean; }> = {
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<Record<SearchTab, SearchCursor | null>>;
abort?: AbortSignal;
}

async function run(opts: RunOpts): Promise<TabResults> {
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<string, unknown>,
oldFormErrors: true,
signal: opts.abort
} as Parameters<typeof RestAPI.post>[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<TabResults> {
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<TabResult> {
const r = await run({ query, tabs: [tab], cursors: { [tab]: cursor } });
return r[tab];
}

function empty_results(tabs: SearchTab[]): TabResults {
const out = { channels: new Map<string, ChannelMeta>() } as TabResults;
for (const tab of tabs) out[tab] = { hits: [], cursor: null, total_results: 0 };
return out;
}
127 changes: 127 additions & 0 deletions src/plugins/dmSearch/components/HitRow.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className="vc-dms-row"
onMouseDown={e => {
e.preventDefault();
e.stopPropagation();
}}
onClick={e => {
e.preventDefault();
e.stopPropagation();
open();
}}
>
<img
className="vc-dms-avatar"
src={avatar_url(hit.author?.id, hit.author?.avatar)}
alt=""
loading="lazy"
/>
<div className="vc-dms-body">
<div className="vc-dms-meta">
<span className="vc-dms-author">{author}</span>
{info.kind === "dm" && <span className="vc-dms-tag">DM</span>}
{info.kind === "dm" && is_self && <span className="vc-dms-context">to {info.target}</span>}
{info.kind === "group" && <span className="vc-dms-tag">GROUP</span>}
{info.kind === "group" && <span className="vc-dms-context">{info.target}</span>}
{info.kind === "server" && <span className="vc-dms-context">{info.target}</span>}
{info.kind === "server" && info.server && <span className="vc-dms-context-muted">{info.server}</span>}
{is_bot && <span className="vc-dms-bot-tag">BOT</span>}
<span className="vc-dms-time">{fmt_time(hit.timestamp)}</span>
</div>
<Body hit={hit} query={query} tab={tab} />
</div>
</div>
);
}

function Body({ hit, query, tab }: { hit: MessageHit; query: string; tab: SearchTab; }) {
if (tab === "media") return <MediaBody hit={hit} query={query} />;
if (tab === "files") return <FilesBody hit={hit} query={query} />;
return <TextBody content={hit.content} query={query} />;
}

function TextBody({ content, query }: { content: string; query: string; }) {
if (!content) {
return <div className="vc-dms-text"><span className="vc-dms-muted">[no text]</span></div>;
}
return <div className="vc-dms-text">{highlight(content, query)}</div>;
}

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 (
<div className="vc-dms-media">
{items.length > 0 && (
<div className="vc-dms-thumbs">
{items.slice(0, 4).map(a => a.content_type?.startsWith?.("video/")
? <video key={a.id} className="vc-dms-thumb" src={a.proxy_url} muted preload="metadata" />
: <img key={a.id} className="vc-dms-thumb" src={a.proxy_url} alt={a.filename ?? ""} loading="lazy" />
)}
</div>
)}
{hit.content && <TextBody content={hit.content} query={query} />}
</div>
);
}

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 (
<div className="vc-dms-files">
{files.map(f => (
<div key={f.id} className="vc-dms-file">
<span className="vc-dms-file-name">{f.filename ?? "file"}</span>
<span className="vc-dms-file-meta">{`${f.content_type ?? "file"} · ${fmt_bytes(f.size ?? 0)}`}</span>
</div>
))}
{hit.content && <TextBody content={hit.content} query={query} />}
</div>
);
}
98 changes: 98 additions & 0 deletions src/plugins/dmSearch/components/InlinePreview.tsx
Original file line number Diff line number Diff line change
@@ -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<string, ChannelMeta>;
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<HTMLElement | null>(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(
<div className="vc-dms-inline">
<div className="vc-dms-section-header">
<span className="vc-dms-section-title">Messages</span>
<span className="vc-dms-section-count">{total}</span>
</div>
<div className="vc-dms-list vc-dms-list-inline">
{shown.map(hit => (
<HitRow
key={hit.id}
hit={hit}
query={query}
tab="messages"
channel_meta={channels.get(hit.channel_id)}
on_keep_open={on_keep_open}
/>
))}
</div>
{total > shown.length && (
<button
type="button"
className="vc-dms-more"
onClick={e => {
e.preventDefault();
e.stopPropagation();
on_show_all();
}}
>
Show all {total} messages
</button>
)}
</div>,
target
);
}
Loading