Equicord/src/equicordplugins/dragify/targets.ts

224 lines
8.6 KiB
TypeScript

/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import type { Channel, User } from "@vencord/discord-types";
import { extractChannelFromUrl, extractChannelPath, extractSnowflakeFromString, extractUserFromAvatar, extractUserFromProfile } from "./utils";
export type ChannelTarget = { id: string; guildId?: string; };
export type ResolvedDragTarget = {
hasAttachment: boolean;
hasMessageInput: boolean;
hasChatBody: boolean;
hasDragifyUser: boolean;
hasUserMarker: boolean;
hasTextNode: boolean;
userId: string | null;
avatarUserId: string | null;
authorId: string | null;
channel: ChannelTarget | null;
guildId: string | null;
};
type InspectionContext = {
ChannelStore: { getChannel(id: string): Channel | null | undefined; };
UserStore: { getUser(id: string): User | null | undefined; };
getDmRecipientId(channel?: Channel | null): string | null;
};
const messageInputSelector = "[data-slate-editor],[role=\"textbox\"],[contenteditable=\"true\"],[aria-label^=\"Message \"]";
const chatBodySelector = "[role=\"log\"],[data-list-id^=\"chat-messages\"]";
function collectEventElements(event: DragEvent): HTMLElement[] {
const seen = new Set<HTMLElement>();
const elements: HTMLElement[] = [];
const add = (value: unknown) => {
if (!(value instanceof HTMLElement) || seen.has(value)) return;
seen.add(value);
elements.push(value);
};
add(event.target);
for (const entry of event.composedPath?.() ?? []) add(entry);
if (typeof document !== "undefined" && event.clientX != null && event.clientY != null) {
for (const element of document.elementsFromPoint?.(event.clientX, event.clientY) ?? []) add(element);
}
return elements;
}
function collectAncestors(elements: HTMLElement[]): HTMLElement[] {
const seen = new Set<HTMLElement>();
const ancestors: HTMLElement[] = [];
for (const element of elements) {
let current: HTMLElement | null = element;
while (current) {
if (!seen.has(current)) {
seen.add(current);
ancestors.push(current);
}
current = current.parentElement;
}
}
return ancestors;
}
function resolveChannelTarget(element: HTMLElement): ChannelTarget | null {
const listId = element.getAttribute("data-list-id") ?? "";
const rawId = element.getAttribute("data-list-item-id") ?? "";
const channelIdAttr = element.getAttribute("data-channel-id") ?? element.getAttribute("data-item-id") ?? "";
const threadIdAttr = element.getAttribute("data-thread-id") ?? "";
const href = element.getAttribute("href") ?? "";
const isChannelContext = /(channel|thread|private|forum)/i.test(listId) || /(channel|thread|private|forum)/i.test(rawId);
const pathMatch = extractChannelPath(href);
if (pathMatch?.channelId) {
return {
id: pathMatch.channelId,
guildId: pathMatch.guildId === "@me" ? undefined : pathMatch.guildId,
};
}
const fullMatch = extractChannelFromUrl(href);
if (fullMatch?.channelId) {
return {
id: fullMatch.channelId,
guildId: fullMatch.guildId === "@me" ? undefined : fullMatch.guildId,
};
}
const candidate = isChannelContext
? (extractSnowflakeFromString(threadIdAttr)
?? extractSnowflakeFromString(channelIdAttr)
?? extractSnowflakeFromString(rawId)
?? extractSnowflakeFromString(listId))
: null;
if (!candidate) return null;
return {
id: candidate,
guildId: extractSnowflakeFromString(element.getAttribute("data-guild-id") ?? "") ?? undefined,
};
}
function resolveGuildId(element: HTMLElement): string | null {
const listId = element.getAttribute("data-list-id");
const rawId = element.getAttribute("data-list-item-id") ?? "";
const isGuildContext = (listId && /guild/i.test(listId)) || /guild/i.test(rawId);
if (!isGuildContext) return null;
const parts = rawId.split("___");
const candidate = parts[parts.length - 1] ?? rawId;
if (/^\d{17,20}$/.test(candidate)) return candidate;
return extractSnowflakeFromString(rawId)
?? extractSnowflakeFromString(listId ?? "")
?? extractSnowflakeFromString(element.getAttribute("data-guild-id") ?? "");
}
function resolveUserId(element: HTMLElement, context: InspectionContext): string | null {
const dataUserId = element.getAttribute("data-user-id") ?? element.getAttribute("data-userid") ?? "";
const dataAuthorId = element.getAttribute("data-author-id") ?? "";
const listId = element.getAttribute("data-list-item-id") ?? element.getAttribute("data-item-id") ?? "";
const href = element.getAttribute("href") ?? "";
const src = element.getAttribute("src") ?? "";
const aria = element.getAttribute("aria-label") ?? "";
const explicit = extractSnowflakeFromString(dataUserId) ?? extractSnowflakeFromString(dataAuthorId);
if (explicit) return explicit;
const listCandidate = extractSnowflakeFromString(listId);
if (listCandidate) {
if (context.UserStore.getUser(listCandidate)) return listCandidate;
const channel = context.ChannelStore.getChannel(listCandidate);
if (channel && channel.isDM() && !channel.isGroupDM() && !channel.isMultiUserDM()) {
const recipientId = context.getDmRecipientId(channel);
if (recipientId) return recipientId;
}
}
const profile = extractUserFromProfile(href);
if (profile) return profile;
const avatar = extractUserFromAvatar(src);
if (avatar) return avatar;
const styleAttr = element.getAttribute("style") ?? "";
const bgImage = element.style?.backgroundImage ?? "";
const styleAvatar = extractUserFromAvatar(`${styleAttr} ${bgImage}`);
if (styleAvatar) return styleAvatar;
const ariaMatch = extractSnowflakeFromString(aria);
if (ariaMatch && context.UserStore.getUser(ariaMatch)) return ariaMatch;
return null;
}
function resolveAvatarUserId(element: HTMLElement) {
const src = element.getAttribute("src") ?? "";
const styleAttr = element.getAttribute("style") ?? "";
const bgImage = element.style?.backgroundImage ?? "";
return extractUserFromAvatar(`${src} ${styleAttr} ${bgImage}`);
}
export function inspectDragEvent(event: DragEvent, context: InspectionContext): ResolvedDragTarget {
return inspectElements(collectEventElements(event), context);
}
export function inspectElement(target: HTMLElement | null, context: InspectionContext): ResolvedDragTarget {
return inspectElements(target ? [target] : [], context);
}
function inspectElements(elements: HTMLElement[], context: InspectionContext): ResolvedDragTarget {
const resolved: ResolvedDragTarget = {
hasAttachment: false,
hasMessageInput: false,
hasChatBody: false,
hasDragifyUser: false,
hasUserMarker: false,
hasTextNode: elements.some(element => element.getAttribute("data-text") != null),
userId: null,
avatarUserId: null,
authorId: null,
channel: null,
guildId: null,
};
for (const element of collectAncestors(elements)) {
resolved.hasMessageInput ||= element.matches(messageInputSelector);
resolved.hasChatBody ||= element.matches(chatBodySelector);
resolved.hasDragifyUser ||= element.getAttribute("data-dragify-user") != null;
resolved.hasUserMarker ||= element.getAttribute("data-user-id") != null || element.getAttribute("data-userid") != null;
resolved.authorId ??= extractSnowflakeFromString(element.getAttribute("data-author-id") ?? "");
const href = element.getAttribute("href") ?? "";
const src = element.getAttribute("src") ?? "";
const style = element.style?.backgroundImage ?? "";
const dataAttachment =
element.getAttribute("data-attachment-id")
?? element.getAttribute("data-attachment-type")
?? element.getAttribute("data-attachment-item-id")
?? "";
const combined = `${href} ${src} ${style}`;
resolved.hasAttachment ||= Boolean(dataAttachment)
|| /(?:cdn|media)\.discordapp\.(?:com|net)\/attachments\//i.test(combined)
|| /discord\.com\/attachments\//i.test(combined)
|| /cdn\.discordapp\.com\/ephemeral-attachments\//i.test(combined);
resolved.userId ??= resolveUserId(element, context);
resolved.avatarUserId ??= resolveAvatarUserId(element);
resolved.channel ??= resolveChannelTarget(element);
resolved.guildId ??= resolveGuildId(element);
}
return resolved;
}