From 3e121f5d3cf15ac57b22c039feda1c6d28f76d99 Mon Sep 17 00:00:00 2001
From: Philipp Heckel <pheckel@datto.com>
Date: Tue, 29 Mar 2022 15:22:26 -0400
Subject: [PATCH] Continued work on the send dialog

---
 web/src/app/utils.js                  |  25 +++++
 web/src/components/App.js             |  14 ++-
 web/src/components/DialogFooter.js    |  29 +++++
 web/src/components/Icon.js            |  38 +++++++
 web/src/components/Notifications.js   |  30 +-----
 web/src/components/SendDialog.js      | 150 ++++++++++++++++++--------
 web/src/components/SubscribeDialog.js |  24 +----
 7 files changed, 212 insertions(+), 98 deletions(-)
 create mode 100644 web/src/components/DialogFooter.js
 create mode 100644 web/src/components/Icon.js

diff --git a/web/src/app/utils.js b/web/src/app/utils.js
index eb440f7f..62eee838 100644
--- a/web/src/app/utils.js
+++ b/web/src/app/utils.js
@@ -22,10 +22,28 @@ export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
 export const expandUrl = (url) => [`https://${url}`, `http://${url}`];
 export const expandSecureUrl = (url) => `https://${url}`;
 
+export const splitTopicUrl = (url) => {
+    if (!validTopicUrl(url)) {
+        throw new Error("Invalid topic URL");
+    }
+    const parts = url.split("/");
+    if (parts.length < 2) {
+        throw new Error("Invalid topic URL");
+    }
+    return {
+        baseUrl: parts.slice(0, parts.length-1).join("/"),
+        topic: parts[parts.length-1]
+    };
+};
+
 export const validUrl = (url) => {
     return url.match(/^https?:\/\//);
 }
 
+export const validTopicUrl = (url) => {
+    return url.match(/^https?:\/\/.+\/.*[^/]/); // At least one other slash
+}
+
 export const validTopic = (topic) => {
     if (disallowedTopic(topic)) {
         return false;
@@ -115,6 +133,13 @@ export const shuffle = (arr) => {
     return arr;
 }
 
+export const splitNoEmpty = (s, delimiter) => {
+    return s
+        .split(delimiter)
+        .map(x => x.trim())
+        .filter(x => x !== "");
+}
+
 /** Non-cryptographic hash function, see https://stackoverflow.com/a/8831937/1440785 */
 export const hashCode = async (s) => {
     let hash = 0;
diff --git a/web/src/components/App.js b/web/src/components/App.js
index 2adeaf79..7ee3242d 100644
--- a/web/src/components/App.js
+++ b/web/src/components/App.js
@@ -127,15 +127,24 @@ const Main = (props) => {
 
 const Sender = (props) => {
     const [message, setMessage] = useState("");
+    const [sendDialogKey, setSendDialogKey] = useState(0);
     const [sendDialogOpen, setSendDialogOpen] = useState(false);
     const subscription = props.selected;
+
     const handleSendClick = () => {
-        api.publish(subscription.baseUrl, subscription.topic, message);
+        api.publish(subscription.baseUrl, subscription.topic, message); // FIXME
         setMessage("");
     };
+
+    const handleSendDialogClose = () => {
+        setSendDialogOpen(false);
+        setSendDialogKey(prev => prev+1);
+    };
+
     if (!props.selected) {
         return null;
     }
+
     return (
         <Paper
             elevation={3}
@@ -172,8 +181,9 @@ const Sender = (props) => {
                 <SendIcon/>
             </IconButton>
             <SendDialog
+                key={`sendDialog${sendDialogKey}`} // Resets dialog when canceled/closed
                 open={sendDialogOpen}
-                onCancel={() => setSendDialogOpen(false)}
+                onClose={handleSendDialogClose}
                 topicUrl={topicUrl(subscription.baseUrl, subscription.topic)}
                 message={message}
             />
diff --git a/web/src/components/DialogFooter.js b/web/src/components/DialogFooter.js
new file mode 100644
index 00000000..efe502b0
--- /dev/null
+++ b/web/src/components/DialogFooter.js
@@ -0,0 +1,29 @@
+import * as React from "react";
+import Box from "@mui/material/Box";
+import DialogContentText from "@mui/material/DialogContentText";
+import DialogActions from "@mui/material/DialogActions";
+
+const DialogFooter = (props) => {
+    return (
+        <Box sx={{
+            display: 'flex',
+            flexDirection: 'row',
+            justifyContent: 'space-between',
+            paddingLeft: '24px',
+            paddingTop: '8px 24px',
+            paddingBottom: '8px 24px',
+        }}>
+            <DialogContentText sx={{
+                margin: '0px',
+                paddingTop: '8px',
+            }}>
+                {props.status}
+            </DialogContentText>
+            <DialogActions>
+                {props.children}
+            </DialogActions>
+        </Box>
+    );
+};
+
+export default DialogFooter;
diff --git a/web/src/components/Icon.js b/web/src/components/Icon.js
new file mode 100644
index 00000000..8a49d435
--- /dev/null
+++ b/web/src/components/Icon.js
@@ -0,0 +1,38 @@
+import * as React from "react";
+import Box from "@mui/material/Box";
+import fileDocument from "../img/file-document.svg";
+import fileImage from "../img/file-image.svg";
+import fileVideo from "../img/file-video.svg";
+import fileAudio from "../img/file-audio.svg";
+import fileApp from "../img/file-app.svg";
+
+const Icon = (props) => {
+    const type = props.type;
+    let imageFile;
+    if (!type) {
+        imageFile = fileDocument;
+    } else if (type.startsWith('image/')) {
+        imageFile = fileImage;
+    } else if (type.startsWith('video/')) {
+        imageFile = fileVideo;
+    } else if (type.startsWith('audio/')) {
+        imageFile = fileAudio;
+    } else if (type === "application/vnd.android.package-archive") {
+        imageFile = fileApp;
+    } else {
+        imageFile = fileDocument;
+    }
+    return (
+        <Box
+            component="img"
+            src={imageFile}
+            loading="lazy"
+            sx={{
+                width: '28px',
+                height: '28px'
+            }}
+        />
+    );
+}
+
+export default Icon;
diff --git a/web/src/components/Notifications.js b/web/src/components/Notifications.js
index f0987a69..b7384d11 100644
--- a/web/src/components/Notifications.js
+++ b/web/src/components/Notifications.js
@@ -43,6 +43,7 @@ import priority2 from "../img/priority-2.svg";
 import priority4 from "../img/priority-4.svg";
 import priority5 from "../img/priority-5.svg";
 import logoOutline from "../img/ntfy-outline.svg";
+import Icon from "./Icon";
 
 const Notifications = (props) => {
     if (props.mode === "all") {
@@ -323,35 +324,6 @@ const Image = (props) => {
     );
 }
 
-const Icon = (props) => {
-    const type = props.type;
-    let imageFile;
-    if (!type) {
-        imageFile = fileDocument;
-    } else if (type.startsWith('image/')) {
-        imageFile = fileImage;
-    } else if (type.startsWith('video/')) {
-        imageFile = fileVideo;
-    } else if (type.startsWith('audio/')) {
-        imageFile = fileAudio;
-    } else if (type === "application/vnd.android.package-archive") {
-        imageFile = fileApp;
-    } else {
-        imageFile = fileDocument;
-    }
-    return (
-        <Box
-            component="img"
-            src={imageFile}
-            loading="lazy"
-            sx={{
-                width: '28px',
-                height: '28px'
-            }}
-        />
-    );
-}
-
 const NoNotifications = (props) => {
     const shortUrl = topicShortUrl(props.subscription.baseUrl, props.subscription.topic);
     return (
diff --git a/web/src/components/SendDialog.js b/web/src/components/SendDialog.js
index c15a5729..cfd78a72 100644
--- a/web/src/components/SendDialog.js
+++ b/web/src/components/SendDialog.js
@@ -1,18 +1,8 @@
 import * as React from 'react';
-import {useState} from 'react';
+import {useRef, useState} from 'react';
 import {NotificationItem} from "./Notifications";
 import theme from "./theme";
-import {
-    Chip,
-    FormControl,
-    InputAdornment, InputLabel,
-    Link,
-    ListItemIcon,
-    ListItemText,
-    Select,
-    Tooltip,
-    useMediaQuery
-} from "@mui/material";
+import {Chip, FormControl, InputLabel, Link, Select, useMediaQuery} from "@mui/material";
 import TextField from "@mui/material/TextField";
 import priority1 from "../img/priority-1.svg";
 import priority2 from "../img/priority-2.svg";
@@ -22,13 +12,17 @@ import priority5 from "../img/priority-5.svg";
 import Dialog from "@mui/material/Dialog";
 import DialogTitle from "@mui/material/DialogTitle";
 import DialogContent from "@mui/material/DialogContent";
-import DialogActions from "@mui/material/DialogActions";
 import Button from "@mui/material/Button";
 import Typography from "@mui/material/Typography";
 import IconButton from "@mui/material/IconButton";
 import InsertEmoticonIcon from '@mui/icons-material/InsertEmoticon';
 import {Close} from "@mui/icons-material";
 import MenuItem from "@mui/material/MenuItem";
+import {formatBytes, shortUrl, splitNoEmpty, splitTopicUrl, validTopicUrl} from "../app/utils";
+import Box from "@mui/material/Box";
+import Icon from "./Icon";
+import DialogFooter from "./DialogFooter";
+import api from "../app/Api";
 
 const SendDialog = (props) => {
     const [topicUrl, setTopicUrl] = useState(props.topicUrl);
@@ -38,6 +32,7 @@ const SendDialog = (props) => {
     const [priority, setPriority] = useState(3);
     const [clickUrl, setClickUrl] = useState("");
     const [attachUrl, setAttachUrl] = useState("");
+    const [attachFile, setAttachFile] = useState(null);
     const [filename, setFilename] = useState("");
     const [email, setEmail] = useState("");
     const [delay, setDelay] = useState("");
@@ -49,20 +44,62 @@ const SendDialog = (props) => {
     const [showEmail, setShowEmail] = useState(false);
     const [showDelay, setShowDelay] = useState(false);
 
+    const attachFileInput = useRef();
+    const [errorText, setErrorText] = useState("");
+
     const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
     const sendButtonEnabled = (() => {
+        if (!validTopicUrl(topicUrl)) {
+            return false;
+        }
         return true;
     })();
     const handleSubmit = async () => {
-        props.onSubmit({
-            baseUrl: "xx",
-            username: username,
-            password: password
-        })
+        const { baseUrl, topic } = splitTopicUrl(topicUrl);
+        const options = {};
+        if (title.trim()) {
+            options["title"] = title.trim();
+        }
+        if (tags.trim()) {
+            options["tags"] = splitNoEmpty(tags, ",");
+        }
+        if (priority && priority !== 3) {
+            options["priority"] = priority;
+        }
+        if (clickUrl.trim()) {
+            options["click"] = clickUrl.trim();
+        }
+        if (attachUrl.trim()) {
+            options["attach"] = attachUrl.trim();
+        }
+        if (filename.trim()) {
+            options["filename"] = filename.trim();
+        }
+        if (email.trim()) {
+            options["email"] = email.trim();
+        }
+        if (delay.trim()) {
+            options["delay"] = delay.trim();
+        }
+        try {
+            const response = await api.publish(baseUrl, topic, message, options);
+            console.log(response);
+            props.onClose();
+        } catch (e) {
+            setErrorText(e);
+        }
+    };
+    const handleAttachFileClick = () => {
+        attachFileInput.current.click();
+    };
+    const handleAttachFileChanged = (ev) => {
+        setAttachFile(ev.target.files[0]);
+        console.log(ev.target.files[0]);
+        console.log(URL.createObjectURL(ev.target.files[0]));
     };
     return (
         <Dialog maxWidth="md" open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
-            <DialogTitle>Publish notification</DialogTitle>
+            <DialogTitle>Publish to {shortUrl(topicUrl)}</DialogTitle>
             <DialogContent>
                 {showTopicUrl &&
                     <ClosableRow onClose={() => {
@@ -173,15 +210,29 @@ const SendDialog = (props) => {
                     />
                     </ClosableRow>
                 }
-                {showAttachUrl && <TextField
-                    margin="dense"
-                    label="Attachment URL"
-                    value={attachUrl}
-                    onChange={ev => setAttachUrl(ev.target.value)}
-                    type="url"
-                    variant="standard"
-                    fullWidth
-                />}
+                {showAttachUrl &&
+                    <ClosableRow onClose={() => {
+                        setAttachUrl("");
+                        setShowAttachUrl(false);
+                    }}>
+                        <TextField
+                            margin="dense"
+                            label="Attachment URL"
+                            value={attachUrl}
+                            onChange={ev => setAttachUrl(ev.target.value)}
+                            type="url"
+                            variant="standard"
+                            fullWidth
+                        />
+                    </ClosableRow>
+                }
+                <input
+                    type="file"
+                    ref={attachFileInput}
+                    onChange={handleAttachFileChanged}
+                    style={{ display: 'none' }}
+                />
+                {attachFile && <AttachmentBox file={attachFile}/>}
                 {(showAttachFile || showAttachUrl) && <TextField
                     margin="dense"
                     label="Attachment Filename"
@@ -215,7 +266,7 @@ const SendDialog = (props) => {
                     {!showClickUrl && <Chip clickable label="Click URL" onClick={() => setShowClickUrl(true)} sx={{marginRight: 1}}/>}
                     {!showEmail && <Chip clickable label="Forward to email" onClick={() => setShowEmail(true)} sx={{marginRight: 1}}/>}
                     {!showAttachUrl && <Chip clickable label="Attach file by URL" onClick={() => setShowAttachUrl(true)} sx={{marginRight: 1}}/>}
-                    {!showAttachFile && <Chip clickable label="Attach local file" onClick={() => setShowAttachFile(true)} sx={{marginRight: 1}}/>}
+                    {!showAttachFile && <Chip clickable label="Attach local file" onClick={() => handleAttachFileClick()} sx={{marginRight: 1}}/>}
                     {!showDelay && <Chip clickable label="Delay delivery" onClick={() => setShowDelay(true)} sx={{marginRight: 1}}/>}
                     {!showTopicUrl && <Chip clickable label="Change topic" onClick={() => setShowTopicUrl(true)} sx={{marginRight: 1}}/>}
                 </div>
@@ -224,10 +275,10 @@ const SendDialog = (props) => {
                     refer to the <Link href="/docs">documentation</Link>.
                 </Typography>
             </DialogContent>
-            <DialogActions>
-                <Button onClick={props.onCancel}>Cancel</Button>
+            <DialogFooter status={errorText}>
+                <Button onClick={props.onClose}>Cancel</Button>
                 <Button onClick={handleSubmit} disabled={!sendButtonEnabled}>Send</Button>
-            </DialogActions>
+            </DialogFooter>
         </Dialog>
     );
 };
@@ -244,28 +295,19 @@ const ClosableRow = (props) => {
     return (
         <Row>
             {props.children}
-            <DialogIconButton onClick={props.onClose}><Close/></DialogIconButton>
+            <DialogIconButton onClick={props.onClose} sx={{marginLeft: "6px"}}><Close/></DialogIconButton>
         </Row>
     );
 };
 
-const PrioritySelect = () => {
-    return (
-        <Tooltip title="Message priority">
-            <IconButton color="inherit" size="large" sx={{height: "45px", marginTop: "15px"}} onClick={() => setSendDialogOpen(true)}>
-                <img src={priority3}/>
-            </IconButton>
-        </Tooltip>
-    );
-};
-
 const DialogIconButton = (props) => {
+    const sx = props.sx || {};
     return (
         <IconButton
             color="inherit"
             size="large"
             edge="start"
-            sx={{height: "45px", marginTop: "17px", marginLeft: "6px"}}
+            sx={{height: "45px", marginTop: "17px", ...sx}}
             onClick={props.onClick}
         >
             {props.children}
@@ -273,6 +315,26 @@ const DialogIconButton = (props) => {
     );
 };
 
+const AttachmentBox = (props) => {
+    const file = props.file;
+    const maybeInfoText = formatBytes(file.size);
+    return (
+        <Box sx={{
+            display: 'flex',
+            alignItems: 'center',
+            marginTop: 2,
+            padding: 1,
+            borderRadius: '4px',
+        }}>
+            <Icon type={file.type}/>
+            <Typography variant="body2" sx={{ marginLeft: 1, textAlign: 'left', color: 'text.primary' }}>
+                <b>{file.name}</b>
+                {maybeInfoText}
+            </Typography>
+        </Box>
+    );
+};
+
 const priorities = {
     1: { label: "Minimum priority", file: priority1 },
     2: { label: "Low priority", file: priority2 },
diff --git a/web/src/components/SubscribeDialog.js b/web/src/components/SubscribeDialog.js
index 4c37d0fc..55836d0b 100644
--- a/web/src/components/SubscribeDialog.js
+++ b/web/src/components/SubscribeDialog.js
@@ -15,6 +15,7 @@ import Box from "@mui/material/Box";
 import userManager from "../app/UserManager";
 import subscriptionManager from "../app/SubscriptionManager";
 import poller from "../app/Poller";
+import DialogFooter from "./DialogFooter";
 
 const publicBaseUrl = "https://ntfy.sh";
 
@@ -188,27 +189,4 @@ const LoginPage = (props) => {
     );
 };
 
-const DialogFooter = (props) => {
-    return (
-        <Box sx={{
-            display: 'flex',
-            flexDirection: 'row',
-            justifyContent: 'space-between',
-            paddingLeft: '24px',
-            paddingTop: '8px 24px',
-            paddingBottom: '8px 24px',
-        }}>
-            <DialogContentText sx={{
-                margin: '0px',
-                paddingTop: '8px',
-            }}>
-                {props.status}
-            </DialogContentText>
-            <DialogActions>
-                {props.children}
-            </DialogActions>
-        </Box>
-    );
-};
-
 export default SubscribeDialog;