mirror of
https://github.com/scottlamb/moonfire-nvr.git
synced 2025-01-27 06:33:20 -05:00
74b9f36a75
This requires a bunch of package name changes. See [https://mui.com/blog/material-ui-is-now-mui/] for their rationale. [https://mui.com/guides/migration-v4/] lists the changes: ``` @material-ui/core -> @mui/material @material-ui/system -> @mui/system @material-ui/unstyled -> @mui/core @material-ui/styles -> @mui/styles @material-ui/icons -> @mui/icons-material @material-ui/lab -> @mui/lab @material-ui/types -> @mui/types @material-ui/styled-engine -> @mui/styled-engine @material-ui/styled-engine-sc ->@mui/styled-engine-sc @material-ui/private-theming -> @mui/private-theming @material-ui/codemod -> @mui/codemod @material-ui/docs -> @mui/docs @material-ui/envinfo -> @mui/envinfo ``` We only use a few of these.
192 lines
5.6 KiB
TypeScript
192 lines
5.6 KiB
TypeScript
// 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
|
|
* <a href="https://www.npmjs.com/package/notistack">notistack</a> because it
|
|
* doesn't seem oriented for complying with the <a
|
|
* href="https://material.io/components/snackbars">material.io spec</a>.
|
|
* Besides supporting non-compliant behaviors (eg <tt>maxSnack</tt> > 1</tt>),
|
|
* it doesn't actually enqueue notifications. Newer ones replace older ones.
|
|
*
|
|
* This isn't as flexible as <tt>notistack</tt> 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 <tt>enqueue</tt>.
|
|
*/
|
|
autoHideDuration: number;
|
|
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
export interface MySnackbarProps
|
|
extends Omit<
|
|
SnackbarProps,
|
|
| "key"
|
|
| "anchorOrigin"
|
|
| "open"
|
|
| "handleClosed"
|
|
| "TransitionProps"
|
|
| "actions"
|
|
> {
|
|
key?: React.Key;
|
|
}
|
|
|
|
type MySnackbarPropsWithRequiredKey = Omit<MySnackbarProps, "key"> &
|
|
Required<Pick<MySnackbarProps, "key">>;
|
|
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 <tt>message</tt>. If
|
|
* <tt>key</tt> 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 <tt>actions</tt>.
|
|
* @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<Snackbars | null>(null);
|
|
|
|
/**
|
|
* Provides a <tt>Snackbars</tt> instance for use by <tt>useSnackbars</tt>.
|
|
*/
|
|
// 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<SnackbarProviderProps, State>
|
|
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<any>,
|
|
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 (
|
|
<ctx.Provider value={snackbars}>
|
|
{this.props.children}
|
|
{first === undefined ? null : (
|
|
<Snackbar
|
|
{...first}
|
|
anchorOrigin={{
|
|
vertical: "bottom",
|
|
horizontal: "left",
|
|
}}
|
|
autoHideDuration={
|
|
first.autoHideDuration ?? this.props.autoHideDuration
|
|
}
|
|
onClose={(event, reason) =>
|
|
this.handleCloseSnackbar(first.key, event, reason)
|
|
}
|
|
TransitionProps={{
|
|
onExited: () => this.handleSnackbarExited(first.key),
|
|
}}
|
|
action={
|
|
<IconButton
|
|
size="small"
|
|
aria-label="close"
|
|
color="inherit"
|
|
onClick={() => this.close(first.key)}
|
|
>
|
|
<CloseIcon fontSize="small" />
|
|
</IconButton>
|
|
}
|
|
/>
|
|
)}
|
|
</ctx.Provider>
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Returns a <tt>Snackbars</tt> from context. */
|
|
export function useSnackbars(): Snackbars {
|
|
return useContext(ctx)!;
|
|
}
|