// 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)!; }