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 ( { 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 ( + + + {props.status} + + + {props.children} + + + ); +}; + +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 ( + + ); +} + +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 ( - - ); -} - 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 ( - Publish notification + Publish to {shortUrl(topicUrl)} {showTopicUrl && { @@ -173,15 +210,29 @@ const SendDialog = (props) => { /> } - {showAttachUrl && setAttachUrl(ev.target.value)} - type="url" - variant="standard" - fullWidth - />} + {showAttachUrl && + { + setAttachUrl(""); + setShowAttachUrl(false); + }}> + setAttachUrl(ev.target.value)} + type="url" + variant="standard" + fullWidth + /> + + } + + {attachFile && } {(showAttachFile || showAttachUrl) && { {!showClickUrl && setShowClickUrl(true)} sx={{marginRight: 1}}/>} {!showEmail && setShowEmail(true)} sx={{marginRight: 1}}/>} {!showAttachUrl && setShowAttachUrl(true)} sx={{marginRight: 1}}/>} - {!showAttachFile && setShowAttachFile(true)} sx={{marginRight: 1}}/>} + {!showAttachFile && handleAttachFileClick()} sx={{marginRight: 1}}/>} {!showDelay && setShowDelay(true)} sx={{marginRight: 1}}/>} {!showTopicUrl && setShowTopicUrl(true)} sx={{marginRight: 1}}/>} @@ -224,10 +275,10 @@ const SendDialog = (props) => { refer to the documentation. - - + + - + ); }; @@ -244,28 +295,19 @@ const ClosableRow = (props) => { return ( {props.children} - + ); }; -const PrioritySelect = () => { - return ( - - setSendDialogOpen(true)}> - - - - ); -}; - const DialogIconButton = (props) => { + const sx = props.sx || {}; return ( {props.children} @@ -273,6 +315,26 @@ const DialogIconButton = (props) => { ); }; +const AttachmentBox = (props) => { + const file = props.file; + const maybeInfoText = formatBytes(file.size); + return ( + + + + {file.name} + {maybeInfoText} + + + ); +}; + 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 ( - - - {props.status} - - - {props.children} - - - ); -}; - export default SubscribeDialog;