Continued work on the send dialog
This commit is contained in:
		
							parent
							
								
									b6426f0417
								
							
						
					
					
						commit
						3e121f5d3c
					
				
					 7 changed files with 212 additions and 98 deletions
				
			
		|  | @ -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; | ||||
|  |  | |||
|  | @ -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} | ||||
|             /> | ||||
|  |  | |||
							
								
								
									
										29
									
								
								web/src/components/DialogFooter.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								web/src/components/DialogFooter.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -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; | ||||
							
								
								
									
										38
									
								
								web/src/components/Icon.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								web/src/components/Icon.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -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; | ||||
|  | @ -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 ( | ||||
|  |  | |||
|  | @ -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 }, | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue