refactor: overhaul dragify internals (#961)
This commit is contained in:
parent
e507423552
commit
e9e42296c4
6 changed files with 866 additions and 802 deletions
134
src/equicordplugins/dragify/dragState.ts
Normal file
134
src/equicordplugins/dragify/dragState.ts
Normal file
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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<typeof createRoot> | null = null;
|
||||
|
|
@ -184,12 +174,7 @@ const DragGhost = () => {
|
|||
: <VoiceStateIcon className={cl("voice-icon")} />)
|
||||
: null}
|
||||
{state.kind === "user" && inVoice && isStreaming
|
||||
? <ScreenArrowIcon
|
||||
className={classes(cl("voice-icon"), cl("voice-icon-stream"))}
|
||||
size={14}
|
||||
width={14}
|
||||
height={14}
|
||||
/>
|
||||
? <StreamIcon className={classes(cl("voice-icon"), cl("voice-icon-stream"))} />
|
||||
: null}
|
||||
</div>
|
||||
{state.subtitle && <div className={cl("subtitle")}>{state.subtitle}</div>}
|
||||
|
|
@ -218,6 +203,24 @@ function VoiceStateIcon({ className, size = 14 }: { className?: string; size?: n
|
|||
);
|
||||
}
|
||||
|
||||
function StreamIcon({ className, size = 14 }: { className?: string; size?: number; }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M4 5a3 3 0 0 0-3 3v7a3 3 0 0 0 3 3h4.59l-1.3 2.3a1 1 0 1 0 1.74.98L10.84 18h2.32l1.81 3.28a1 1 0 1 0 1.74-.98L15.41 18H20a3 3 0 0 0 3-3V8a3 3 0 0 0-3-3H4Zm0 2h16a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1Zm6 2.57c0-.8.89-1.27 1.54-.82l3.22 2.23a1 1 0 0 1 0 1.64l-3.22 2.23A1 1 0 0 1 10 14.03V9.57Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function VoiceMutedIcon({ className, size = 14 }: { className?: string; size?: number; }) {
|
||||
return (
|
||||
<svg
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
189
src/equicordplugins/dragify/invite.ts
Normal file
189
src/equicordplugins/dragify/invite.ts
Normal file
|
|
@ -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<string, InviteCacheEntry>();
|
||||
|
||||
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<string, Channel>, 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<string, Channel>();
|
||||
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<string | null> {
|
||||
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();
|
||||
}
|
||||
224
src/equicordplugins/dragify/targets.ts
Normal file
224
src/equicordplugins/dragify/targets.ts
Normal file
|
|
@ -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<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;
|
||||
}
|
||||
|
|
@ -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<DragifyPayload>(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<DragifyPayload>(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 };
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue