/*
* Copyright (c) 2021 - 2025, Ludvig Lundgren and the autobrr contributors.
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { Fragment, MouseEvent, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { ArrowPathIcon, LockClosedIcon, LockOpenIcon, PlusIcon } from "@heroicons/react/24/solid";
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from "@headlessui/react";
import {
ArrowsPointingInIcon,
ArrowsPointingOutIcon,
Cog6ToothIcon,
EllipsisHorizontalIcon,
ExclamationCircleIcon,
PencilSquareIcon,
TrashIcon
} from "@heroicons/react/24/outline";
import { classNames, IsEmptyDate, simplifyDate } from "@utils";
import { IrcNetworkAddForm, IrcNetworkUpdateForm } from "@forms";
import { useToggle } from "@hooks/hooks";
import { APIClient } from "@api/APIClient";
import { IrcKeys } from "@api/query_keys";
import { IrcQueryOptions } from "@api/queries";
import { EmptySimple } from "@components/emptystates";
import { DeleteModal } from "@components/modals";
import { toast } from "@components/hot-toast";
import Toast from "@components/notifications/Toast";
import { SettingsContext } from "@utils/Context";
import { Checkbox } from "@components/Checkbox";
import { Section } from "./_components";
import { RingResizeSpinner } from "@components/Icons.tsx";
interface SortConfig {
key: keyof ListItemProps["network"] | "enabled";
direction: "ascending" | "descending";
}
function useSort(items: ListItemProps["network"][], config?: SortConfig) {
const [sortConfig, setSortConfig] = useState(config);
const sortedItems = useMemo(() => {
if (!sortConfig) {
return items;
}
const sortableItems = [...items];
sortableItems.sort((a, b) => {
const aValue = sortConfig.key === "enabled" ? (a[sortConfig.key] ?? false) as number | boolean | string : a[sortConfig.key] as number | boolean | string;
const bValue = sortConfig.key === "enabled" ? (b[sortConfig.key] ?? false) as number | boolean | string : b[sortConfig.key] as number | boolean | string;
if (aValue < bValue) {
return sortConfig.direction === "ascending" ? -1 : 1;
}
if (aValue > bValue) {
return sortConfig.direction === "ascending" ? 1 : -1;
}
return 0;
});
return sortableItems;
}, [items, sortConfig]);
const requestSort = (key: keyof ListItemProps["network"]) => {
let direction: "ascending" | "descending" = "ascending";
if (
sortConfig &&
sortConfig.key === key &&
sortConfig.direction === "ascending"
) {
direction = "descending";
}
setSortConfig({ key, direction });
};
const getSortIndicator = (key: keyof ListItemProps["network"]) => {
if (!sortConfig || sortConfig.key !== key) {
return "";
}
return sortConfig.direction === "ascending" ? "↑" : "↓";
};
return { items: sortedItems, requestSort, sortConfig, getSortIndicator };
}
const IrcSettings = () => {
const [expandNetworks, toggleExpand] = useToggle(false);
const [addNetworkIsOpen, toggleAddNetwork] = useToggle(false);
const ircQuery = useSuspenseQuery(IrcQueryOptions())
const sortedNetworks = useSort(ircQuery.data || []);
return (
Add new
}
>
Network healthy
Network unhealthy
Network disabled
{expandNetworks
? Collapse
: Expand
}
{ircQuery.data && ircQuery.data.length > 0 ? (
sortedNetworks.requestSort("enabled")}>
Enabled {sortedNetworks.getSortIndicator("enabled")}
sortedNetworks.requestSort("name")}>
Network {sortedNetworks.getSortIndicator("name")}
sortedNetworks.requestSort("server")}>
Server {sortedNetworks.getSortIndicator("server")}
sortedNetworks.requestSort("nick")}>
Nick {sortedNetworks.getSortIndicator("nick")}
{sortedNetworks.items.map((network) => (
))}
) : (
)}
);
};
interface ListItemProps {
network: IrcNetworkWithHealth;
expanded: boolean;
}
const ListItem = ({ network, expanded }: ListItemProps) => {
const [updateIsOpen, toggleUpdate] = useToggle(false);
const [edit, toggleEdit] = useToggle(false);
const queryClient = useQueryClient();
const updateMutation = useMutation({
mutationFn: (network: IrcNetwork) => APIClient.irc.updateNetwork(network).then(() => network),
onSuccess: (network: IrcNetwork) => {
queryClient.invalidateQueries({ queryKey: IrcKeys.lists() });
toast.custom(t => );
}
});
const onToggleMutation = (newState: boolean) => {
updateMutation.mutate({
...network,
enabled: newState
});
};
return (
{
if (e.defaultPrevented)
return;
e.preventDefault();
toggleEdit();
}}
>
{network.enabled ? (
network.healthy ? (
) : (
)
) : (
)}
{network.name}
{network.tls ? (
) : (
)}
{network.server}:{network.port}
{(edit || expanded) && (
{network.channels.length > 0 ? (
Channel
Monitoring since
Last announce
{network.channels.map((c) => (
))}
) : (
)}
)}
);
};
interface ChannelItemProps {
network: IrcNetwork;
channel: IrcChannelWithHealth;
}
const ChannelItem = ({ network, channel }: ChannelItemProps) => {
const [viewChannel, toggleView] = useToggle(false);
return (
{network.enabled ? (
channel.monitoring ? (
) : (
)
) : (
)}
{channel.name}
{IsEmptyDate(channel.monitoring_since)}
{IsEmptyDate(channel.last_announce)}
{viewChannel ? "Hide" : "View"}
{viewChannel && (
)}
);
};
interface ListItemDropdownProps {
network: IrcNetwork;
toggleUpdate: () => void;
}
const ListItemDropdown = ({
network,
toggleUpdate
}: ListItemDropdownProps) => {
const cancelModalButtonRef = useRef(null);
const queryClient = useQueryClient();
const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false);
const deleteMutation = useMutation({
mutationFn: (id: number) => APIClient.irc.deleteNetwork(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: IrcKeys.lists() });
queryClient.invalidateQueries({ queryKey: IrcKeys.detail(network.id) });
toast.custom((t) => );
toggleDeleteModal();
}
});
const restartMutation = useMutation({
mutationFn: (id: number) => APIClient.irc.restartNetwork(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: IrcKeys.lists() });
queryClient.invalidateQueries({ queryKey: IrcKeys.detail(network.id) });
toast.custom((t) => );
}
});
const restart = (id: number) => restartMutation.mutate(id);
return (
{
e.preventDefault();
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
}}
>
{
deleteMutation.mutate(network.id);
toggleDeleteModal();
}}
title={`Remove network: ${network.name}`}
text="Are you sure you want to remove this network? This action cannot be undone."
/>
{({ active }) => (
toggleUpdate()}
>
Edit
)}
{/*
*/}
{/* {({ active }) => (*/}
{/* onToggle(!network.enabled)}*/}
{/* >*/}
{/* */}
{/* {network.enabled ? "Disable" : "Enable"}*/}
{/* */}
{/* )}*/}
{/* */}
{({ active }) => (
restart(network.id)}
disabled={!network.enabled}
title={network.enabled ? "Restart" : "Network disabled"}
>
Restart
)}
{({ active }) => (
toggleDeleteModal()}
>
Delete
)}
);
};
interface ReprocessAnnounceProps {
networkId: number;
channel: string;
msg: string;
}
const ReprocessAnnounceButton = ({ networkId, channel, msg }: ReprocessAnnounceProps) => {
const mutation = useMutation({
mutationFn: (req: IrcProcessManualRequest) => APIClient.irc.reprocessAnnounce(req.network_id, req.channel, req.msg),
onSuccess: () => {
toast.custom((t) => (
));
}
});
const reprocessAnnounce = () => {
const req: IrcProcessManualRequest = {
network_id: networkId,
msg: msg,
channel: channel,
}
if (channel.startsWith("#")) {
req.channel = channel.replace("#", "")
}
mutation.mutate(req);
};
return (
{mutation.isPending
?
:
}
);
}
type IrcEvent = {
channel: string;
nick: string;
msg: string;
time: string;
};
// type IrcMsg = {
// msg: string;
// };
interface EventsProps {
network: IrcNetwork;
channel: string;
}
export const Events = ({ network, channel }: EventsProps) => {
const [logs, setLogs] = useState([]);
const [settings] = SettingsContext.use();
useEffect(() => {
// Following RFC4648
const key = window.btoa(`${network.id}${channel.toLowerCase()}`)
.replaceAll("+", "-")
.replaceAll("/", "_")
.replaceAll("=", "");
const es = APIClient.irc.events(key);
es.onmessage = (event) => {
const newData = JSON.parse(event.data) as IrcEvent;
setLogs((prevState) => [...prevState, newData]);
};
return () => es.close();
}, [channel, network.id, settings]);
const [isFullscreen, toggleFullscreen] = useToggle(false);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && isFullscreen) {
toggleFullscreen();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [isFullscreen, toggleFullscreen]);
const messagesEndRef = useRef(null);
useEffect(() => {
const scrollToBottom = () => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollTop = messagesEndRef.current.scrollHeight;
}
};
if (settings.scrollOnNewLog)
scrollToBottom();
}, [logs, settings.scrollOnNewLog]);
// Add a useEffect to clear logs div when settings.scrollOnNewLog changes to prevent duplicate entries.
useEffect(() => {
setLogs([]);
}, [settings.scrollOnNewLog]);
useEffect(() => {
document.body.classList.toggle("overflow-hidden", isFullscreen);
return () => {
// Clean up by removing the class when the component unmounts
document.body.classList.remove("overflow-hidden");
};
}, [isFullscreen]);
return (
{logs.map((entry, idx) => (
[{simplifyDate(entry.time)}] {entry.nick}: {entry.msg}
))}
);
};
export default IrcSettings;
const IRCLogsDropdown = () => {
const [settings, setSettings] = SettingsContext.use();
const onSetValue = (
key: "scrollOnNewLog",
newValue: boolean
) => setSettings((prevState) => ({
...prevState,
[key]: newValue
}));
//
// FIXME: Warning: Function components cannot be given refs. Attempts to access this ref will fail.
// Did you mean to use React.forwardRef()?
//
// Check the render method of `Pe2`.
// at Checkbox (http://localhost:3000/src/components/Checkbox.tsx:14:28)
// at Pe2 (http://localhost:3000/node_modules/.vite/deps/@headlessui_react.js?v=e8629745:2164:12)
// at div
// at Ee (http://localhost:3000/node_modules/.vite/deps/@headlessui_react.js?v=e8629745:2106:12)
// at c5 (http://localhost:3000/node_modules/.vite/deps/@headlessui_react.js?v=e8629745:592:22)
// at De4 (http://localhost:3000/node_modules/.vite/deps/@headlessui_react.js?v=e8629745:3016:22)
// at He5 (http://localhost:3000/node_modules/.vite/deps/@headlessui_react.js?v=e8629745:3053:15)
// at div
// at c5 (http://localhost:3000/node_modules/.vite/deps/@headlessui_react.js?v=e8629745:592:22)
// at Me2 (http://localhost:3000/node_modules/.vite/deps/@headlessui_react.js?v=e8629745:2062:21)
// at IRCLogsDropdown (http://localhost:3000/src/screens/settings/Irc.tsx?t=1694269937935:1354:53)
return (
Options
{() => (
onSetValue("scrollOnNewLog", newValue)}
/>
)}
);
};