// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
/**
* App-wide provider for imperative snackbar.
*
* I chose not to use the popular
* notistack because it
* doesn't seem oriented for complying with the material.io spec.
* Besides supporting non-compliant behaviors (eg maxSnack > 1),
* it doesn't actually enqueue notifications. Newer ones replace older ones.
*
* This isn't as flexible as notistack because I don't need that
* flexibility (yet).
*/
import IconButton from "@mui/material/IconButton";
import Snackbar, {
SnackbarCloseReason,
SnackbarProps,
} from "@mui/material/Snackbar";
import CloseIcon from "@mui/icons-material/Close";
import React, { useContext } from "react";
interface SnackbarProviderProps {
/**
* The autohide duration to use if none is provided to enqueue.
*/
autoHideDuration: number;
children: React.ReactNode;
}
export interface MySnackbarProps
extends Omit<
SnackbarProps,
| "key"
| "anchorOrigin"
| "open"
| "handleClosed"
| "TransitionProps"
| "actions"
> {
key?: React.Key;
}
type MySnackbarPropsWithRequiredKey = Omit &
Required>;
interface Enqueued extends MySnackbarPropsWithRequiredKey {
open: boolean;
}
/**
* Imperative interface to enqueue and close app-wide snackbars.
* These methods should be called from effects (not directly from render).
*/
export interface Snackbars {
/**
* Enqueues a snackbar.
*
* @param snackbar
* The snackbar to add. The only required property is message. If
* key is present, it will close any message with the same key
* immediately, as well as be returned so it can be passed to close again
* later. Note that currently several properties are used internally and
* can't be specified, including actions.
* @return A key that can be passed to close: the caller-specified key if
* possible, or an internally generated key otherwise.
*/
enqueue: (snackbar: MySnackbarProps) => React.Key;
/**
* Closes a snackbar if present.
*
* If it is currently visible, it will be allowed to gracefully close.
* Otherwise it's removed from the queue.
*/
close: (key: React.Key) => void;
}
interface State {
queue: Enqueued[];
}
const ctx = React.createContext(null);
/**
* Provides a Snackbars instance for use by useSnackbars.
*/
// This is a class because I want to guarantee the context value never changes,
// and I couldn't figure out a way to do that with hooks.
export class SnackbarProvider
extends React.Component
implements Snackbars
{
constructor(props: SnackbarProviderProps) {
super(props);
this.state = { queue: [] };
}
autoKeySeq = 0;
enqueue(snackbar: MySnackbarProps): React.Key {
let key =
snackbar.key === undefined ? `auto-${this.autoKeySeq++}` : snackbar.key;
// TODO: filter existing.
this.setState((state) => ({
queue: [...state.queue, { key, open: true, ...snackbar }],
}));
return key;
}
handleCloseSnackbar = (
key: React.Key,
event: React.SyntheticEvent,
reason: SnackbarCloseReason
) => {
if (reason === "clickaway") return;
this.setState((state) => {
const snack = state.queue[0];
if (snack?.key !== key) {
console.warn(`Active snack is ${snack?.key}; expected ${key}`);
return null; // no change.
}
const newSnack: Enqueued = { ...snack, open: false };
return { queue: [newSnack, ...state.queue.slice(1)] };
});
};
handleSnackbarExited = (key: React.Key) => {
this.setState((state) => ({ queue: state.queue.slice(1) }));
};
close(key: React.Key): void {
this.setState((state) => {
// If this is the active snackbar, let it close gracefully, as in
// handleCloseSnackbar.
if (state.queue[0]?.key === key) {
const newSnack: Enqueued = { ...state.queue[0], open: false };
return { queue: [newSnack, ...state.queue.slice(1)] };
}
// Otherwise, remove it before it shows up at all.
return { queue: state.queue.filter((e: Enqueued) => e.key !== key) };
});
}
render(): JSX.Element {
const first = this.state.queue[0];
const snackbars: Snackbars = this;
return (
{this.props.children}
{first === undefined ? null : (
this.handleCloseSnackbar(first.key, event, reason)
}
TransitionProps={{
onExited: () => this.handleSnackbarExited(first.key),
}}
action={
this.close(first.key)}
>
}
/>
)}
);
}
}
/** Returns a Snackbars from context. */
export function useSnackbars(): Snackbars {
return useContext(ctx)!;
}