From e9e42296c44531312fc77614399d5c1af3070800 Mon Sep 17 00:00:00 2001 From: Jake <134332787+justjxke@users.noreply.github.com> Date: Thu, 9 Apr 2026 22:00:45 +0100 Subject: [PATCH] refactor: overhaul dragify internals (#961) --- src/equicordplugins/dragify/dragState.ts | 134 ++++ src/equicordplugins/dragify/ghost.tsx | 35 +- src/equicordplugins/dragify/index.tsx | 967 +++++------------------ src/equicordplugins/dragify/invite.ts | 189 +++++ src/equicordplugins/dragify/targets.ts | 224 ++++++ src/equicordplugins/dragify/utils.ts | 119 ++- 6 files changed, 866 insertions(+), 802 deletions(-) create mode 100644 src/equicordplugins/dragify/dragState.ts create mode 100644 src/equicordplugins/dragify/invite.ts create mode 100644 src/equicordplugins/dragify/targets.ts diff --git a/src/equicordplugins/dragify/dragState.ts b/src/equicordplugins/dragify/dragState.ts new file mode 100644 index 000000000..a32dcfbcc --- /dev/null +++ b/src/equicordplugins/dragify/dragState.ts @@ -0,0 +1,134 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import type { DropEntity } from "./utils"; + +type DragSessionState = { + activeEntity: DropEntity | null; + activeUserId: string | null; + activeGuildId: string | null; + dragSourceIsInput: boolean; + active: boolean; + lastDropAt: number; + lastHandledDrop: { at: number; key: string; }; + lastDragEventAt: number; + watchdogTimer: number | null; + guildCleanupTimer: number | null; +}; + +const dropDedupeWindowMs = 150; + +const state: DragSessionState = { + activeEntity: null, + activeUserId: null, + activeGuildId: null, + dragSourceIsInput: false, + active: false, + lastDropAt: 0, + lastHandledDrop: { at: 0, key: "" }, + lastDragEventAt: 0, + watchdogTimer: null, + guildCleanupTimer: null, +}; + +export function beginDrag(entity: DropEntity) { + cancelGuildCleanup(); + state.activeEntity = entity; + state.activeUserId = entity.kind === "user" ? entity.id : null; + state.activeGuildId = entity.kind === "guild" ? entity.id : null; + state.active = true; + touchDrag(); +} + +export function clearDragState() { + state.activeEntity = null; + state.activeUserId = null; + state.activeGuildId = null; + state.dragSourceIsInput = false; + state.active = false; +} + +export function markInputDragSource() { + clearDragState(); + state.dragSourceIsInput = true; +} + +export function isInputDragSource() { + return state.dragSourceIsInput; +} + +export function touchDrag() { + state.lastDragEventAt = Date.now(); +} + +export function getLastDropAt() { + return state.lastDropAt; +} + +export function markDrop() { + state.lastDropAt = Date.now(); +} + +export function shouldIgnoreDrop(key: string) { + const now = Date.now(); + if (state.lastHandledDrop.key === key && now - state.lastHandledDrop.at < dropDedupeWindowMs) return true; + state.lastHandledDrop = { key, at: now }; + return false; +} + +export function hasActiveDrag() { + return state.active || state.activeEntity !== null || state.activeUserId !== null || state.activeGuildId !== null; +} + +export function isGuildDragActive() { + return state.activeGuildId !== null || state.activeEntity?.kind === "guild"; +} + +export function isUserDragActive() { + return state.activeUserId !== null || state.activeEntity?.kind === "user"; +} + +export function getActiveEntity() { + return state.activeEntity; +} + +export function startDragWatchdog(onExpire: () => void) { + if (state.watchdogTimer !== null) return; + state.watchdogTimer = window.setInterval(() => { + if (!state.active) return; + if (Date.now() - state.lastDragEventAt < 1200) return; + clearDragState(); + onExpire(); + }, 500); +} + +export function stopDragWatchdog() { + if (state.watchdogTimer !== null) { + clearInterval(state.watchdogTimer); + state.watchdogTimer = null; + } +} + +export function scheduleGuildCleanup(onExpire: () => void) { + cancelGuildCleanup(); + state.guildCleanupTimer = window.setTimeout(() => { + if (Date.now() - state.lastDragEventAt < 200) return; + clearDragState(); + onExpire(); + }, 300); +} + +export function cancelGuildCleanup() { + if (state.guildCleanupTimer === null) return; + clearTimeout(state.guildCleanupTimer); + state.guildCleanupTimer = null; +} + +export function stopDragState() { + cancelGuildCleanup(); + stopDragWatchdog(); + clearDragState(); +} diff --git a/src/equicordplugins/dragify/ghost.tsx b/src/equicordplugins/dragify/ghost.tsx index 38e6fd039..32506c279 100644 --- a/src/equicordplugins/dragify/ghost.tsx +++ b/src/equicordplugins/dragify/ghost.tsx @@ -7,7 +7,6 @@ import ErrorBoundary from "@components/ErrorBoundary"; import { classNameFactory } from "@utils/css"; import { classes } from "@utils/misc"; -import { findComponentByCodeLazy } from "@webpack"; import { createRoot, React, useEffect, useState, useStateFromStores, VoiceStateStore } from "@webpack/common"; export type GhostState = { @@ -24,15 +23,6 @@ export type GhostState = { exiting: boolean; }; -const ScreenArrowIcon = findComponentByCodeLazy("3V5Zm16") as React.ComponentType<{ - className?: string; - width?: number; - height?: number; - size?: number; - color?: string; - colorClass?: string; -}>; - const cl = classNameFactory("vc-dragify-"); let ghostRoot: ReturnType | null = null; @@ -184,12 +174,7 @@ const DragGhost = () => { : ) : null} {state.kind === "user" && inVoice && isStreaming - ? + ? : null} {state.subtitle &&
{state.subtitle}
} @@ -218,6 +203,24 @@ function VoiceStateIcon({ className, size = 14 }: { className?: string; size?: n ); } +function StreamIcon({ className, size = 14 }: { className?: string; size?: number; }) { + return ( + + + + ); +} + function VoiceMutedIcon({ className, size = 14 }: { className?: string; size?: number; }) { return ( ; isMessageInputEvent(event: DragEvent): boolean; - isAttachmentElement(target: Element | null): boolean; - isMessageInputElement(el: Element | null): boolean; - extractUserIdFromTarget(target: Element | null): string | null; - extractUserIdFromEvent(event: DragEvent): string | null; onUserDragStart(event: DragEvent, user?: { id: string; userId?: string; user?: { id: string; }; }): void; - extractAvatarUserIdFromTarget(target: HTMLElement | null): string | null; - extractChannelIdFromTarget(target: HTMLElement | null): { id: string; guildId?: string; } | null; - onChannelDragStart(event: DragEvent, channel?: Pick | { id: string; guildId?: string; }): void; - extractGuildIdFromTarget(target: HTMLElement): string | null; + onChannelDragStart(event: DragEvent, channel?: ChannelTarget): void; + onDmDragStart(event: DragEvent, channel?: Channel | null): void; + onGuildDragStart(event: DragEvent, guildId: string): void; + beginDrag(event: DragEvent, entity: DropEntity, options?: { effectAllowed?: DataTransfer["effectAllowed"]; }): void; showGhost(entity: DropEntity, event?: DragEvent): void; + getDmRecipientId(channel?: Channel | null): string | null; }; let pluginInstance: DragifyRuntime | null = null; -let activeGuildDragId: string | null = null; -let activeUserDragId: string | null = null; -let activeDragEntity: DropEntity | null = null; -let lastDropAt = 0; -let lastHandledDrop: { at: number; key: string; } = { at: 0, key: "" }; -let lastDragEventAt = 0; -let guildGhostCleanupTimer: number | null = null; -let dragifyActive = false; -let dragStateWatchdog: number | null = null; -let dragSourceIsInput = false; -const dropDedupeWindowMs = 150; let transparentDragImage: HTMLCanvasElement | null = null; function getTransparentDragImage(): HTMLCanvasElement | null { @@ -64,11 +54,35 @@ function suppressDefaultDragPreview(event: DragEvent) { event.dataTransfer.setDragImage(image, 0, 0); } -function shouldIgnoreDrop(key: string): boolean { - const now = Date.now(); - if (lastHandledDrop.key === key && now - lastHandledDrop.at < dropDedupeWindowMs) return true; - lastHandledDrop = { key, at: now }; - return false; +function setDragifyDataTransfer(dataTransfer: DataTransfer | null, entity: DropEntity) { + if (!dataTransfer) return; + + if (dataTransfer.clearData) { + dataTransfer.clearData("text/plain"); + dataTransfer.clearData("text/uri-list"); + dataTransfer.clearData("text/html"); + } + + const payload = serializeDragEntity(entity); + dataTransfer.setData("application/json", payload); + dataTransfer.setData("application/dragify", payload); + dataTransfer.setData("text/plain", ""); +} + +function hasDragifyTransfer(dataTransfer?: DataTransfer | null) { + return dataTransfer?.types?.includes("application/dragify"); +} + +function buildDropKey(entity: DropEntity, channelId: string) { + return `${entity.kind}:${entity.id}:${channelId}`; +} + +function inspectDragTarget(event: DragEvent, runtime: Pick): ResolvedDragTarget { + return inspectDragEvent(event, { + ChannelStore, + UserStore, + getDmRecipientId: runtime.getDmRecipientId, + }); } const settings = definePluginSettings({ @@ -132,31 +146,6 @@ const settings = definePluginSettings({ }, }); -function clearDragState() { - activeUserDragId = null; - activeGuildDragId = null; - activeDragEntity = null; - dragifyActive = false; - dragSourceIsInput = false; -} - -function setDragifyDataTransfer(dataTransfer: DataTransfer | null, payload: string) { - if (!dataTransfer) return; - if (dataTransfer.clearData) { - dataTransfer.clearData("text/plain"); - dataTransfer.clearData("text/uri-list"); - dataTransfer.clearData("text/html"); - } - dataTransfer.setData("application/json", payload); - dataTransfer.setData("application/dragify", payload); - dataTransfer.setData("text/plain", ""); -} - -function hasDragifyTransfer(dataTransfer?: DataTransfer | null) { - return dataTransfer?.types?.includes("application/dragify"); -} -const inviteCache = new Map(); - export default definePlugin({ name: "Dragify", description: "Drop users, channels, or servers into chat to insert mentions or invites.", @@ -263,63 +252,34 @@ export default definePlugin({ ], async onDrop(event: DragEvent, channel?: Channel | null) { - if (dragSourceIsInput) return; + if (isInputDragSource()) return; const { dataTransfer } = event; if (!dataTransfer || dataTransfer.files?.length) return; - const dragifyData = dataTransfer.getData("application/dragify"); - const hasActiveEntity = activeDragEntity || activeUserDragId || activeGuildDragId; - if (dragifyData || hasActiveEntity) { - lastDropAt = Date.now(); - event.preventDefault(); - event.stopPropagation(); - } - const resolvedChannel = channel ?? (SelectedChannelStore?.getChannelId?.() ? ChannelStore.getChannel(SelectedChannelStore.getChannelId()) : null); if (!resolvedChannel) return; - if (dragifyData) { - const parsed = tryParseJson<{ kind?: string; id?: string; guildId?: string; }>(dragifyData); - if (parsed?.kind && parsed.id) { - let fromDragify: DropEntity | null = null; - switch (parsed.kind) { - case "user": - fromDragify = { kind: "user", id: parsed.id }; - break; - case "channel": - fromDragify = { kind: "channel", id: parsed.id, guildId: parsed.guildId }; - break; - case "guild": - fromDragify = { kind: "guild", id: parsed.id }; - break; - } - if (fromDragify) { - const key = `${fromDragify.kind}:${fromDragify.id}:${resolvedChannel.id}`; - if (shouldIgnoreDrop(key)) return; - await this.handleDropEntity(fromDragify, resolvedChannel); - return; - } + if (hasDragifyTransfer(dataTransfer)) { + const entity = parseDragifyPayload(dataTransfer.getData("application/dragify")); + if (!entity) { + clearDragState(); + return; } - } - - const payloads = await collectPayloadStrings(dataTransfer); - let entity = parseFromStrings(payloads, { ChannelStore, GuildStore, UserStore }); - if (!entity && activeDragEntity) { - entity = activeDragEntity; - } else if (!entity && activeUserDragId) { - entity = { kind: "user", id: activeUserDragId }; - } else if (!entity && activeGuildDragId) { - entity = { kind: "guild", id: activeGuildDragId }; - } - if (!entity) { + if (shouldIgnoreDrop(buildDropKey(entity, resolvedChannel.id))) return; + await this.handleDropEntity(entity, resolvedChannel); return; } - const key = `${entity.kind}:${entity.id}:${resolvedChannel.id}`; - if (shouldIgnoreDrop(key)) return; + const payloads = await collectPayloadStrings(dataTransfer); + const entity = parseFromStrings(payloads, { ChannelStore, GuildStore, UserStore }); + if (!entity) { + if (hasActiveDrag()) clearDragState(); + return; + } + if (shouldIgnoreDrop(buildDropKey(entity, resolvedChannel.id))) return; await this.handleDropEntity(entity, resolvedChannel); }, @@ -330,15 +290,15 @@ export default definePlugin({ clearDragState(); return; } - let inserted = false; - if (entity.kind === "user") { - inserted = this.insertText(channel.id, text, { removeUnknownUser: true }); - } else { - inserted = this.insertText(channel.id, text); - } + + const inserted = entity.kind === "user" + ? this.insertText(channel.id, text, { removeUnknownUser: true }) + : this.insertText(channel.id, text); + if (!inserted) throw new Error("Unable to insert drag content"); clearDragState(); } catch (error) { + clearDragState(); logger.error("Failed handling drop", error); showToast("Dragify failed to handle drop.", Toasts.Type.FAILURE); } @@ -351,7 +311,7 @@ export default definePlugin({ case "channel": return this.formatChannel(entity.id, entity.guildId); case "guild": - return await this.createInvite(entity.id, currentChannel); + return await createInvite(entity.id, currentChannel, settings.store); default: return null; } @@ -360,327 +320,107 @@ export default definePlugin({ formatChannel(channelId: string, guildId?: string): string | null { const channel = ChannelStore.getChannel(channelId); if (!channel) return null; - if (channel.isGroupDM() || channel.isMultiUserDM()) { - return settings.store.userOutput === "id" - ? channelId - : `https://discord.com/channels/@me/${channelId}`; + if (isGroupMessageChannel(channel)) { + return `https://discord.com/channels/@me/${channelId}`; } if (settings.store.channelOutput === "link") { - const effectiveGuildId = guildId ?? channel?.guild_id ?? "@me"; + const effectiveGuildId = guildId ?? channel.guild_id ?? "@me"; return `https://discord.com/channels/${effectiveGuildId}/${channelId}`; } return `<#${channelId}>`; }, - async createInvite(guildId: string, currentChannel: Channel): Promise { - const inviteChannel = this.findInviteChannel(guildId, currentChannel); - const fallbackChannelId = inviteChannel ? null : await this.fetchInviteChannelId(guildId); - - if (settings.store.reuseExistingInvites) { - const cached = inviteCache.get(guildId); - if (cached && cached.maxUses === null && !this.isInviteExpired(cached)) return `https://discord.gg/${cached.code}`; - const reused = await this.fetchReusableInvite(guildId, inviteChannel ?? null); - if (reused) return `https://discord.gg/${reused}`; - } - - const inviteChannelId = inviteChannel?.id ?? fallbackChannelId; - if (!inviteChannelId) { - showToast("No channel available for invites.", Toasts.Type.FAILURE); - return null; - } - if (inviteChannel && inviteChannel.guild_id !== guildId) { - showToast("No channel available for invites.", Toasts.Type.FAILURE); - return null; - } - - try { - const maxAge = settings.store.inviteExpireAfter ?? 0; - const maxUses = settings.store.inviteMaxUses ?? 0; - const { body } = await RestAPI.post({ - url: `/channels/${inviteChannelId}/invites`, - body: { - max_age: maxAge, - max_uses: maxUses, - temporary: settings.store.inviteTemporaryMembership, - unique: true, - }, - }); - const code = typeof body === "object" && body ? (body as { code?: string; }).code : null; - if (!code) throw new Error("Invite response missing code"); - inviteCache.set(guildId, { - code, - expiresAt: maxAge > 0 ? Date.now() + maxAge * 1000 : null, - maxUses: maxUses === 0 ? null : maxUses, - uses: 0, - }); - showToast("Invite created.", Toasts.Type.SUCCESS); - return `https://discord.gg/${code}`; - } catch (error) { - logger.error("Failed to create invite", error); - showToast("Unable to create invite.", Toasts.Type.FAILURE); // uh oh! - return null; - } - }, - - async fetchInviteChannelId(guildId: string): Promise { - try { - const { body } = await RestAPI.get({ url: `/guilds/${guildId}/channels` }); - if (!Array.isArray(body)) return null; - - const candidates = body - .filter(ch => ch && ch?.id) - .filter(ch => { - const { type } = ch; - return type === ChannelType.GUILD_TEXT - || type === ChannelType.GUILD_ANNOUNCEMENT - || type === ChannelType.GUILD_FORUM - || type === ChannelType.GUILD_MEDIA; - }) - .sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); - - return candidates[0]?.id ?? null; - } catch { - return null; - } - }, - - async fetchReusableInvite(guildId: string, inviteChannel: Channel | null): Promise { - const cached = inviteCache.get(guildId); - if (cached && cached.maxUses === null && !this.isInviteExpired(cached)) return cached.code; - - try { - const channelId = inviteChannel?.id ?? null; - const url = channelId ? `/channels/${channelId}/invites` : `/guilds/${guildId}/invites`; - - const { body } = await RestAPI.get({ url }); - if (!Array.isArray(body)) return null; - - const now = Date.now(); - const invite = body.find(inv => { - const expiresAt = inv.expires_at ? Date.parse(inv.expires_at) : null; - const maxUsesRaw = inv.max_uses ?? null; - const maxUses = maxUsesRaw === 0 ? null : maxUsesRaw; - const uses = inv.uses ?? null; - const notExpired = expiresAt === null || expiresAt > now; - const usesLeft = maxUses === null || uses === null || uses < maxUses; - return notExpired && usesLeft && typeof inv.code === "string"; - }); - - if (invite?.code) { - inviteCache.set(guildId, { - code: invite.code, - expiresAt: invite.expires_at ? Date.parse(invite.expires_at) : null, - maxUses: invite.max_uses === 0 ? null : invite.max_uses ?? null, - uses: invite.uses ?? null, - }); - return invite.code; - } - } catch { - // If we cannot list invites (permissions/403), fall back silently. - return null; - } - - return null; - }, - - isInviteExpired(invite: { expiresAt: number | null; maxUses: number | null; uses: number | null; }): boolean { - const now = Date.now(); - const expired = invite.expiresAt !== null && invite.expiresAt <= now; - const exhausted = invite.maxUses !== null && invite.uses !== null && invite.uses >= invite.maxUses; - return expired || exhausted; - }, - - findInviteChannel(guildId: string, currentChannel: Channel): Channel | null { - if (currentChannel.guild_id === guildId && this.canCreateInvite(currentChannel)) return currentChannel; - - const guild = GuildStore.getGuild(guildId); - const preferredIds = [ - guild?.systemChannelId, - guild?.rulesChannelId, - guild?.publicUpdatesChannelId, - ].filter(Boolean) as string[]; - for (const id of preferredIds) { - const channel = ChannelStore.getChannel(id); - if (this.canCreateInvite(channel)) return channel; - } - - const selectableStore = (GuildChannelStore.getSelectableChannels?.(guildId) ?? []) - .map(e => e.channel) - .filter(Boolean) as Channel[]; - const selectableCollection = (() => { - const collection = GuildChannelStore.getChannels(guildId); - if (!collection?.SELECTABLE) return []; - - const result: Channel[] = []; - for (const entry of Object.values(collection.SELECTABLE)) { - const channel = entry && entry?.channel ? entry.channel : entry; - if (channel) result.push(channel); - } - return result; - })(); - - const byId = new Map(); - for (const ch of [...selectableStore, ...selectableCollection]) { - if (!ch || ch.guild_id !== guildId) continue; - if (!byId.has(ch.id)) byId.set(ch.id, ch); - } - const candidates = [...byId.values()] - .sort((a, b) => { - const pa = typeof a.position === "number" ? a.position : 0; - const pb = typeof b.position === "number" ? b.position : 0; - if (pa === pb) return a.id.localeCompare(b.id); - return pa - pb; - }); - - for (const channel of candidates) { - if (this.canCreateInvite(channel)) return channel; - } - - return null; - }, - - canCreateInvite(channel?: Channel | null): channel is Channel { - if (!channel || !channel.guild_id) return false; - if (channel.isDM() || channel.isGroupDM() || channel.isMultiUserDM()) return false; - if (channel.isCategory()) return false; - if (channel.isThread()) return false; - return PermissionStore.can(PermissionsBits.CREATE_INSTANT_INVITE, channel); - }, - insertText(channelId: string, text: string, options?: { removeUnknownUser?: boolean; }): boolean { - let insertedIntoInput = false; - let insertedIntoDraft = false; - try { - insertTextIntoChatInputBox(text); - insertedIntoInput = true; + const nextText = options?.removeUnknownUser + ? text.replace(/@unknown[- ]user/gi, "").trim() + : text; + if (!nextText) return false; + insertTextIntoChatInputBox(nextText); + return true; } catch (error) { logger.error("Failed to insert text into input", error); + return false; } - - try { - let existing = DraftStore.getDraft(channelId, DraftType.ChannelMessage) ?? ""; - if (options?.removeUnknownUser) existing = existing.replace(/@unknown[- ]user/gi, "").trim(); - - const needsSpace = existing.length > 0 && !existing.endsWith(" "); - const nextValue = needsSpace ? `${existing} ${text}` : `${existing}${text}`; - - !existing ? DraftActions.saveDraft(channelId, nextValue, DraftType.ChannelMessage) : DraftActions.changeDraft(channelId, nextValue, DraftType.ChannelMessage); - insertedIntoDraft = true; - } catch (error) { - logger.error("Failed to update draft", error); - } - - return insertedIntoInput || insertedIntoDraft; }, - onChannelDragStart(event: DragEvent, channel?: Pick | { id: string; guildId?: string; }) { - if (activeUserDragId) return; - const existingDragify = event.dataTransfer?.getData("application/dragify") ?? ""; - if (existingDragify) { - const parsed = tryParseJson<{ kind?: string; }>(existingDragify); - if (parsed?.kind === "user") return; - } - const targetEl = event.target as HTMLElement | null; - if (targetEl?.closest?.("[data-dragify-user]")) return; + beginDrag(event: DragEvent, entity: DropEntity, options?: { effectAllowed?: DataTransfer["effectAllowed"]; }) { + if (!event.dataTransfer) return; + suppressDefaultDragPreview(event); + if (options?.effectAllowed) event.dataTransfer.effectAllowed = options.effectAllowed; + beginSessionDrag(entity); + this.showGhost(entity, event); + setDragifyDataTransfer(event.dataTransfer, entity); + if (entity.kind === "user") event.dataTransfer.setData("data-user-id", entity.id); + }, - const targetResolved = this.extractChannelIdFromTarget(event.target as HTMLElement | null); - const resolved = - channel && "id" in channel && channel.id - ? channel - : targetResolved; + onChannelDragStart(event: DragEvent, channel?: ChannelTarget) { + if (isUserDragActive()) return; + + const existing = parseDragifyPayload(event.dataTransfer?.getData("application/dragify") ?? ""); + if (existing?.kind === "user") return; + + const inspection = inspectDragTarget(event, this); + if (inspection.hasDragifyUser) return; + + const resolved = channel ?? inspection.channel; if (!resolved?.id) return; - const channelObj = ChannelStore.getChannel(resolved.id); - if (!channelObj) return; - const resolvedGuildId = resolved ? ("guild_id" in resolved ? resolved.guild_id : resolved.guildId) : undefined; - const guildId = resolvedGuildId ?? targetResolved?.guildId ?? channelObj?.guild_id; - const isDirectMessage = channelObj.isDM(); - if (isDirectMessage) { - const recipientId = this.getDmRecipientId(channelObj); - if (recipientId) { - this.onUserDragStart(event, { id: recipientId }); - return; - } - } - suppressDefaultDragPreview(event); - this.showGhost({ kind: "channel", id: channelObj.id, guildId }, event); - const payload = JSON.stringify({ kind: "channel", id: channelObj.id, guildId }); - activeDragEntity = { kind: "channel", id: channelObj.id, guildId }; - dragifyActive = true; - lastDragEventAt = Date.now(); - setDragifyDataTransfer(event.dataTransfer ?? null, payload); - }, - - onUserDragStart(event: DragEvent, user?: { id: string; userId?: string; user?: { id: string; }; }) { - if (this.isAttachmentElement(event.target as HTMLElement | null)) return; - const searchTarget = event.target as HTMLElement | null; - if (searchTarget?.closest) { - const chatMessage = searchTarget.closest("[data-author-id]") as HTMLElement | null; - if (chatMessage) { - const authorId = chatMessage.getAttribute("data-author-id"); - const parsed = authorId ? extractSnowflakeFromString(authorId) : null; - if (parsed) { - user = { id: parsed }; - } - } - } - const currentTarget = (event as unknown as { currentTarget?: EventTarget | null; }).currentTarget as HTMLElement | null; - const target = (event.target as HTMLElement | null) ?? null; - const userIdFromParam = user?.id ?? user?.userId ?? user?.user?.id ?? null; - const userIdFromDom = - this.extractUserIdFromTarget(currentTarget) - ?? this.extractUserIdFromTarget(target) - ?? null; - const userIdFromEvent = this.extractUserIdFromEvent(event); - const rawUserId = - userIdFromParam - ?? userIdFromDom - ?? userIdFromEvent - ?? event.dataTransfer?.getData("data-user-id") - ?? null; - const userId = rawUserId ? (extractSnowflakeFromString(rawUserId) ?? rawUserId) : null; - if (!userId) return; - const payload = JSON.stringify({ kind: "user", id: userId }); - activeUserDragId = userId; - activeDragEntity = { kind: "user", id: userId }; - dragifyActive = true; - lastDragEventAt = Date.now(); - suppressDefaultDragPreview(event); - this.showGhost({ kind: "user", id: userId }, event); - setDragifyDataTransfer(event.dataTransfer ?? null, payload); - event.dataTransfer?.setData("data-user-id", userId); - }, - - onDmDragStart(event: DragEvent, channel?: Channel | null) { - let resolvedChannel = channel ?? null; - if (!resolvedChannel) { - const targetChannel = this.extractChannelIdFromTarget(event.target as HTMLElement | null); - if (targetChannel?.id) resolvedChannel = ChannelStore.getChannel(targetChannel.id) ?? null; - } + const resolvedChannel = ChannelStore.getChannel(resolved.id); if (!resolvedChannel) return; - const shouldDragChannel = resolvedChannel.isGroupDM() && resolvedChannel.isMultiUserDM(); - if (!shouldDragChannel) { + + if (resolvedChannel.isDM() && !isGroupMessageChannel(resolvedChannel)) { const recipientId = this.getDmRecipientId(resolvedChannel); if (recipientId) { this.onUserDragStart(event, { id: recipientId }); - return; } + return; } - this.onChannelDragStart(event, resolvedChannel); + this.beginDrag(event, { + kind: "channel", + id: resolvedChannel.id, + guildId: resolved.guildId ?? resolvedChannel.guild_id ?? undefined, + }); + }, + + onUserDragStart(event: DragEvent, user?: { id: string; userId?: string; user?: { id: string; }; }) { + const inspection = inspectDragTarget(event, this); + if (inspection.hasAttachment) return; + + const rawUserId = + inspection.authorId + ?? user?.id + ?? user?.userId + ?? user?.user?.id + ?? inspection.userId + ?? null; + const userId = rawUserId?.trim(); + if (!userId) return; + + this.beginDrag(event, { kind: "user", id: userId }); + }, + + onDmDragStart(event: DragEvent, channel?: Channel | null) { + const inspection = inspectDragTarget(event, this); + const resolvedChannel = channel ?? (inspection.channel?.id ? ChannelStore.getChannel(inspection.channel.id) ?? null : null); + if (!resolvedChannel) return; + + if (isGroupMessageChannel(resolvedChannel)) { + this.onChannelDragStart(event, { + id: resolvedChannel.id, + guildId: resolvedChannel.guild_id ?? undefined, + }); + return; + } + + const recipientId = this.getDmRecipientId(resolvedChannel); + if (!recipientId) return; + this.onUserDragStart(event, { id: recipientId }); }, onGuildDragStart(event: DragEvent, guildId: string) { - suppressDefaultDragPreview(event); - this.showGhost({ kind: "guild", id: guildId }, event); - const payload = JSON.stringify({ kind: "guild", id: guildId }); - if (event.dataTransfer) event.dataTransfer.effectAllowed = "copyMove"; - activeGuildDragId = guildId; - activeDragEntity = { kind: "guild", id: guildId }; - dragifyActive = true; - lastDragEventAt = Date.now(); - setDragifyDataTransfer(event.dataTransfer ?? null, payload); + this.beginDrag(event, { kind: "guild", id: guildId }, { effectAllowed: "copyMove" }); }, start() { @@ -692,17 +432,7 @@ export default definePlugin({ window.addEventListener("drag", this.globalDragMove, true); window.addEventListener("dragover", this.globalDragMove, true); window.addEventListener("dragend", this.globalDragEnd, true); - if (dragStateWatchdog === null) { - dragStateWatchdog = window.setInterval(() => { - if (!dragifyActive) return; - if (Date.now() - lastDragEventAt < 1200) return; - activeUserDragId = null; - activeGuildDragId = null; - activeDragEntity = null; - dragifyActive = false; - hideDragGhost(); - }, 500); - } + startDragWatchdog(hideDragGhost); }, stop() { @@ -712,16 +442,8 @@ export default definePlugin({ window.removeEventListener("drag", this.globalDragMove, true); window.removeEventListener("dragover", this.globalDragMove, true); window.removeEventListener("dragend", this.globalDragEnd, true); - clearDragState(); - inviteCache.clear(); - if (guildGhostCleanupTimer !== null) { - clearTimeout(guildGhostCleanupTimer); - guildGhostCleanupTimer = null; - } - if (dragStateWatchdog !== null) { - clearInterval(dragStateWatchdog); - dragStateWatchdog = null; - } + stopDragState(); + clearInviteCache(); this.unmountGhost(); pluginInstance = null; }, @@ -729,35 +451,48 @@ export default definePlugin({ globalDragOver: (event: DragEvent) => { const inst = pluginInstance; if (!inst || !inst.isMessageInputEvent(event)) return; - if (dragSourceIsInput) return; - if (event.dataTransfer?.files?.length) return; - const hasActiveGuildDrag = activeGuildDragId !== null; - const hasActiveEntity = activeDragEntity || activeUserDragId; - const shouldHandle = hasActiveGuildDrag || hasActiveEntity || dragifyActive || hasDragifyTransfer(event.dataTransfer); - if (!shouldHandle) return; + if (isInputDragSource()) return; + const { dataTransfer } = event; + if (!dataTransfer || dataTransfer.files?.length) return; + if (!hasActiveDrag() && !hasDragifyTransfer(dataTransfer)) { + const payloads = extractStrings(dataTransfer); + if (!parseFromStrings(payloads, { ChannelStore, GuildStore, UserStore })) return; + } + event.preventDefault(); event.stopPropagation(); - if (event.dataTransfer) event.dataTransfer.dropEffect = "copy"; + dataTransfer.dropEffect = "copy"; }, globalDrop: async (event: DragEvent) => { const inst = pluginInstance; if (!inst || !inst.isMessageInputEvent(event)) return; - if (dragSourceIsInput) return; - if (event.dataTransfer?.files?.length) { + if (isInputDragSource()) return; + const { dataTransfer } = event; + if (!dataTransfer) return; + + if (dataTransfer.files?.length) { clearDragState(); + hideDragGhost(); return; } + + const hasDragifyPayload = hasDragifyTransfer(dataTransfer); + const hasActiveSessionDrag = hasActiveDrag(); + if (!hasDragifyPayload && !hasActiveSessionDrag) { + const payloads = await collectPayloadStrings(dataTransfer); + if (!parseFromStrings(payloads, { ChannelStore, GuildStore, UserStore })) return; + } + const channelId = SelectedChannelStore.getChannelId(); const channel = ChannelStore.getChannel(channelId); - if (!channel) return; + if (!channel) { + clearDragState(); + hideDragGhost(); + return; + } - const types = event.dataTransfer?.types ?? []; - const hasDragify = types.includes("application/dragify"); - const hasAnyActiveDrag = activeGuildDragId !== null || activeUserDragId !== null || activeDragEntity !== null || dragifyActive; - if (!hasDragify && !hasAnyActiveDrag) return; - - lastDropAt = Date.now(); + markDrop(); event.preventDefault(); event.stopPropagation(); await inst.onDrop(event, channel); @@ -768,342 +503,69 @@ export default definePlugin({ const inst = pluginInstance; if (!inst || !event.dataTransfer) return; - const target = event.target as HTMLElement | null; - if (!target) return; - const hasDragify = event.dataTransfer.types?.includes("application/dragify") ?? false; - const path = event.composedPath?.() ?? []; - if (inst.isAttachmentElement(target)) return; - if (inst.isMessageInputElement(target)) { - clearDragState(); - dragSourceIsInput = true; + const inspection = inspectDragTarget(event, inst); + if (inspection.hasAttachment) return; + if (inspection.hasMessageInput) { + markInputDragSource(); return; } - for (const entry of path) { - const el = entry as Element | null; - if (!el) continue; - if (inst.isAttachmentElement(el)) return; - if (inst.isMessageInputElement(el)) { - clearDragState(); - dragSourceIsInput = true; + if (hasDragifyTransfer(event.dataTransfer)) return; + + const markedUserId = inspection.authorId ?? inspection.userId; + if (inspection.hasDragifyUser && markedUserId) { + inst.onUserDragStart(event, { id: markedUserId }); + return; + } + + if (inspection.hasTextNode && inspection.authorId) { + inst.onUserDragStart(event, { id: inspection.authorId }); + return; + } + + if (inspection.authorId && inspection.avatarUserId) { + inst.onUserDragStart(event, { id: inspection.authorId }); + return; + } + + if (inspection.channel && !inspection.hasUserMarker) { + const channel = ChannelStore.getChannel(inspection.channel.id); + if (channel && (channel.isDM() || isGroupMessageChannel(channel))) { + inst.onDmDragStart(event, channel); return; } - } - if (!hasDragify) { - for (const entry of path) { - const el = entry as HTMLElement | null; - if (!el) continue; - const userTarget = el.closest?.("[data-dragify-user]"); - if (!userTarget) continue; - const userId = inst.extractUserIdFromTarget(userTarget); - if (userId) { - inst.onUserDragStart(event, { id: userId }); - } - return; - } - } - - const authorContainer = target.closest?.("[data-author-id]"); - const authorId = authorContainer?.getAttribute?.("data-author-id") ?? null; - if (authorId && target.getAttribute?.("data-text") && !hasDragify) { - inst.onUserDragStart(event, { id: authorId }); + inst.onChannelDragStart(event, inspection.channel); return; } - if (!hasDragify) { - const dragifyTarget = target.closest?.("[data-dragify-user]"); - if (dragifyTarget) { - const userIdFromTarget = inst.extractUserIdFromEvent(event) ?? inst.extractUserIdFromTarget(dragifyTarget); - if (userIdFromTarget) { - inst.onUserDragStart(event, { id: userIdFromTarget }); - return; - } - } - } - - if (!hasDragify) { - const avatarUserId = inst.extractAvatarUserIdFromTarget(target); - if (avatarUserId) { - const messageItem = target.closest?.("[data-author-id]") as HTMLElement | null; - const authorId = messageItem?.getAttribute("data-author-id") ?? null; - if (authorId) { - inst.onUserDragStart(event, { id: authorId }); - return; - } - const channelFromTarget = inst.extractChannelIdFromTarget(target); - if (channelFromTarget) { - inst.onChannelDragStart(event, channelFromTarget); - return; - } - } - } - - const channelFromTarget = inst.extractChannelIdFromTarget(target); - if (channelFromTarget && !hasDragify) { - if (target.closest?.("[data-user-id]")) return; - inst.onChannelDragStart(event, channelFromTarget); - return; - } - - const guildId = inst.extractGuildIdFromTarget(target); - if (!guildId) return; - - if (!hasDragify) { - event.dataTransfer.effectAllowed = "copyMove"; - suppressDefaultDragPreview(event); - activeGuildDragId = guildId; - setDragifyDataTransfer(event.dataTransfer, JSON.stringify({ kind: "guild", id: guildId })); - inst.showGhost({ kind: "guild", id: guildId }, event); + if (inspection.guildId) { + inst.onGuildDragStart(event, inspection.guildId); } }, + globalDragEnd: (_event: DragEvent) => { setTimeout(() => { - if (Date.now() - lastDropAt < 100) return; + if (Date.now() - getLastDropAt() < 100) return; clearDragState(); hideDragGhost(); }, 0); }, + globalDragMove: (event: DragEvent | MouseEvent) => { if (!isGhostVisible()) return; - if (event?.clientX == null || event?.clientY == null) return; + if (event.clientX == null || event.clientY == null) return; + if (event instanceof DragEvent) { - lastDragEventAt = Date.now(); - if (activeGuildDragId !== null || activeDragEntity?.kind === "guild") { - if (guildGhostCleanupTimer !== null) clearTimeout(guildGhostCleanupTimer); - guildGhostCleanupTimer = window.setTimeout(() => { - if (Date.now() - lastDragEventAt < 200) return; - activeGuildDragId = null; - if (activeDragEntity?.kind === "guild") activeDragEntity = null; - hideDragGhost(); - }, 300); - } + touchDrag(); + if (isGuildDragActive()) scheduleGuildCleanup(hideDragGhost); } + scheduleDragGhostPosition(event.clientX + 16, event.clientY + 20); }, + isMessageInputEvent(event: DragEvent): boolean { - const target = event.target as Element | null; - if (this.isMessageInputElement(target)) return true; - if (settings.store.allowChatBodyDrop && this.isChatBodyElement(target)) return true; - - const path = event.composedPath?.() ?? []; - for (const entry of path) { - const el = entry as Element | null; - if (this.isMessageInputElement(el)) return true; - if (settings.store.allowChatBodyDrop && this.isChatBodyElement(el)) return true; - } - - if (typeof document !== "undefined" && event?.clientX != null && event?.clientY != null) { - const elements = document.elementsFromPoint?.(event.clientX, event.clientY); - if (elements && elements.length) { - for (const el of elements) { - if (this.isMessageInputElement(el)) return true; - if (settings.store.allowChatBodyDrop && this.isChatBodyElement(el)) return true; - } - } else { - const atPoint = document.elementFromPoint(event.clientX, event.clientY); - if (this.isMessageInputElement(atPoint)) return true; - if (settings.store.allowChatBodyDrop && this.isChatBodyElement(atPoint)) return true; - } - } - - return false; - }, - - isMessageInputElement(el: Element | null): boolean { - if (!el) return false; - const selector = "[data-slate-editor],[role=\"textbox\"],[contenteditable=\"true\"],[aria-label^=\"Message \"]"; - return Boolean((el as HTMLElement).closest?.(selector)); - }, - - isChatBodyElement(el: Element | null): boolean { - if (!el) return false; - const selector = "[role=\"log\"],[data-list-id^=\"chat-messages\"]"; - return Boolean((el as HTMLElement).closest?.(selector)); - }, - - isAttachmentElement(target: Element | null): boolean { - let el: Element | null = target; - while (el) { - if (!(el instanceof HTMLElement)) { - el = el.parentElement ?? null; - continue; - } - const href = el.getAttribute("href") ?? ""; - const src = el.getAttribute("src") ?? ""; - const style = el.style?.backgroundImage ?? ""; - const dataAttachment = - el.getAttribute("data-attachment-id") - ?? el.getAttribute("data-attachment-type") - ?? el.getAttribute("data-attachment-item-id") - ?? ""; - - if (dataAttachment) return true; - - const combined = `${href} ${src} ${style}`; - if (/(?:cdn|media)\.discordapp\.(?:com|net)\/attachments\//i.test(combined)) return true; - if (/discord\.com\/attachments\//i.test(combined)) return true; - if (/cdn\.discordapp\.com\/ephemeral-attachments\//i.test(combined)) return true; - - el = el.parentElement; - } - return false; - }, - - extractGuildIdFromTarget(target: HTMLElement): string | null { - let el: HTMLElement | null = target; - while (el) { - const listId = el.getAttribute("data-list-id"); - const rawId = el.getAttribute("data-list-item-id") ?? ""; - const isGuildContext = (listId && /guild/i.test(listId)) || /guild/i.test(rawId); - - if (!isGuildContext) { - el = el.parentElement; - continue; - } - - const parts = rawId.split("___"); - const candidate = parts[parts.length - 1] ?? rawId; - if (/^\d{17,20}$/.test(candidate)) return candidate; - - const direct = - extractSnowflakeFromString(rawId) ?? - extractSnowflakeFromString(listId ?? "") ?? - extractSnowflakeFromString(el.getAttribute("data-guild-id") ?? ""); - if (direct) return direct; - - el = el.parentElement; - } - return null; - }, - - extractChannelIdFromTarget(target: HTMLElement | null): { id: string; guildId?: string; } | null { - let el = target; - while (el) { - const listId = el.getAttribute("data-list-id") ?? ""; - const rawId = el.getAttribute("data-list-item-id") ?? ""; - const channelIdAttr = el.getAttribute("data-channel-id") ?? el.getAttribute("data-item-id") ?? ""; - const threadIdAttr = el.getAttribute("data-thread-id") ?? ""; - const href = el.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) { - const guildId = pathMatch.guildId === "@me" ? undefined : pathMatch.guildId; - return { id: pathMatch.channelId, guildId }; - } - - const fullMatch = extractChannelFromUrl(href); - if (fullMatch?.channelId) { - const guildId = fullMatch.guildId === "@me" ? undefined : fullMatch.guildId; - return { id: fullMatch.channelId, guildId }; - } - - const candidate = isChannelContext - ? (extractSnowflakeFromString(threadIdAttr) - ?? extractSnowflakeFromString(channelIdAttr) - ?? extractSnowflakeFromString(rawId) - ?? extractSnowflakeFromString(listId)) - : null; - if (candidate) { - const guildId = extractSnowflakeFromString(el.getAttribute("data-guild-id") ?? "") ?? undefined; - return { id: candidate, guildId }; - } - - el = el.parentElement; - } - return null; - }, - - extractUserIdFromTarget(target: Element | null): string | null { - let el: Element | null = target; - while (el) { - if (!(el instanceof HTMLElement)) { - el = el.parentElement ?? null; - continue; - } - const dataUserId = el.getAttribute("data-user-id") ?? el.getAttribute("data-userid") ?? ""; - const dataAuthorId = el.getAttribute("data-author-id") ?? ""; - const listId = el.getAttribute("data-list-item-id") ?? el.getAttribute("data-item-id") ?? ""; - const href = el.getAttribute("href") ?? ""; - const src = el.getAttribute("src") ?? ""; - const aria = el.getAttribute("aria-label") ?? ""; - - const explicit = extractSnowflakeFromString(dataUserId) - ?? extractSnowflakeFromString(dataAuthorId); - if (explicit) return explicit; - - const listCandidate = extractSnowflakeFromString(listId); - if (listCandidate) { - if (UserStore.getUser(listCandidate)) return listCandidate; - const channel = ChannelStore.getChannel(listCandidate); - if (channel && channel.isDM() && !channel.isGroupDM() && !channel.isMultiUserDM()) { - const recipientId = this.getDmRecipientId(channel); - if (recipientId) return recipientId; - } - } - - const profile = extractUserFromProfile(href); - if (profile) return profile; - - const avatar = extractUserFromAvatar(src); - if (avatar) return avatar; - - const styleAttr = el.getAttribute("style") ?? ""; - const bgImage = el.style?.backgroundImage ?? ""; - const styleAvatar = extractUserFromAvatar(styleAttr + " " + bgImage); - if (styleAvatar) return styleAvatar; - - const ariaMatch = extractSnowflakeFromString(aria); - if (ariaMatch && UserStore.getUser(ariaMatch)) return ariaMatch; - - el = el.parentElement; - } - return null; - }, - - extractAvatarUserIdFromTarget(target: HTMLElement | null): string | null { - let el: Element | null = target; - while (el) { - if (!(el instanceof HTMLElement)) { - el = el.parentElement ?? null; - continue; - } - const src = el.getAttribute("src") ?? ""; - const styleAttr = el.getAttribute("style") ?? ""; - const bgImage = el.style?.backgroundImage ?? ""; - const candidate = extractUserFromAvatar(`${src} ${styleAttr} ${bgImage}`); - if (candidate) return candidate; - el = el.parentElement; - } - return null; - }, - - extractUserIdFromEvent(event: DragEvent): string | null { - const path = event.composedPath?.() ?? []; - for (const entry of path) { - const candidate = entry instanceof HTMLElement ? this.extractUserIdFromTarget(entry) : null; - if (candidate) return candidate; - } - - const target = event.target as HTMLElement | null; - if (target) { - const direct = target.closest?.("[data-author-id],[data-user-id]") as HTMLElement | null; - if (direct) { - return this.extractUserIdFromTarget(direct); - } - } - - if (typeof document !== "undefined" && event?.clientX != null && event?.clientY != null) { - const elements = document.elementsFromPoint?.(event.clientX, event.clientY); - if (elements && elements.length) { - for (const el of elements) { - const candidate = el instanceof HTMLElement ? this.extractUserIdFromTarget(el) : null; - if (candidate) return candidate; - } - } - } - - return null; + const inspection = inspectDragTarget(event, this); + return inspection.hasMessageInput || (settings.store.allowChatBodyDrop && inspection.hasChatBody); }, mountGhost() { @@ -1117,7 +579,8 @@ export default definePlugin({ showGhost(entity: DropEntity, event?: DragEvent) { const ghost = this.buildGhost(entity); if (!ghost) return; - const position = event && event?.clientX != null && event?.clientY != null + + const position = event?.clientX != null && event.clientY != null ? { x: event.clientX + 16, y: event.clientY + 20 } : undefined; showDragGhost({ ...ghost, entityId: entity.id }, position); @@ -1127,6 +590,7 @@ export default definePlugin({ if (entity.kind === "user") { const user = UserStore.getUser(entity.id); if (!user) return null; + const title = user.globalName ?? user.username; const subtitle = user.username ? `@${user.username}` : "User"; const iconUrl = user.getAvatarURL(void 0, 80, true) ?? undefined; @@ -1136,13 +600,15 @@ export default definePlugin({ if (entity.kind === "channel") { const channel = ChannelStore.getChannel(entity.id); if (!channel) return null; + const isThread = channel.isThread(); const isGroupDm = channel.type === ChannelType.GROUP_DM; const channelName = this.getGroupDmDisplayName(channel) || channel.name; const title = `${isThread || isGroupDm ? "" : "#"}${channelName}`; - const guild = channel?.guild_id ? GuildStore.getGuild(channel.guild_id) : null; + const guild = channel.guild_id ? GuildStore.getGuild(channel.guild_id) : null; const subtitle = guild?.name ?? (entity.guildId ? "Server" : "Direct Messages"); let iconUrl: string | undefined; + if (guild?.icon) { iconUrl = IconUtils.getGuildIconURL({ id: guild.id, icon: guild.icon, size: 64 }) ?? undefined; } else if (channel.type === ChannelType.GROUP_DM) { @@ -1153,11 +619,13 @@ export default definePlugin({ }); iconUrl = channelIcon ?? undefined; } + if (!iconUrl && (channel.isDM() || channel.type === ChannelType.GROUP_DM)) { const recipientId = this.getDmRecipientId(channel); const recipient = recipientId ? UserStore.getUser(recipientId) : null; iconUrl = recipient ? recipient.getAvatarURL(void 0, 80, true) : undefined; } + let badge = "channel"; if (isThread) { badge = "thread"; @@ -1184,6 +652,7 @@ export default definePlugin({ badge = "channel"; } } + return { kind: "channel", title, subtitle, iconUrl, badge, entityId: entity.id, exiting: false }; } @@ -1207,9 +676,11 @@ export default definePlugin({ getGroupDmDisplayName(channel?: Channel | null): string | null { if (!channel || channel.type !== ChannelType.GROUP_DM) return null; + const selfId = UserStore.getCurrentUser()?.id ?? null; const recipients = (channel.recipients ?? channel.rawRecipients ?? []).filter(Boolean); if (recipients.length === 0) return null; + const names = recipients .filter(id => id !== selfId) .map(id => { diff --git a/src/equicordplugins/dragify/invite.ts b/src/equicordplugins/dragify/invite.ts new file mode 100644 index 000000000..66bfce9ac --- /dev/null +++ b/src/equicordplugins/dragify/invite.ts @@ -0,0 +1,189 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Logger } from "@utils/Logger"; +import type { Channel } from "@vencord/discord-types"; +import { ChannelType } from "@vencord/discord-types/enums"; +import { ChannelStore, GuildChannelStore, GuildStore, PermissionsBits, PermissionStore, RestAPI, showToast, Toasts } from "@webpack/common"; + +const logger = new Logger("Dragify"); + +export type InviteSettings = { + inviteExpireAfter?: number; + inviteMaxUses?: number; + inviteTemporaryMembership: boolean; + reuseExistingInvites: boolean; +}; + +export type InviteCacheEntry = { + code: string; + expiresAt: number | null; + maxUses: number | null; + uses: number | null; +}; + +const inviteCache = new Map(); + +export function clearInviteCache() { + inviteCache.clear(); +} + +function normalizeInviteCacheEntry(invite: { + code: string; + expires_at?: string | null; + max_uses?: number | null; + uses?: number | null; +}): InviteCacheEntry { + return { + code: invite.code, + expiresAt: invite.expires_at ? Date.parse(invite.expires_at) : null, + maxUses: invite.max_uses === 0 ? null : invite.max_uses ?? null, + uses: invite.uses ?? null, + }; +} + +function isInviteExpired(invite: InviteCacheEntry) { + const now = Date.now(); + const expired = invite.expiresAt !== null && invite.expiresAt <= now; + const exhausted = invite.maxUses !== null && invite.uses !== null && invite.uses >= invite.maxUses; + return expired || exhausted; +} + +function canCreateInvite(channel?: Channel | null): channel is Channel { + if (!channel || !channel.guild_id) return false; + if (channel.isDM() || channel.isGroupDM() || channel.isMultiUserDM()) return false; + if (channel.isCategory()) return false; + if (channel.isThread()) return false; + return PermissionStore.can(PermissionsBits.CREATE_INSTANT_INVITE, channel); +} + +function appendInviteCandidate(candidates: Map, channel?: Channel | null) { + if (!channel || !channel.guild_id || candidates.has(channel.id)) return; + candidates.set(channel.id, channel); +} + +function resolveInviteChannel(guildId: string, currentChannel: Channel): Channel | null { + if (currentChannel.guild_id === guildId && canCreateInvite(currentChannel)) return currentChannel; + + const guild = GuildStore.getGuild(guildId); + const preferredIds = [ + guild?.systemChannelId, + guild?.rulesChannelId, + guild?.publicUpdatesChannelId, + ].filter(Boolean) as string[]; + + for (const id of preferredIds) { + const channel = ChannelStore.getChannel(id); + if (canCreateInvite(channel)) return channel; + } + + const candidatesById = new Map(); + for (const entry of GuildChannelStore.getSelectableChannels?.(guildId) ?? []) { + appendInviteCandidate(candidatesById, entry?.channel); + } + + for (const entry of Object.values(GuildChannelStore.getChannels(guildId)?.SELECTABLE ?? {})) { + appendInviteCandidate(candidatesById, entry && "channel" in entry ? entry.channel : entry); + } + + return [...candidatesById.values()] + .filter(channel => channel.guild_id === guildId) + .sort((a, b) => { + const pa = typeof a.position === "number" ? a.position : 0; + const pb = typeof b.position === "number" ? b.position : 0; + if (pa === pb) return a.id.localeCompare(b.id); + return pa - pb; + }) + .find(canCreateInvite) ?? null; +} + +async function fetchReusableInvite(guildId: string, inviteChannelId: string) { + const cached = inviteCache.get(guildId); + if (cached && cached.maxUses === null && !isInviteExpired(cached)) return { ok: true as const, code: cached.code }; + + try { + const { body } = await RestAPI.get({ url: `/channels/${inviteChannelId}/invites` }); + if (!Array.isArray(body)) return { ok: false as const, reason: "failed" as const }; + + const now = Date.now(); + const invite = body.find(inv => { + const expiresAt = inv.expires_at ? Date.parse(inv.expires_at) : null; + const maxUses = inv.max_uses === 0 ? null : inv.max_uses ?? null; + const uses = inv.uses ?? null; + const notExpired = expiresAt === null || expiresAt > now; + const usesLeft = maxUses === null || uses === null || uses < maxUses; + return notExpired && usesLeft && typeof inv.code === "string"; + }); + + if (!invite?.code) return { ok: false as const, reason: "missing" as const }; + + inviteCache.set(guildId, normalizeInviteCacheEntry(invite)); + return { ok: true as const, code: invite.code }; + } catch (error) { + logger.error("Failed to reuse invite", error); + return { ok: false as const, reason: "failed" as const }; + } +} + +function getInviteUrl(code: string) { + return `https://discord.gg/${code}`; +} + +export async function createInvite(guildId: string, currentChannel: Channel, settings: InviteSettings): Promise { + const inviteChannel = resolveInviteChannel(guildId, currentChannel); + if (!inviteChannel) { + showToast("No channel available for invites.", Toasts.Type.FAILURE); + return null; + } + + if (settings.reuseExistingInvites) { + const reused = await fetchReusableInvite(guildId, inviteChannel.id); + if (reused.ok) return getInviteUrl(reused.code); + + showToast( + reused.reason === "missing" + ? "No reusable invite available." + : "Unable to reuse invite.", + Toasts.Type.FAILURE, + ); + return null; + } + + try { + const maxAge = settings.inviteExpireAfter ?? 0; + const maxUses = settings.inviteMaxUses ?? 0; + const { body } = await RestAPI.post({ + url: `/channels/${inviteChannel.id}/invites`, + body: { + max_age: maxAge, + max_uses: maxUses, + temporary: settings.inviteTemporaryMembership, + unique: true, + }, + }); + + const code = typeof body === "object" && body ? (body as { code?: string; }).code : null; + if (!code) throw new Error("Invite response missing code"); + + inviteCache.set(guildId, { + code, + expiresAt: maxAge > 0 ? Date.now() + maxAge * 1000 : null, + maxUses: maxUses === 0 ? null : maxUses, + uses: 0, + }); + showToast("Invite created.", Toasts.Type.SUCCESS); + return getInviteUrl(code); + } catch (error) { + logger.error("Failed to create invite", error); + showToast("Unable to create invite.", Toasts.Type.FAILURE); + return null; + } +} + +export function isGroupMessageChannel(channel?: Channel | null) { + if (!channel) return false; + return channel.type === ChannelType.GROUP_DM || channel.isGroupDM() || channel.isMultiUserDM(); +} diff --git a/src/equicordplugins/dragify/targets.ts b/src/equicordplugins/dragify/targets.ts new file mode 100644 index 000000000..c44596b56 --- /dev/null +++ b/src/equicordplugins/dragify/targets.ts @@ -0,0 +1,224 @@ +/* + * 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(); + 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(); + 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; +} diff --git a/src/equicordplugins/dragify/utils.ts b/src/equicordplugins/dragify/utils.ts index 131f0db28..4c26ac714 100644 --- a/src/equicordplugins/dragify/utils.ts +++ b/src/equicordplugins/dragify/utils.ts @@ -20,7 +20,7 @@ export type DropEntity = | { kind: "channel"; id: string; guildId?: string; } | { kind: "guild"; id: string; }; -interface DragifyPayload { +export interface DragifyPayload { id?: string; userId?: string; channelId?: string; @@ -92,55 +92,98 @@ export async function collectPayloadStrings(dataTransfer: DataTransfer): Promise return Array.from(new Set([...sync, ...asyncValues])); } +export function serializeDragEntity(entity: DropEntity) { + return JSON.stringify(entity); +} + +export function parseDragifyPayload(value: string): DropEntity | null { + const parsed = tryParseJson(value); + if (!parsed?.kind || !parsed.id) return null; + + switch (parsed.kind) { + case "user": + return { kind: "user", id: parsed.id }; + case "channel": + return { kind: "channel", id: parsed.id, guildId: parsed.guildId }; + case "guild": + return { kind: "guild", id: parsed.id }; + default: + return null; + } +} + +function parseJsonPayload(payload: DragifyPayload): DropEntity | null { + const id = payload.id ?? payload.userId ?? payload.channelId ?? payload.guildId; + if (!id) return null; + + const type = (payload.type ?? payload.kind ?? payload.itemType ?? "").toLowerCase(); + if (type.includes("user")) return { kind: "user", id }; + if (type.includes("channel")) return { kind: "channel", id, guildId: payload.guildId }; + if (type.includes("guild") || type.includes("server")) return { kind: "guild", id }; + return null; +} + +function parseUserString(value: string): DropEntity | null { + const userFromMention = userMentionRegex.exec(value); + if (userFromMention) return { kind: "user", id: userFromMention[1] }; + + const userFromProfile = userProfileUrlRegex.exec(value); + if (userFromProfile) return { kind: "user", id: userFromProfile[1] }; + + const guildUserAvatar = guildUserAvatarRegex.exec(value); + if (guildUserAvatar) return { kind: "user", id: guildUserAvatar[1] }; + + const userFromAvatar = userAvatarRegex.exec(value); + return userFromAvatar ? { kind: "user", id: userFromAvatar[1] } : null; +} + +function parseChannelString(value: string): DropEntity | null { + const channelFromMention = channelMentionRegex.exec(value); + if (channelFromMention) return { kind: "channel", id: channelFromMention[1] }; + + const channelFromUrl = channelUrlRegex.exec(value); + if (!channelFromUrl) return null; + + const guildId = channelFromUrl[1] === "@me" ? "@me" : channelFromUrl[2]; + return { kind: "channel", id: channelFromUrl[3], guildId: guildId ?? undefined }; +} + +function parseGuildString(value: string): DropEntity | null { + const guildFromIcon = guildIconRegex.exec(value); + return guildFromIcon ? { kind: "guild", id: guildFromIcon[1] } : null; +} + export function parseFromStrings(payloads: string[], stores: StoreSet): DropEntity | null { if (payloads.length === 0) return null; - for (const value of payloads) { + const values = payloads + .map(value => value.trim()) + .filter(Boolean); + + for (const value of values) { const parsed = tryParseJson(value); - if (parsed) { - const id = parsed.id ?? parsed.userId ?? parsed.channelId ?? parsed.guildId; - const type = (parsed.type ?? parsed.kind ?? parsed.itemType ?? "").toLowerCase(); - if (id) { - if (type.includes("user")) return { kind: "user", id }; - if (type.includes("channel")) return { kind: "channel", id, guildId: parsed.guildId }; - if (type.includes("guild") || type.includes("server")) return { kind: "guild", id }; - } - } + if (!parsed) continue; + + const jsonEntity = parseJsonPayload(parsed); + if (jsonEntity) return jsonEntity; } - for (const value of payloads) { - const trimmed = value.trim(); - if (!trimmed) continue; - const userFromMention = userMentionRegex.exec(trimmed); - if (userFromMention) return { kind: "user", id: userFromMention[1] }; - const userFromProfile = userProfileUrlRegex.exec(trimmed); - if (userFromProfile) return { kind: "user", id: userFromProfile[1] }; - const guildUserAvatar = guildUserAvatarRegex.exec(trimmed); - if (guildUserAvatar) return { kind: "user", id: guildUserAvatar[1] }; - const userFromAvatar = userAvatarRegex.exec(trimmed); - if (userFromAvatar) return { kind: "user", id: userFromAvatar[1] }; + for (const value of values) { + const userEntity = parseUserString(value); + if (userEntity) return userEntity; } - for (const value of payloads) { - const trimmed = value.trim(); - if (!trimmed) continue; - const channelFromMention = channelMentionRegex.exec(trimmed); - if (channelFromMention) return { kind: "channel", id: channelFromMention[1] }; - const channelFromUrl = channelUrlRegex.exec(trimmed); - if (channelFromUrl) { - const guildId = channelFromUrl[1] === "@me" ? "@me" : channelFromUrl[2]; - return { kind: "channel", id: channelFromUrl[3], guildId: guildId ?? undefined }; - } + for (const value of values) { + const channelEntity = parseChannelString(value); + if (channelEntity) return channelEntity; } - for (const value of payloads) { - const trimmed = value.trim(); - if (!trimmed) continue; - const guildFromIcon = guildIconRegex.exec(trimmed); - if (guildFromIcon) return { kind: "guild", id: guildFromIcon[1] }; + for (const value of values) { + const guildEntity = parseGuildString(value); + if (guildEntity) return guildEntity; } - const candidates = extractSnowflakes(payloads); + const candidates = extractSnowflakes(values); for (const candidate of candidates) { if (stores.ChannelStore.getChannel(candidate)) return { kind: "channel", id: candidate }; if (stores.GuildStore.getGuild(candidate)) return { kind: "guild", id: candidate };