refactor: overhaul dragify internals (#961)

This commit is contained in:
Jake 2026-04-09 22:00:45 +01:00 • committed by GitHub
parent e507423552
commit e9e42296c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 866 additions and 802 deletions

View 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();
}

View file

@ -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

View 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();
}

View 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;
}

View file

@ -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 };