SendDialog.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473
  1. import * as React from 'react';
  2. import {useEffect, useRef, useState} from 'react';
  3. import {NotificationItem} from "./Notifications";
  4. import theme from "./theme";
  5. import {Chip, FormControl, InputLabel, Link, Select, useMediaQuery} from "@mui/material";
  6. import TextField from "@mui/material/TextField";
  7. import priority1 from "../img/priority-1.svg";
  8. import priority2 from "../img/priority-2.svg";
  9. import priority3 from "../img/priority-3.svg";
  10. import priority4 from "../img/priority-4.svg";
  11. import priority5 from "../img/priority-5.svg";
  12. import Dialog from "@mui/material/Dialog";
  13. import DialogTitle from "@mui/material/DialogTitle";
  14. import DialogContent from "@mui/material/DialogContent";
  15. import Button from "@mui/material/Button";
  16. import Typography from "@mui/material/Typography";
  17. import IconButton from "@mui/material/IconButton";
  18. import InsertEmoticonIcon from '@mui/icons-material/InsertEmoticon';
  19. import {Close} from "@mui/icons-material";
  20. import MenuItem from "@mui/material/MenuItem";
  21. import {basicAuth, formatBytes, shortUrl, splitNoEmpty, splitTopicUrl, validTopicUrl} from "../app/utils";
  22. import Box from "@mui/material/Box";
  23. import Icon from "./Icon";
  24. import DialogFooter from "./DialogFooter";
  25. import api from "../app/Api";
  26. import Divider from "@mui/material/Divider";
  27. import EditIcon from '@mui/icons-material/Edit';
  28. import CheckIcon from '@mui/icons-material/Check';
  29. import userManager from "../app/UserManager";
  30. const SendDialog = (props) => {
  31. const [topicUrl, setTopicUrl] = useState(props.topicUrl);
  32. const [message, setMessage] = useState(props.message || "");
  33. const [title, setTitle] = useState("");
  34. const [tags, setTags] = useState("");
  35. const [priority, setPriority] = useState(3);
  36. const [clickUrl, setClickUrl] = useState("");
  37. const [attachUrl, setAttachUrl] = useState("");
  38. const [attachFile, setAttachFile] = useState(null);
  39. const [filename, setFilename] = useState("");
  40. const [filenameEdited, setFilenameEdited] = useState(false);
  41. const [email, setEmail] = useState("");
  42. const [delay, setDelay] = useState("");
  43. const [showTopicUrl, setShowTopicUrl] = useState(props.topicUrl === "");
  44. const [showClickUrl, setShowClickUrl] = useState(false);
  45. const [showAttachUrl, setShowAttachUrl] = useState(false);
  46. const [showEmail, setShowEmail] = useState(false);
  47. const [showDelay, setShowDelay] = useState(false);
  48. const showAttachFile = !!attachFile && !showAttachUrl;
  49. const attachFileInput = useRef();
  50. const [sendRequest, setSendRequest] = useState(null);
  51. const [statusText, setStatusText] = useState("");
  52. const disabled = !!sendRequest;
  53. const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
  54. const sendButtonEnabled = (() => {
  55. if (!validTopicUrl(topicUrl)) {
  56. return false;
  57. }
  58. return true;
  59. })();
  60. const handleSubmit = async () => {
  61. const { baseUrl, topic } = splitTopicUrl(topicUrl);
  62. const headers = {};
  63. if (title.trim()) {
  64. headers["X-Title"] = title.trim();
  65. }
  66. if (tags.trim()) {
  67. headers["X-Tags"] = tags.trim();
  68. }
  69. if (priority && priority !== 3) {
  70. headers["X-Priority"] = priority.toString();
  71. }
  72. if (clickUrl.trim()) {
  73. headers["X-Click"] = clickUrl.trim();
  74. }
  75. if (attachUrl.trim()) {
  76. headers["X-Attach"] = attachUrl.trim();
  77. }
  78. if (filename.trim()) {
  79. headers["X-Filename"] = filename.trim();
  80. }
  81. if (email.trim()) {
  82. headers["X-Email"] = email.trim();
  83. }
  84. if (delay.trim()) {
  85. headers["X-Delay"] = delay.trim();
  86. }
  87. if (attachFile && message.trim()) {
  88. headers["X-Message"] = message.replaceAll("\n", "\\n").trim();
  89. }
  90. const body = (attachFile) ? attachFile : message;
  91. try {
  92. const user = await userManager.get(baseUrl);
  93. if (user) {
  94. headers["Authorization"] = basicAuth(user.username, user.password);
  95. }
  96. const progressFn = (ev) => {
  97. console.log(ev);
  98. if (ev.loaded > 0 && ev.total > 0) {
  99. const percent = Math.round(ev.loaded * 100.0 / ev.total);
  100. setStatusText(`Uploading ${formatBytes(ev.loaded)}/${formatBytes(ev.total)} (${percent}%) ...`);
  101. } else {
  102. setStatusText(`Uploading ...`);
  103. }
  104. };
  105. const request = api.publishXHR(baseUrl, topic, body, headers, progressFn);
  106. setSendRequest(request);
  107. await request;
  108. setStatusText("Message published");
  109. //props.onClose();
  110. } catch (e) {
  111. console.log("error", e);
  112. setStatusText("An error occurred");
  113. }
  114. setSendRequest(null);
  115. };
  116. const handleAttachFileClick = () => {
  117. attachFileInput.current.click();
  118. };
  119. const handleAttachFileChanged = (ev) => {
  120. const file = ev.target.files[0];
  121. setAttachFile(file);
  122. setFilename(file.name);
  123. console.log(ev.target.files[0]);
  124. console.log(URL.createObjectURL(ev.target.files[0]));
  125. };
  126. return (
  127. <Dialog maxWidth="md" open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
  128. <DialogTitle>Publish to {shortUrl(topicUrl)}</DialogTitle>
  129. <DialogContent>
  130. {showTopicUrl &&
  131. <ClosableRow disabled={disabled} onClose={() => {
  132. setTopicUrl(props.topicUrl);
  133. setShowTopicUrl(false);
  134. }}>
  135. <TextField
  136. margin="dense"
  137. label="Topic URL"
  138. value={topicUrl}
  139. onChange={ev => setTopicUrl(ev.target.value)}
  140. disabled={disabled}
  141. type="text"
  142. variant="standard"
  143. fullWidth
  144. required
  145. />
  146. </ClosableRow>
  147. }
  148. <TextField
  149. margin="dense"
  150. label="Title"
  151. value={title}
  152. onChange={ev => setTitle(ev.target.value)}
  153. disabled={disabled}
  154. type="text"
  155. fullWidth
  156. variant="standard"
  157. placeholder="Notification title, e.g. Disk space alert"
  158. />
  159. <TextField
  160. margin="dense"
  161. label="Message"
  162. placeholder="Type the main message body here."
  163. value={message}
  164. onChange={ev => setMessage(ev.target.value)}
  165. disabled={disabled}
  166. type="text"
  167. variant="standard"
  168. rows={5}
  169. fullWidth
  170. autoFocus
  171. multiline
  172. />
  173. <div style={{display: 'flex'}}>
  174. <DialogIconButton disabled={disabled} onClick={() => null}><InsertEmoticonIcon/></DialogIconButton>
  175. <TextField
  176. margin="dense"
  177. label="Tags"
  178. placeholder="Comma-separated list of tags, e.g. warning, srv1-backup"
  179. value={tags}
  180. onChange={ev => setTags(ev.target.value)}
  181. disabled={disabled}
  182. type="text"
  183. variant="standard"
  184. sx={{flexGrow: 1, marginRight: 1}}
  185. />
  186. <FormControl
  187. variant="standard"
  188. margin="dense"
  189. sx={{minWidth: 120, maxWidth: 200, flexGrow: 1}}
  190. >
  191. <InputLabel/>
  192. <Select
  193. label="Priority"
  194. margin="dense"
  195. value={priority}
  196. onChange={(ev) => setPriority(ev.target.value)}
  197. disabled={disabled}
  198. >
  199. {[5,4,3,2,1].map(priority =>
  200. <MenuItem value={priority}>
  201. <div style={{ display: 'flex', alignItems: 'center' }}>
  202. <img src={priorities[priority].file} style={{marginRight: "8px"}}/>
  203. <div>{priorities[priority].label}</div>
  204. </div>
  205. </MenuItem>
  206. )}
  207. </Select>
  208. </FormControl>
  209. </div>
  210. {showClickUrl &&
  211. <ClosableRow disabled={disabled} onClose={() => {
  212. setClickUrl("");
  213. setShowClickUrl(false);
  214. }}>
  215. <TextField
  216. margin="dense"
  217. label="Click URL"
  218. placeholder="URL that is opened when notification is clicked"
  219. value={clickUrl}
  220. onChange={ev => setClickUrl(ev.target.value)}
  221. disabled={disabled}
  222. type="url"
  223. fullWidth
  224. variant="standard"
  225. />
  226. </ClosableRow>
  227. }
  228. {showEmail &&
  229. <ClosableRow disabled={disabled} onClose={() => {
  230. setEmail("");
  231. setShowEmail(false);
  232. }}>
  233. <TextField
  234. margin="dense"
  235. label="Email"
  236. placeholder="Address to forward the message to, e.g. phil@example.com"
  237. value={email}
  238. onChange={ev => setEmail(ev.target.value)}
  239. disabled={disabled}
  240. type="email"
  241. variant="standard"
  242. fullWidth
  243. />
  244. </ClosableRow>
  245. }
  246. {showAttachUrl &&
  247. <ClosableRow disabled={disabled} onClose={() => {
  248. setAttachUrl("");
  249. setFilename("");
  250. setFilenameEdited(false);
  251. setShowAttachUrl(false);
  252. }}>
  253. <TextField
  254. margin="dense"
  255. label="Attachment URL"
  256. placeholder="Attach file by URL, e.g. https://f-droid.org/F-Droid.apk"
  257. value={attachUrl}
  258. onChange={ev => {
  259. const url = ev.target.value;
  260. setAttachUrl(url);
  261. if (!filenameEdited) {
  262. try {
  263. const u = new URL(url);
  264. const parts = u.pathname.split("/");
  265. if (parts.length > 0) {
  266. setFilename(parts[parts.length-1]);
  267. }
  268. } catch (e) {
  269. // Do nothing
  270. }
  271. }
  272. }}
  273. disabled={disabled}
  274. type="url"
  275. variant="standard"
  276. sx={{flexGrow: 5, marginRight: 1}}
  277. />
  278. <TextField
  279. margin="dense"
  280. label="Filename"
  281. placeholder="Attachment filename"
  282. value={filename}
  283. onChange={ev => {
  284. setFilename(ev.target.value);
  285. setFilenameEdited(true);
  286. }}
  287. disabled={disabled}
  288. type="text"
  289. variant="standard"
  290. sx={{flexGrow: 1}}
  291. />
  292. </ClosableRow>
  293. }
  294. <input
  295. type="file"
  296. ref={attachFileInput}
  297. onChange={handleAttachFileChanged}
  298. style={{ display: 'none' }}
  299. />
  300. {showAttachFile && <AttachmentBox
  301. file={attachFile}
  302. filename={filename}
  303. disabled={disabled}
  304. onChangeFilename={(f) => setFilename(f)}
  305. onClose={() => {
  306. setAttachFile(null);
  307. setFilename("");
  308. }}
  309. />}
  310. {showDelay &&
  311. <ClosableRow disabled={disabled} onClose={() => {
  312. setDelay("");
  313. setShowDelay(false);
  314. }}>
  315. <TextField
  316. margin="dense"
  317. label="Delay"
  318. placeholder="Unix timestamp, duration or English natural language"
  319. value={delay}
  320. onChange={ev => setDelay(ev.target.value)}
  321. disabled={disabled}
  322. type="text"
  323. variant="standard"
  324. fullWidth
  325. />
  326. </ClosableRow>
  327. }
  328. <Typography variant="body1" sx={{marginTop: 2, marginBottom: 1}}>
  329. Other features:
  330. </Typography>
  331. <div>
  332. {!showClickUrl && <Chip clickable disabled={disabled} label="Click URL" onClick={() => setShowClickUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
  333. {!showEmail && <Chip clickable disabled={disabled} label="Forward to email" onClick={() => setShowEmail(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
  334. {!showAttachUrl && !showAttachFile && <Chip clickable disabled={disabled} label="Attach file by URL" onClick={() => setShowAttachUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
  335. {!showAttachFile && !showAttachUrl && <Chip clickable disabled={disabled} label="Attach local file" onClick={() => handleAttachFileClick()} sx={{marginRight: 1, marginBottom: 1}}/>}
  336. {!showDelay && <Chip clickable disabled={disabled} label="Delay delivery" onClick={() => setShowDelay(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
  337. {!showTopicUrl && <Chip clickable disabled={disabled} label="Change topic" onClick={() => setShowTopicUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
  338. </div>
  339. <Typography variant="body1" sx={{marginTop: 1, marginBottom: 1}}>
  340. For examples and a detailed description of all send features, please
  341. refer to the <Link href="/docs">documentation</Link>.
  342. </Typography>
  343. </DialogContent>
  344. <DialogFooter status={statusText}>
  345. {sendRequest && <Button onClick={() => sendRequest.abort()}>Cancel sending</Button>}
  346. {!sendRequest &&
  347. <>
  348. <Button onClick={props.onClose}>Cancel</Button>
  349. <Button onClick={handleSubmit} disabled={!sendButtonEnabled}>Send</Button>
  350. </>
  351. }
  352. </DialogFooter>
  353. </Dialog>
  354. );
  355. };
  356. const Row = (props) => {
  357. return (
  358. <div style={{display: 'flex'}}>
  359. {props.children}
  360. </div>
  361. );
  362. };
  363. const ClosableRow = (props) => {
  364. return (
  365. <Row>
  366. {props.children}
  367. <DialogIconButton disabled={props.disabled} onClick={props.onClose} sx={{marginLeft: "6px"}}><Close/></DialogIconButton>
  368. </Row>
  369. );
  370. };
  371. const DialogIconButton = (props) => {
  372. const sx = props.sx || {};
  373. return (
  374. <IconButton
  375. color="inherit"
  376. size="large"
  377. edge="start"
  378. sx={{height: "45px", marginTop: "17px", ...sx}}
  379. onClick={props.onClick}
  380. disabled={props.disabled}
  381. >
  382. {props.children}
  383. </IconButton>
  384. );
  385. };
  386. const AttachmentBox = (props) => {
  387. const file = props.file;
  388. return (
  389. <>
  390. <Typography variant="body1" sx={{marginTop: 2}}>
  391. Attached file:
  392. </Typography>
  393. <Box sx={{
  394. display: 'flex',
  395. alignItems: 'center',
  396. padding: 0.5,
  397. borderRadius: '4px',
  398. }}>
  399. <Icon type={file.type}/>
  400. <Typography variant="body2" sx={{ marginLeft: 1, textAlign: 'left', color: 'text.primary' }}>
  401. <ExpandingTextField
  402. minWidth={140}
  403. variant="body2"
  404. value={props.filename}
  405. onChange={(ev) => props.onChangeFilename(ev.target.value)}
  406. disabled={props.disabled}
  407. />
  408. <br/>
  409. {formatBytes(file.size)}
  410. </Typography>
  411. <DialogIconButton disabled={props.disabled} onClick={props.onClose} sx={{marginLeft: "6px"}}><Close/></DialogIconButton>
  412. </Box>
  413. </>
  414. );
  415. };
  416. const ExpandingTextField = (props) => {
  417. const invisibleFieldRef = useRef();
  418. const [textWidth, setTextWidth] = useState(props.minWidth);
  419. const determineTextWidth = () => {
  420. const boundingRect = invisibleFieldRef?.current?.getBoundingClientRect();
  421. if (!boundingRect) {
  422. return props.minWidth;
  423. }
  424. return (boundingRect.width >= props.minWidth) ? Math.round(boundingRect.width) : props.minWidth;
  425. };
  426. useEffect(() => {
  427. setTextWidth(determineTextWidth() + 5);
  428. }, [props.value]);
  429. return (
  430. <>
  431. <Typography
  432. ref={invisibleFieldRef}
  433. component="span"
  434. variant={props.variant}
  435. sx={{position: "absolute", left: "-100%"}}
  436. >
  437. {props.value}
  438. </Typography>
  439. <TextField
  440. margin="dense"
  441. placeholder="Attachment filename"
  442. value={props.value}
  443. onChange={props.onChange}
  444. type="text"
  445. variant="standard"
  446. sx={{ width: `${textWidth}px`, borderBottom: "none" }}
  447. InputProps={{ style: { fontSize: theme.typography[props.variant].fontSize } }}
  448. inputProps={{ style: { paddingBottom: 0, paddingTop: 0 } }}
  449. disabled={props.disabled}
  450. />
  451. </>
  452. )
  453. };
  454. const priorities = {
  455. 1: { label: "Minimum priority", file: priority1 },
  456. 2: { label: "Low priority", file: priority2 },
  457. 3: { label: "Default priority", file: priority3 },
  458. 4: { label: "High priority", file: priority4 },
  459. 5: { label: "Maximum priority", file: priority5 }
  460. };
  461. export default SendDialog;