import * as React from "react"; import { useContext, useEffect, useState } from "react"; import { Alert, CardActions, CardContent, Chip, FormControl, Select, Stack, Table, TableBody, TableCell, TableHead, TableRow, Tooltip, useMediaQuery, Typography, IconButton, Container, TextField, MenuItem, Card, Button, Dialog, DialogTitle, DialogContent, DialogActions, } from "@mui/material"; import EditIcon from "@mui/icons-material/Edit"; import CloseIcon from "@mui/icons-material/Close"; import PlayArrowIcon from "@mui/icons-material/PlayArrow"; import { useLiveQuery } from "dexie-react-hooks"; import { useTranslation } from "react-i18next"; import { Info } from "@mui/icons-material"; import { useOutletContext } from "react-router-dom"; import theme from "./theme"; import userManager from "../app/UserManager"; import { playSound, shuffle, sounds, validUrl } from "../app/utils"; import session from "../app/Session"; import routes from "./routes"; import accountApi, { Permission, Role } from "../app/AccountApi"; import { Pref, PrefGroup } from "./Pref"; import { AccountContext } from "./App"; import { Paragraph } from "./styles"; import prefs from "../app/Prefs"; import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons"; import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs"; import { UnauthorizedError } from "../app/errors"; import { subscribeTopic } from "./SubscribeDialog"; import notifier from "../app/Notifier"; const maybeUpdateAccountSettings = async (payload) => { if (!session.exists()) { return; } try { await accountApi.updateSettings(payload); } catch (e) { console.log(`[Preferences] Error updating account settings`, e); if (e instanceof UnauthorizedError) { session.resetAndRedirect(routes.login); } } }; const Preferences = () => ( ); const Notifications = () => { const { t } = useTranslation(); return ( {t("prefs_notifications_title")} {notifier.pushSupported() && } ); }; const Sound = () => { const { t } = useTranslation(); const labelId = "prefSound"; const sound = useLiveQuery(async () => prefs.sound()); const handleChange = async (ev) => { await prefs.setSound(ev.target.value); await maybeUpdateAccountSettings({ notification: { sound: ev.target.value, }, }); }; if (!sound) { return null; // While loading } let description; if (sound === "none") { description = t("prefs_notifications_sound_description_none"); } else { description = t("prefs_notifications_sound_description_some", { sound: sounds[sound].label, }); } return (
playSound(sound)} disabled={sound === "none"} aria-label={t("prefs_notifications_sound_play")}>
); }; const MinPriority = () => { const { t } = useTranslation(); const labelId = "prefMinPriority"; const minPriority = useLiveQuery(async () => prefs.minPriority()); const handleChange = async (ev) => { await prefs.setMinPriority(ev.target.value); await maybeUpdateAccountSettings({ notification: { min_priority: ev.target.value, }, }); }; if (!minPriority) { return null; // While loading } const priorities = { 1: t("priority_min"), 2: t("priority_low"), 3: t("priority_default"), 4: t("priority_high"), 5: t("priority_max"), }; let description; if (minPriority === 1) { description = t("prefs_notifications_min_priority_description_any"); } else if (minPriority === 5) { description = t("prefs_notifications_min_priority_description_max"); } else { description = t("prefs_notifications_min_priority_description_x_or_higher", { number: minPriority, name: priorities[minPriority], }); } return ( ); }; const DeleteAfter = () => { const { t } = useTranslation(); const labelId = "prefDeleteAfter"; const deleteAfter = useLiveQuery(async () => prefs.deleteAfter()); const handleChange = async (ev) => { await prefs.setDeleteAfter(ev.target.value); await maybeUpdateAccountSettings({ notification: { delete_after: ev.target.value, }, }); }; if (deleteAfter === null || deleteAfter === undefined) { // !deleteAfter will not work with "0" return null; // While loading } const description = (() => { switch (deleteAfter) { case 0: return t("prefs_notifications_delete_after_never_description"); case 10800: return t("prefs_notifications_delete_after_three_hours_description"); case 86400: return t("prefs_notifications_delete_after_one_day_description"); case 604800: return t("prefs_notifications_delete_after_one_week_description"); case 2592000: return t("prefs_notifications_delete_after_one_month_description"); default: return ""; } })(); return ( ); }; const WebPushDefaultEnabled = () => { const { t } = useTranslation(); const labelId = "prefWebPushDefaultEnabled"; const defaultEnabled = useLiveQuery(async () => prefs.webPushDefaultEnabled()); const handleChange = async (ev) => { await prefs.setWebPushDefaultEnabled(ev.target.value); }; // while loading if (defaultEnabled == null) { return null; } return ( ); }; const Users = () => { const { t } = useTranslation(); const [dialogKey, setDialogKey] = useState(0); const [dialogOpen, setDialogOpen] = useState(false); const users = useLiveQuery(() => userManager.all()); const handleAddClick = () => { setDialogKey((prev) => prev + 1); setDialogOpen(true); }; const handleDialogCancel = () => { setDialogOpen(false); }; const handleDialogSubmit = async (user) => { setDialogOpen(false); try { await userManager.save(user); console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} added`); } catch (e) { console.log(`[Preferences] Error adding user.`, e); } }; return ( {t("prefs_users_title")} {t("prefs_users_description")} {session.exists() && <>{` ${t("prefs_users_description_no_sync")}`}} {users?.length > 0 && } ); }; const UserTable = (props) => { const { t } = useTranslation(); const [dialogKey, setDialogKey] = useState(0); const [dialogOpen, setDialogOpen] = useState(false); const [dialogUser, setDialogUser] = useState(null); const handleEditClick = (user) => { setDialogKey((prev) => prev + 1); setDialogUser(user); setDialogOpen(true); }; const handleDialogCancel = () => { setDialogOpen(false); }; const handleDialogSubmit = async (user) => { setDialogOpen(false); try { await userManager.save(user); console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} updated`); } catch (e) { console.log(`[Preferences] Error updating user.`, e); } }; const handleDeleteClick = async (user) => { try { await userManager.delete(user.baseUrl); console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} deleted`); } catch (e) { console.error(`[Preferences] Error deleting user for ${user.baseUrl}`, e); } }; return ( {t("prefs_users_table_user_header")} {t("prefs_users_table_base_url_header")} {props.users?.map((user) => ( {user.username} {user.baseUrl} {(!session.exists() || user.baseUrl !== config.base_url) && ( <> handleEditClick(user)} aria-label={t("prefs_users_edit_button")}> handleDeleteClick(user)} aria-label={t("prefs_users_delete_button")}> )} {session.exists() && user.baseUrl === config.base_url && ( )} ))}
); }; const UserDialog = (props) => { const { t } = useTranslation(); const [baseUrl, setBaseUrl] = useState(""); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const editMode = props.user !== null; const addButtonEnabled = (() => { if (editMode) { return username.length > 0 && password.length > 0; } const baseUrlValid = validUrl(baseUrl); const baseUrlExists = props.users?.map((user) => user.baseUrl).includes(baseUrl); return baseUrlValid && !baseUrlExists && username.length > 0 && password.length > 0; })(); const handleSubmit = async () => { props.onSubmit({ baseUrl, username, password, }); }; useEffect(() => { if (editMode) { setBaseUrl(props.user.baseUrl); setUsername(props.user.username); setPassword(props.user.password); } }, [editMode, props.user]); return ( {editMode ? t("prefs_users_dialog_title_edit") : t("prefs_users_dialog_title_add")} {!editMode && ( setBaseUrl(ev.target.value)} type="url" fullWidth variant="standard" /> )} setUsername(ev.target.value)} type="text" fullWidth variant="standard" /> setPassword(ev.target.value)} fullWidth variant="standard" /> ); }; const Appearance = () => { const { t } = useTranslation(); return ( {t("prefs_appearance_title")} ); }; const Language = () => { const { t, i18n } = useTranslation(); const labelId = "prefLanguage"; const lang = i18n.resolvedLanguage ?? "en"; // Country flags are displayed using emoji. Emoji rendering is handled by platform fonts. // Windows in particular does not yet play nicely with flag emoji so for now, hide flags on Windows. const randomFlags = shuffle([ "🇬🇧", "🇺🇸", "🇪🇸", "🇫🇷", "🇧🇬", "🇨🇿", "🇩🇪", "🇵🇱", "🇺🇦", "🇨🇳", "🇮🇹", "🇭🇺", "🇧🇷", "🇳🇱", "🇮🇩", "🇯🇵", "🇷🇺", "🇹🇷", ]).slice(0, 3); const showFlags = !navigator.userAgent.includes("Windows"); let title = t("prefs_appearance_language_title"); if (showFlags) { title += ` ${randomFlags.join(" ")}`; } const handleChange = async (ev) => { await i18n.changeLanguage(ev.target.value); await maybeUpdateAccountSettings({ language: ev.target.value, }); }; // Remember: Flags are not languages. Don't put flags next to the language in the list. // Languages names from: https://www.omniglot.com/language/names.htm // Better: Sidebar in Wikipedia: https://en.wikipedia.org/wiki/Bokm%C3%A5l return ( ); }; const Reservations = () => { const { t } = useTranslation(); const { account } = useContext(AccountContext); const [dialogKey, setDialogKey] = useState(0); const [dialogOpen, setDialogOpen] = useState(false); if (!config.enable_reservations || !session.exists() || !account) { return <>; } const reservations = account.reservations || []; const limitReached = account.role === Role.USER && account.stats.reservations_remaining === 0; const handleAddClick = () => { setDialogKey((prev) => prev + 1); setDialogOpen(true); }; return ( {t("prefs_reservations_title")} {t("prefs_reservations_description")} {reservations.length > 0 && } {limitReached && {t("prefs_reservations_limit_reached")}} setDialogOpen(false)} /> ); }; const ReservationsTable = (props) => { const { t } = useTranslation(); const [dialogKey, setDialogKey] = useState(0); const [dialogReservation, setDialogReservation] = useState(null); const [editDialogOpen, setEditDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const { subscriptions } = useOutletContext(); const localSubscriptions = subscriptions?.length > 0 ? Object.assign({}, ...subscriptions.filter((s) => s.baseUrl === config.base_url).map((s) => ({ [s.topic]: s }))) : {}; const handleEditClick = (reservation) => { setDialogKey((prev) => prev + 1); setDialogReservation(reservation); setEditDialogOpen(true); }; const handleDeleteClick = async (reservation) => { setDialogKey((prev) => prev + 1); setDialogReservation(reservation); setDeleteDialogOpen(true); }; const handleSubscribeClick = async (reservation) => { await subscribeTopic(config.base_url, reservation.topic); }; return ( {t("prefs_reservations_table_topic_header")} {t("prefs_reservations_table_access_header")} {props.reservations.map((reservation) => ( {reservation.topic} {reservation.everyone === Permission.READ_WRITE && ( <> {t("prefs_reservations_table_everyone_read_write")} )} {reservation.everyone === Permission.READ_ONLY && ( <> {t("prefs_reservations_table_everyone_read_only")} )} {reservation.everyone === Permission.WRITE_ONLY && ( <> {t("prefs_reservations_table_everyone_write_only")} )} {reservation.everyone === Permission.DENY_ALL && ( <> {t("prefs_reservations_table_everyone_deny_all")} )} {!localSubscriptions[reservation.topic] && ( } onClick={() => handleSubscribeClick(reservation)} label={t("prefs_reservations_table_not_subscribed")} color="primary" variant="outlined" /> )} handleEditClick(reservation)} aria-label={t("prefs_reservations_edit_button")}> handleDeleteClick(reservation)} aria-label={t("prefs_reservations_delete_button")}> ))} setEditDialogOpen(false)} /> setDeleteDialogOpen(false)} />
); }; export default Preferences;