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,
useTheme,
} 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 userManager from "../app/UserManager";
import { playSound, shortUrl, 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, { THEME } 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";
import { useIsLaunchedPWA, useNotificationPermissionListener } from "./hooks";
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) {
await session.resetAndRedirect(routes.login);
}
}
};
const Preferences = () => (
);
const Notifications = () => {
const { t } = useTranslation();
const isLaunchedPWA = useIsLaunchedPWA();
const pushPossible = useNotificationPermissionListener(() => notifier.pushPossible());
return (
{t("prefs_notifications_title")}
{!isLaunchedPWA && pushPossible && }
);
};
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 Theme = () => {
const { t } = useTranslation();
const labelId = "prefTheme";
const theme = useLiveQuery(async () => prefs.theme());
const handleChange = async (ev) => {
await prefs.setTheme(ev.target.value);
};
return (
);
};
const WebPushEnabled = () => {
const { t } = useTranslation();
const labelId = "prefWebPushEnabled";
const enabled = useLiveQuery(async () => prefs.webPushEnabled());
const handleChange = async (ev) => {
await prefs.setWebPushEnabled(ev.target.value);
};
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 theme = useTheme();
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 (
);
};
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;