bazarr/frontend/src/App/Notification.tsx

242 lines
6.0 KiB
TypeScript

import { useReduxStore } from "@/modules/redux/hooks/base";
import { BuildKey, useIsArrayExtended } from "@/utilities";
import {
faBug,
faCircleNotch,
faExclamationTriangle,
faInfoCircle,
faStream,
IconDefinition,
} from "@fortawesome/free-solid-svg-icons";
import {
FontAwesomeIcon,
FontAwesomeIconProps,
} from "@fortawesome/react-fontawesome";
import {
Fragment,
FunctionComponent,
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
Button,
Dropdown,
Overlay,
ProgressBar,
Tooltip,
} from "react-bootstrap";
import { useDidUpdate, useTimeoutWhen } from "rooks";
enum State {
Idle,
Working,
Failed,
}
function useTotalProgress(progress: Site.Progress[]) {
return useMemo(() => {
const { value, count } = progress.reduce(
(prev, { value, count }) => {
prev.value += value;
prev.count += count;
return prev;
},
{ value: 0, count: 0 }
);
if (count === 0) {
return 0;
} else {
return (value + 0.001) / count;
}
}, [progress]);
}
function useHasErrorNotification(notifications: Server.Notification[]) {
return useMemo(
() => notifications.find((v) => v.type !== "info") !== undefined,
[notifications]
);
}
const NotificationCenter: FunctionComponent = () => {
const { progress, notifications, notifier } = useReduxStore((s) => s.site);
const dropdownRef = useRef<HTMLDivElement>(null);
const [hasNew, setHasNew] = useState(false);
const hasNewProgress = useIsArrayExtended(progress);
const hasNewNotifications = useIsArrayExtended(notifications);
useDidUpdate(() => {
if (hasNewNotifications || hasNewProgress) {
setHasNew(true);
}
}, [hasNewProgress, hasNewNotifications]);
useDidUpdate(() => {
if (progress.length === 0 && notifications.length === 0) {
setHasNew(false);
}
}, [progress.length, notifications.length]);
const [btnState, setBtnState] = useState(State.Idle);
const totalProgress = useTotalProgress(progress);
const hasError = useHasErrorNotification(notifications);
useEffect(() => {
if (hasError) {
setBtnState(State.Failed);
} else if (totalProgress > 0 && totalProgress < 1.0) {
setBtnState(State.Working);
} else {
setBtnState(State.Idle);
}
}, [totalProgress, hasError]);
const iconProps = useMemo<FontAwesomeIconProps>(() => {
switch (btnState) {
case State.Idle:
return {
icon: faStream,
};
case State.Working:
return {
icon: faCircleNotch,
spin: true,
};
default:
return {
icon: faExclamationTriangle,
};
}
}, [btnState]);
const content = useMemo<ReactNode>(() => {
const nodes: JSX.Element[] = [];
nodes.push(
<Dropdown.Header key="notifications-header">
{notifications.length > 0 ? "Notifications" : "No Notifications"}
</Dropdown.Header>
);
nodes.push(
...notifications.map((v, idx) => (
<Dropdown.Item disabled key={BuildKey(idx, v.id, "notification")}>
<Notification {...v}></Notification>
</Dropdown.Item>
))
);
nodes.push(<Dropdown.Divider key="dropdown-divider"></Dropdown.Divider>);
nodes.push(
<Dropdown.Header key="background-task-header">
{progress.length > 0 ? "Background Tasks" : "No Background Tasks"}
</Dropdown.Header>
);
nodes.push(
...progress.map((v, idx) => (
<Dropdown.Item disabled key={BuildKey(idx, v.id, "progress")}>
<Progress {...v}></Progress>
</Dropdown.Item>
))
);
return nodes;
}, [progress, notifications]);
const onToggleClick = useCallback(() => {
setHasNew(false);
}, []);
// Tooltip Controller
const [showTooltip, setTooltip] = useState(false);
useTimeoutWhen(() => setTooltip(false), 3 * 1000, showTooltip);
useDidUpdate(() => {
if (notifier.content) {
setTooltip(true);
}
}, [notifier.timestamp]);
return (
<Fragment>
<Dropdown
onClick={onToggleClick}
className={`notification-btn ${hasNew ? "new-item" : ""}`}
ref={dropdownRef}
alignRight
>
<Dropdown.Toggle as={Button} className="hide-arrow">
<FontAwesomeIcon {...iconProps}></FontAwesomeIcon>
</Dropdown.Toggle>
<Dropdown.Menu className="pb-3">{content}</Dropdown.Menu>
</Dropdown>
<Overlay target={dropdownRef} show={showTooltip} placement="bottom">
{(props) => {
return (
<Tooltip id="new-notification-tip" {...props}>
{notifier.content}
</Tooltip>
);
}}
</Overlay>
</Fragment>
);
};
const Notification: FunctionComponent<Server.Notification> = ({
type,
message,
}) => {
const icon = useMemo<IconDefinition>(() => {
switch (type) {
case "info":
return faInfoCircle;
case "warning":
return faExclamationTriangle;
default:
return faBug;
}
}, [type]);
return (
<div className="notification-center-notification d-flex flex-nowrap align-items-center justify-content-start my-1">
<FontAwesomeIcon className="mr-2 text-dark" icon={icon}></FontAwesomeIcon>
<span className="text-dark small">{message}</span>
</div>
);
};
const Progress: FunctionComponent<Site.Progress> = ({
name,
value,
count,
header,
}) => {
const isCompleted = value / count > 1;
const displayValue = Math.min(count, value + 1);
return (
<div className="notification-center-progress d-flex flex-column">
<p className="progress-header m-0 h-6 text-dark font-weight-bold">
{header}
</p>
<p className="progress-name m-0 small text-secondary">
{isCompleted ? "Completed successfully" : name}
</p>
<ProgressBar
className="mt-2"
animated={!isCompleted}
now={displayValue / count}
max={1}
label={`${displayValue}/${count}`}
></ProgressBar>
</div>
);
};
export default NotificationCenter;