| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332 |
- import * as React from "react";
- import { useContext, useState } from "react";
- import {
- Button,
- TextField,
- Dialog,
- DialogContent,
- DialogContentText,
- DialogTitle,
- Autocomplete,
- FormControlLabel,
- FormGroup,
- useMediaQuery,
- Switch,
- useTheme,
- } from "@mui/material";
- import { useTranslation } from "react-i18next";
- import { useLiveQuery } from "dexie-react-hooks";
- import api from "../app/Api";
- import { randomAlphanumericString, topicUrl, validTopic, validUrl } from "../app/utils";
- import userManager from "../app/UserManager";
- import subscriptionManager from "../app/SubscriptionManager";
- import poller from "../app/Poller";
- import DialogFooter from "./DialogFooter";
- import session from "../app/Session";
- import routes from "./routes";
- import accountApi, { Permission, Role } from "../app/AccountApi";
- import ReserveTopicSelect from "./ReserveTopicSelect";
- import { AccountContext } from "./App";
- import { TopicReservedError, UnauthorizedError } from "../app/errors";
- import { ReserveLimitChip } from "./SubscriptionPopup";
- import prefs from "../app/Prefs";
- const publicBaseUrl = "https://ntfy.sh";
- export const subscribeTopic = async (baseUrl, topic, opts) => {
- const subscription = await subscriptionManager.add(baseUrl, topic, opts);
- if (session.exists()) {
- try {
- await accountApi.addSubscription(baseUrl, topic);
- } catch (e) {
- console.log(`[SubscribeDialog] Subscribing to topic ${topic} failed`, e);
- if (e instanceof UnauthorizedError) {
- await session.resetAndRedirect(routes.login);
- }
- }
- }
- return subscription;
- };
- const SubscribeDialog = (props) => {
- const theme = useTheme();
- const [baseUrl, setBaseUrl] = useState("");
- const [topic, setTopic] = useState("");
- const [showLoginPage, setShowLoginPage] = useState(false);
- const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
- const handleSuccess = async () => {
- console.log(`[SubscribeDialog] Subscribing to topic ${topic}`);
- const actualBaseUrl = baseUrl || config.base_url;
- const subscription = await subscribeTopic(actualBaseUrl, topic, {});
- poller.pollInBackground(subscription); // Dangle!
- props.onSuccess(subscription);
- };
- return (
- <Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
- {!showLoginPage && (
- <SubscribePage
- baseUrl={baseUrl}
- setBaseUrl={setBaseUrl}
- topic={topic}
- setTopic={setTopic}
- subscriptions={props.subscriptions}
- onCancel={props.onCancel}
- onNeedsLogin={() => setShowLoginPage(true)}
- onSuccess={handleSuccess}
- />
- )}
- {showLoginPage && <LoginPage baseUrl={baseUrl} topic={topic} onBack={() => setShowLoginPage(false)} onSuccess={handleSuccess} />}
- </Dialog>
- );
- };
- const SubscribePage = (props) => {
- const { t } = useTranslation();
- const { account } = useContext(AccountContext);
- const [error, setError] = useState("");
- const [reserveTopicVisible, setReserveTopicVisible] = useState(false);
- const [anotherServerVisible, setAnotherServerVisible] = useState(false);
- const [everyone, setEveryone] = useState(Permission.DENY_ALL);
- const baseUrl = anotherServerVisible ? props.baseUrl : config.base_url;
- const { topic } = props;
- const existingTopicUrls = props.subscriptions.map((s) => topicUrl(s.baseUrl, s.topic));
- const existingBaseUrls = Array.from(new Set([publicBaseUrl, ...props.subscriptions.map((s) => s.baseUrl)])).filter(
- (s) => s !== config.base_url
- );
- const showReserveTopicCheckbox = config.enable_reservations && !anotherServerVisible && (config.enable_payments || account);
- const reserveTopicEnabled =
- session.exists() && (account?.role === Role.ADMIN || (account?.role === Role.USER && (account?.stats.reservations_remaining || 0) > 0));
- const webPushEnabled = useLiveQuery(() => prefs.webPushEnabled());
- const handleSubscribe = async () => {
- const user = await userManager.get(baseUrl); // May be undefined
- const username = user ? user.username : t("subscribe_dialog_error_user_anonymous");
- // Check read access to topic
- const success = await api.topicAuth(baseUrl, topic, user);
- if (!success) {
- console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`);
- if (user) {
- setError(
- t("subscribe_dialog_error_user_not_authorized", {
- username,
- })
- );
- return;
- }
- props.onNeedsLogin();
- return;
- }
- // Reserve topic (if requested)
- if (session.exists() && baseUrl === config.base_url && reserveTopicVisible) {
- console.log(`[SubscribeDialog] Reserving topic ${topic} with everyone access ${everyone}`);
- try {
- await accountApi.upsertReservation(topic, everyone);
- } catch (e) {
- console.log(`[SubscribeDialog] Error reserving topic`, e);
- if (e instanceof UnauthorizedError) {
- await session.resetAndRedirect(routes.login);
- } else if (e instanceof TopicReservedError) {
- setError(t("subscribe_dialog_error_topic_already_reserved"));
- return;
- }
- }
- }
- console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`);
- props.onSuccess();
- };
- const handleUseAnotherChanged = (e) => {
- props.setBaseUrl("");
- setAnotherServerVisible(e.target.checked);
- };
- const subscribeButtonEnabled = (() => {
- if (anotherServerVisible) {
- const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(baseUrl, topic));
- return validTopic(topic) && validUrl(baseUrl) && !isExistingTopicUrl;
- }
- const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(config.base_url, topic));
- return validTopic(topic) && !isExistingTopicUrl;
- })();
- const updateBaseUrl = (ev, newVal) => {
- if (validUrl(newVal)) {
- props.setBaseUrl(newVal.replace(/\/$/, "")); // strip trailing slash after https?://
- } else {
- props.setBaseUrl(newVal);
- }
- };
- return (
- <>
- <DialogTitle>{t("subscribe_dialog_subscribe_title")}</DialogTitle>
- <DialogContent>
- <DialogContentText>{t("subscribe_dialog_subscribe_description")}</DialogContentText>
- <div style={{ display: "flex", paddingBottom: "8px" }} role="row">
- <TextField
- autoFocus
- margin="dense"
- id="topic"
- placeholder={t("subscribe_dialog_subscribe_topic_placeholder")}
- value={props.topic}
- onChange={(ev) => props.setTopic(ev.target.value)}
- type="text"
- fullWidth
- variant="standard"
- inputProps={{
- maxLength: 64,
- "aria-label": t("subscribe_dialog_subscribe_topic_placeholder"),
- }}
- />
- <Button
- onClick={() => {
- props.setTopic(randomAlphanumericString(16));
- }}
- style={{ flexShrink: "0", marginTop: "0.5em" }}
- >
- {t("subscribe_dialog_subscribe_button_generate_topic_name")}
- </Button>
- </div>
- {showReserveTopicCheckbox && (
- <FormGroup>
- <FormControlLabel
- variant="standard"
- control={
- <Switch
- disabled={!reserveTopicEnabled}
- checked={reserveTopicVisible}
- onChange={(ev) => setReserveTopicVisible(ev.target.checked)}
- inputProps={{
- "aria-label": t("reserve_dialog_checkbox_label"),
- }}
- />
- }
- label={
- <>
- {t("reserve_dialog_checkbox_label")}
- <ReserveLimitChip />
- </>
- }
- />
- {reserveTopicVisible && <ReserveTopicSelect value={everyone} onChange={setEveryone} />}
- </FormGroup>
- )}
- {!reserveTopicVisible && (
- <FormGroup>
- <FormControlLabel
- control={
- <Switch
- onChange={handleUseAnotherChanged}
- checked={anotherServerVisible}
- inputProps={{
- "aria-label": t("subscribe_dialog_subscribe_use_another_label"),
- }}
- />
- }
- label={t("subscribe_dialog_subscribe_use_another_label")}
- />
- {anotherServerVisible && (
- <Autocomplete
- freeSolo
- options={existingBaseUrls}
- inputValue={props.baseUrl}
- onInputChange={updateBaseUrl}
- renderInput={(params) => (
- <>
- <TextField
- {...params}
- placeholder={config.base_url}
- variant="standard"
- aria-label={t("subscribe_dialog_subscribe_base_url_label")}
- />
- {webPushEnabled && (
- <div style={{ width: "100%", color: "#aaa", fontSize: "0.75rem", marginTop: "0.5rem" }}>
- {t("subscribe_dialog_subscribe_use_another_background_info")}
- </div>
- )}
- </>
- )}
- />
- )}
- </FormGroup>
- )}
- </DialogContent>
- <DialogFooter status={error}>
- <Button onClick={props.onCancel}>{t("subscribe_dialog_subscribe_button_cancel")}</Button>
- <Button onClick={handleSubscribe} disabled={!subscribeButtonEnabled}>
- {t("subscribe_dialog_subscribe_button_subscribe")}
- </Button>
- </DialogFooter>
- </>
- );
- };
- const LoginPage = (props) => {
- const { t } = useTranslation();
- const [username, setUsername] = useState("");
- const [password, setPassword] = useState("");
- const [error, setError] = useState("");
- const baseUrl = props.baseUrl ? props.baseUrl : config.base_url;
- const { topic } = props;
- const handleLogin = async () => {
- const user = { baseUrl, username, password };
- const success = await api.topicAuth(baseUrl, topic, user);
- if (!success) {
- console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`);
- setError(t("subscribe_dialog_error_user_not_authorized", { username }));
- return;
- }
- console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`);
- await userManager.save(user);
- props.onSuccess();
- };
- return (
- <>
- <DialogTitle>{t("subscribe_dialog_login_title")}</DialogTitle>
- <DialogContent>
- <DialogContentText>{t("subscribe_dialog_login_description")}</DialogContentText>
- <TextField
- autoFocus
- margin="dense"
- id="username"
- label={t("subscribe_dialog_login_username_label")}
- value={username}
- onChange={(ev) => setUsername(ev.target.value)}
- type="text"
- fullWidth
- variant="standard"
- inputProps={{
- "aria-label": t("subscribe_dialog_login_username_label"),
- }}
- />
- <TextField
- margin="dense"
- id="password"
- label={t("subscribe_dialog_login_password_label")}
- type="password"
- value={password}
- onChange={(ev) => setPassword(ev.target.value)}
- fullWidth
- variant="standard"
- inputProps={{
- "aria-label": t("subscribe_dialog_login_password_label"),
- }}
- />
- </DialogContent>
- <DialogFooter status={error}>
- <Button onClick={props.onBack}>{t("common_back")}</Button>
- <Button onClick={handleLogin}>{t("subscribe_dialog_login_button_login")}</Button>
- </DialogFooter>
- </>
- );
- };
- export default SubscribeDialog;
|