Files
clash-verge-rev-lite/src/components/home/proxy-selectors.tsx
2025-09-29 02:29:13 +03:00

398 lines
13 KiB
TypeScript

import React, { useState, useEffect, useMemo, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@root/lib/utils";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {AlertTriangle, ChevronsUpDown, Timer, WholeWord} from "lucide-react";
import { useVerge } from "@/hooks/use-verge";
import { useAppData } from "@/providers/app-data-provider";
import delayManager from "@/services/delay";
import { updateProxy, deleteConnection } from "@/services/api";
const STORAGE_KEY_GROUP = "clash-verge-selected-proxy-group";
const STORAGE_KEY_SORT_TYPE = "clash-verge-proxy-sort-type";
const presetList = ["DIRECT", "REJECT", "REJECT-DROP", "PASS", "COMPATIBLE"];
type ProxySortType = "default" | "delay" | "name";
interface IProxyGroup {
name: string;
type: string;
now: string;
hidden: boolean;
all: (string | { name: string })[];
icon?: string;
}
function getDelayColorClasses(delayValue: number): string {
if (delayValue < 0) {
return "text-muted-foreground border-border";
}
if (delayValue >= 150) {
return "text-destructive border-destructive/40";
}
return "text-green-600 border-green-500/40 dark:text-green-400 dark:border-green-400/30";
}
const ProxySelectItem = ({
proxyName,
groupName,
}: {
proxyName: string;
groupName: string;
}) => {
const [delay, setDelay] = useState(() =>
delayManager.getDelay(proxyName, groupName),
);
const [isJustUpdated, setIsJustUpdated] = useState(false);
useEffect(() => {
const listener = (newDelay: number) => {
setDelay((currentDelay) => {
if (newDelay >= 0 && newDelay !== currentDelay) {
setIsJustUpdated(true);
setTimeout(() => setIsJustUpdated(false), 600);
}
return newDelay;
});
};
delayManager.setListener(proxyName, groupName, listener);
return () => {
delayManager.removeListener(proxyName, groupName);
};
}, [proxyName, groupName]);
return (
<SelectItem key={proxyName} value={proxyName}>
<div className="flex items-center justify-between w-full">
<Badge
variant="outline"
className={cn(
"mr-2 flex-shrink-0 px-2 h-5 w-8 justify-center transition-colors duration-300",
getDelayColorClasses(delay),
isJustUpdated && "bg-primary/10 border-primary/50",
)}
>
{delay < 0 || delay > 10000 ? "---" : delay}
</Badge>
<span className="truncate">{proxyName}</span>
</div>
</SelectItem>
);
};
export const ProxySelectors: React.FC = () => {
const { t } = useTranslation();
const { verge } = useVerge();
const { proxies, connections, clashConfig, refreshProxy } = useAppData();
const mode = clashConfig?.mode?.toLowerCase() || "rule";
const isGlobalMode = mode === "global";
const isDirectMode = mode === "direct";
const [selectedGroup, setSelectedGroup] = useState<string>("");
const [selectedProxy, setSelectedProxy] = useState<string>("");
const [sortType, setSortType] = useState<ProxySortType>(
() =>
(localStorage.getItem(STORAGE_KEY_SORT_TYPE) as ProxySortType) ||
"default",
);
const enable_group_icon = verge?.enable_group_icon ?? true;
useEffect(() => {
if (!proxies?.groups) return;
if (isGlobalMode) {
setSelectedGroup("GLOBAL");
return;
}
if (isDirectMode) {
setSelectedGroup("DIRECT");
return;
}
const savedGroup = localStorage.getItem(STORAGE_KEY_GROUP);
const primaryGroup =
proxies.groups.find(
(g: IProxyGroup) =>
g.type === "Selector" && g.name.toLowerCase().includes("auto"),
) || proxies.groups.find((g: IProxyGroup) => g.type === "Selector");
if (
savedGroup &&
proxies.groups.some((g: IProxyGroup) => g.name === savedGroup)
) {
setSelectedGroup(savedGroup);
} else if (primaryGroup) {
setSelectedGroup(primaryGroup.name);
} else if (proxies.groups.length > 0) {
const firstSelector = proxies.groups.find(
(g: IProxyGroup) => g.type === "Selector",
);
if (firstSelector) {
setSelectedGroup(firstSelector.name);
}
}
}, [proxies, isGlobalMode, isDirectMode]);
useEffect(() => {
if (!selectedGroup || !proxies) return;
if (isGlobalMode) {
setSelectedProxy(proxies.global?.now || "");
return;
}
if (isDirectMode) {
setSelectedProxy("DIRECT");
return;
}
const group = proxies.groups.find(
(g: IProxyGroup) => g.name === selectedGroup,
);
if (group) {
const current = group.now;
const firstInList =
typeof group.all?.[0] === "string"
? group.all[0]
: group.all?.[0]?.name;
setSelectedProxy(current || firstInList || "");
}
}, [selectedGroup, proxies, isGlobalMode, isDirectMode]);
const handleProxyListOpen = useCallback(
(isOpen: boolean) => {
if (!isOpen || isDirectMode) return;
const timeout = verge?.default_latency_timeout || 5000;
if (isGlobalMode) {
const proxyList = proxies?.global?.all;
if (proxyList) {
const proxyNames = proxyList
.map((p: any) => (typeof p === "string" ? p : p.name))
.filter((name: string) => name && !presetList.includes(name));
delayManager.checkListDelay(proxyNames, "GLOBAL", timeout);
}
} else {
const group = proxies?.groups?.find(
(g: IProxyGroup) => g.name === selectedGroup,
);
if (group && group.all) {
const proxyNames = group.all
.map((p: any) => (typeof p === "string" ? p : p.name))
.filter(Boolean);
delayManager.checkListDelay(proxyNames, selectedGroup, timeout);
}
}
},
[selectedGroup, proxies, isGlobalMode, isDirectMode, verge],
);
const handleGroupChange = (newGroup: string) => {
if (isGlobalMode || isDirectMode) return;
setSelectedGroup(newGroup);
localStorage.setItem(STORAGE_KEY_GROUP, newGroup);
};
const handleProxyChange = async (newProxy: string) => {
if (newProxy === selectedProxy) return;
const previousProxy = selectedProxy;
setSelectedProxy(newProxy);
try {
await updateProxy(selectedGroup, newProxy);
if (verge?.auto_close_connection && previousProxy) {
connections?.data.forEach((conn: any) => {
if (conn.chains.includes(previousProxy)) {
deleteConnection(conn.id);
}
});
}
setTimeout(() => refreshProxy(), 300);
} catch (error) {
console.error("Failed to update proxy", error);
}
};
const handleSortChange = () => {
const nextSort: Record<ProxySortType, ProxySortType> = {
default: "delay",
delay: "name",
name: "default",
};
const newSortType = nextSort[sortType];
setSortType(newSortType);
localStorage.setItem(STORAGE_KEY_SORT_TYPE, newSortType);
};
const selectorGroups = useMemo(() => {
if (!proxies?.groups) return [];
const allowedTypes = ["Selector", "URLTest", "Fallback"];
return proxies.groups.filter(
(g: IProxyGroup) => allowedTypes.includes(g.type) && !g.hidden,
);
}, [proxies]);
const proxyOptions = useMemo(() => {
let options: { name: string }[] = [];
if (isDirectMode) return [{ name: "DIRECT" }];
const sourceList = isGlobalMode
? proxies?.global?.all
: proxies?.groups?.find((g: IProxyGroup) => g.name === selectedGroup)
?.all;
if (sourceList) {
const rawOptions = sourceList
.map((proxy: any) => ({
name: typeof proxy === "string" ? proxy : proxy.name,
}))
.filter((p: { name: string }) => p.name);
const uniqueNames = new Set<string>();
options = rawOptions.filter((proxy: any) => {
if (!uniqueNames.has(proxy.name)) {
uniqueNames.add(proxy.name);
return true;
}
return false;
});
}
if (sortType === "name")
return options.sort((a, b) => a.name.localeCompare(b.name));
if (sortType === "delay") {
return options.sort((a, b) => {
const delayA = delayManager.getDelay(a.name, selectedGroup);
const delayB = delayManager.getDelay(b.name, selectedGroup);
if (delayA < 0) return 1;
if (delayB < 0) return -1;
return delayA - delayB;
});
}
return options;
}, [selectedGroup, proxies, sortType, isGlobalMode, isDirectMode]);
return (
<TooltipProvider>
<div className="flex justify-center flex-col gap-2 md:items-end">
<div className="flex flex-col items-start gap-2">
<label className="text-sm font-medium text-muted-foreground">
{t("Group")}
</label>
<Select
value={selectedGroup}
onValueChange={handleGroupChange}
disabled={isGlobalMode || isDirectMode}
>
<SelectTrigger className="w-100">
{isGlobalMode ? (
<div className="flex items-center gap-2 text-destructive">
<AlertTriangle className="h-4 w-4 text-destructive" />
<span className="font-medium text-sm">
{t("Global Mode Active")}
</span>
</div>
) : (
<div className="flex items-center gap-2 truncate">
<span className="truncate">
<SelectValue placeholder={t("Select a group...")} />
</span>
</div>
)}
</SelectTrigger>
<SelectContent>
{selectorGroups.map((group: IProxyGroup) => (
<SelectItem key={group.name} value={group.name}>
<div className="flex items-center gap-2">
{enable_group_icon && group.icon && (
<img
src={
group.icon.startsWith("data")
? group.icon
: group.icon.startsWith("<svg")
? `data:image/svg+xml;base64,${btoa(group.icon)}`
: group.icon
}
className="w-4 h-4 rounded-sm"
alt={group.name}
/>
)}
<span>{group.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col items-start gap-2">
<div className="flex justify-between items-center w-100">
<label className="text-sm font-medium text-muted-foreground">
{t("Proxy")}
</label>
<Tooltip>
<TooltipTrigger asChild>
<span className="truncate">
<Button
variant="ghost"
size="sm"
onClick={handleSortChange}
disabled={isDirectMode}
>
{sortType === "default" && (
<ChevronsUpDown className="h-4 w-4" />
)}
{sortType === "delay" && <Timer className="h-4 w-4" />}
{sortType === "name" && <WholeWord className="h-4 w-4" />}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{sortType === "default" && <p>{t("Sort by default")}</p>}
{sortType === "delay" && <p>{t("Sort by delay")}</p>}
{sortType === "name" && <p>{t("Sort by name")}</p>}
</TooltipContent>
</Tooltip>
</div>
<Select
value={selectedProxy}
onValueChange={handleProxyChange}
disabled={isDirectMode}
onOpenChange={handleProxyListOpen}
>
<SelectTrigger className="w-100">
<span className="truncate">
<SelectValue placeholder={t("Select a proxy...")} />
</span>
</SelectTrigger>
<SelectContent>
{proxyOptions.map((proxy) => (
<ProxySelectItem
key={proxy.name}
proxyName={proxy.name}
groupName={selectedGroup}
/>
))}
</SelectContent>
</Select>
</div>
</div>
</TooltipProvider>
);
};