PublishDialog.jsx 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948
  1. import * as React from "react";
  2. import { useContext, useEffect, useRef, useState } from "react";
  3. import {
  4. Checkbox,
  5. Chip,
  6. FormControl,
  7. FormControlLabel,
  8. InputLabel,
  9. Link,
  10. Select,
  11. Tooltip,
  12. useMediaQuery,
  13. TextField,
  14. Dialog,
  15. DialogTitle,
  16. DialogContent,
  17. Button,
  18. Typography,
  19. IconButton,
  20. MenuItem,
  21. Box,
  22. useTheme,
  23. } from "@mui/material";
  24. import InsertEmoticonIcon from "@mui/icons-material/InsertEmoticon";
  25. import { Close } from "@mui/icons-material";
  26. import { Trans, useTranslation } from "react-i18next";
  27. import priority1 from "../img/priority-1.svg";
  28. import priority2 from "../img/priority-2.svg";
  29. import priority3 from "../img/priority-3.svg";
  30. import priority4 from "../img/priority-4.svg";
  31. import priority5 from "../img/priority-5.svg";
  32. import { formatBytes, maybeWithAuth, topicShortUrl, topicUrl, validTopic, validUrl } from "../app/utils";
  33. import AttachmentIcon from "./AttachmentIcon";
  34. import DialogFooter from "./DialogFooter";
  35. import api from "../app/Api";
  36. import userManager from "../app/UserManager";
  37. import EmojiPicker from "./EmojiPicker";
  38. import session from "../app/Session";
  39. import routes from "./routes";
  40. import accountApi from "../app/AccountApi";
  41. import { UnauthorizedError } from "../app/errors";
  42. import { AccountContext } from "./App";
  43. const PublishDialog = (props) => {
  44. const theme = useTheme();
  45. const { t } = useTranslation();
  46. const { account } = useContext(AccountContext);
  47. const [baseUrl, setBaseUrl] = useState("");
  48. const [topic, setTopic] = useState("");
  49. const [message, setMessage] = useState("");
  50. const [messageFocused, setMessageFocused] = useState(true);
  51. const [title, setTitle] = useState("");
  52. const [tags, setTags] = useState("");
  53. const [priority, setPriority] = useState(3);
  54. const [clickUrl, setClickUrl] = useState("");
  55. const [attachUrl, setAttachUrl] = useState("");
  56. const [attachFile, setAttachFile] = useState(null);
  57. const [filename, setFilename] = useState("");
  58. const [filenameEdited, setFilenameEdited] = useState(false);
  59. const [email, setEmail] = useState("");
  60. const [call, setCall] = useState("");
  61. const [delay, setDelay] = useState("");
  62. const [publishAnother, setPublishAnother] = useState(false);
  63. const [markdownEnabled, setMarkdownEnabled] = useState(false);
  64. const [showTopicUrl, setShowTopicUrl] = useState("");
  65. const [showClickUrl, setShowClickUrl] = useState(false);
  66. const [showAttachUrl, setShowAttachUrl] = useState(false);
  67. const [showEmail, setShowEmail] = useState(false);
  68. const [showCall, setShowCall] = useState(false);
  69. const [showDelay, setShowDelay] = useState(false);
  70. const showAttachFile = !!attachFile && !showAttachUrl;
  71. const attachFileInput = useRef();
  72. const [attachFileError, setAttachFileError] = useState("");
  73. const [activeRequest, setActiveRequest] = useState(null);
  74. const [status, setStatus] = useState("");
  75. const disabled = !!activeRequest;
  76. const [emojiPickerAnchorEl, setEmojiPickerAnchorEl] = useState(null);
  77. const [dropZone, setDropZone] = useState(false);
  78. const [sendButtonEnabled, setSendButtonEnabled] = useState(true);
  79. const open = !!props.openMode;
  80. const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
  81. useEffect(() => {
  82. window.addEventListener("dragenter", () => {
  83. props.onDragEnter();
  84. setDropZone(true);
  85. });
  86. }, []);
  87. useEffect(() => {
  88. setBaseUrl(props.baseUrl);
  89. setTopic(props.topic);
  90. setShowTopicUrl(!props.baseUrl || !props.topic);
  91. setMessageFocused(!!props.topic); // Focus message only if topic is set
  92. }, [props.baseUrl, props.topic]);
  93. useEffect(() => {
  94. const valid = validUrl(baseUrl) && validTopic(topic) && !attachFileError;
  95. setSendButtonEnabled(valid);
  96. }, [baseUrl, topic, attachFileError]);
  97. useEffect(() => {
  98. setMessage(props.message);
  99. }, [props.message]);
  100. const updateBaseUrl = (newVal) => {
  101. if (validUrl(newVal)) {
  102. setBaseUrl(newVal.replace(/\/$/, "")); // strip traililng slash after https?://
  103. } else {
  104. setBaseUrl(newVal);
  105. }
  106. };
  107. const handleSubmit = async () => {
  108. const url = new URL(topicUrl(baseUrl, topic));
  109. if (title.trim()) {
  110. url.searchParams.append("title", title.trim());
  111. }
  112. if (tags.trim()) {
  113. url.searchParams.append("tags", tags.trim());
  114. }
  115. if (priority && priority !== 3) {
  116. url.searchParams.append("priority", priority.toString());
  117. }
  118. if (clickUrl.trim()) {
  119. url.searchParams.append("click", clickUrl.trim());
  120. }
  121. if (attachUrl.trim()) {
  122. url.searchParams.append("attach", attachUrl.trim());
  123. }
  124. if (filename.trim()) {
  125. url.searchParams.append("filename", filename.trim());
  126. }
  127. if (email.trim()) {
  128. url.searchParams.append("email", email.trim());
  129. }
  130. if (call.trim()) {
  131. url.searchParams.append("call", call.trim());
  132. }
  133. if (delay.trim()) {
  134. url.searchParams.append("delay", delay.trim());
  135. }
  136. if (attachFile && message.trim()) {
  137. url.searchParams.append("message", message.replaceAll("\n", "\\n").trim());
  138. }
  139. if (markdownEnabled) {
  140. url.searchParams.append("markdown", "true");
  141. }
  142. const body = attachFile || message;
  143. try {
  144. const user = await userManager.get(baseUrl);
  145. const headers = maybeWithAuth({}, user);
  146. const progressFn = (ev) => {
  147. if (ev.loaded > 0 && ev.total > 0) {
  148. setStatus(
  149. t("publish_dialog_progress_uploading_detail", {
  150. loaded: formatBytes(ev.loaded),
  151. total: formatBytes(ev.total),
  152. percent: Math.round((ev.loaded * 100.0) / ev.total),
  153. })
  154. );
  155. } else {
  156. setStatus(t("publish_dialog_progress_uploading"));
  157. }
  158. };
  159. const request = api.publishXHR(url, body, headers, progressFn);
  160. setActiveRequest(request);
  161. await request;
  162. if (!publishAnother) {
  163. props.onClose();
  164. } else {
  165. setStatus(t("publish_dialog_message_published"));
  166. setActiveRequest(null);
  167. }
  168. } catch (e) {
  169. setStatus(<Typography sx={{ color: "error.main", maxWidth: "400px" }}>{e}</Typography>);
  170. setActiveRequest(null);
  171. }
  172. };
  173. const checkAttachmentLimits = async (file) => {
  174. try {
  175. const apiAccount = await accountApi.get();
  176. const fileSizeLimit = apiAccount.limits.attachment_file_size ?? 0;
  177. const remainingBytes = apiAccount.stats.attachment_total_size_remaining;
  178. const fileSizeLimitReached = fileSizeLimit > 0 && file.size > fileSizeLimit;
  179. const quotaReached = remainingBytes > 0 && file.size > remainingBytes;
  180. if (fileSizeLimitReached && quotaReached) {
  181. setAttachFileError(
  182. t("publish_dialog_attachment_limits_file_and_quota_reached", {
  183. fileSizeLimit: formatBytes(fileSizeLimit),
  184. remainingBytes: formatBytes(remainingBytes),
  185. })
  186. );
  187. } else if (fileSizeLimitReached) {
  188. setAttachFileError(
  189. t("publish_dialog_attachment_limits_file_reached", {
  190. fileSizeLimit: formatBytes(fileSizeLimit),
  191. })
  192. );
  193. } else if (quotaReached) {
  194. setAttachFileError(
  195. t("publish_dialog_attachment_limits_quota_reached", {
  196. remainingBytes: formatBytes(remainingBytes),
  197. })
  198. );
  199. } else {
  200. setAttachFileError("");
  201. }
  202. } catch (e) {
  203. console.log(`[PublishDialog] Retrieving attachment limits failed`, e);
  204. if (e instanceof UnauthorizedError) {
  205. await session.resetAndRedirect(routes.login);
  206. } else {
  207. setAttachFileError(""); // Reset error (rely on server-side checking)
  208. }
  209. }
  210. };
  211. const handleAttachFileClick = () => {
  212. attachFileInput.current.click();
  213. };
  214. const updateAttachFile = async (file) => {
  215. setAttachFile(file);
  216. setFilename(file.name);
  217. props.onResetOpenMode();
  218. await checkAttachmentLimits(file);
  219. };
  220. useEffect(() => {
  221. if (props.attachFile) {
  222. updateAttachFile(props.attachFile);
  223. }
  224. }, [props.attachFile]);
  225. const handlePaste = (ev) => {
  226. const blob = props.getPastedImage(ev);
  227. if (blob) {
  228. updateAttachFile(blob);
  229. }
  230. };
  231. const handleAttachFileChanged = async (ev) => {
  232. await updateAttachFile(ev.target.files[0]);
  233. };
  234. const handleAttachFileDrop = async (ev) => {
  235. ev.preventDefault();
  236. setDropZone(false);
  237. await updateAttachFile(ev.dataTransfer.files[0]);
  238. };
  239. const handleAttachFileDragLeave = () => {
  240. setDropZone(false);
  241. if (props.openMode === PublishDialog.OPEN_MODE_DRAG) {
  242. props.onClose(); // Only close dialog if it was not open before dragging file in
  243. }
  244. };
  245. const handleEmojiClick = (ev) => {
  246. setEmojiPickerAnchorEl(ev.currentTarget);
  247. };
  248. const handleEmojiPick = (emoji) => {
  249. setTags((prevTags) => (prevTags.trim() ? `${prevTags.trim()}, ${emoji}` : emoji));
  250. };
  251. const handleEmojiClose = () => {
  252. setEmojiPickerAnchorEl(null);
  253. };
  254. const priorities = {
  255. 1: { label: t("publish_dialog_priority_min"), file: priority1 },
  256. 2: { label: t("publish_dialog_priority_low"), file: priority2 },
  257. 3: { label: t("publish_dialog_priority_default"), file: priority3 },
  258. 4: { label: t("publish_dialog_priority_high"), file: priority4 },
  259. 5: { label: t("publish_dialog_priority_max"), file: priority5 },
  260. };
  261. return (
  262. <>
  263. {dropZone && <DropArea onDrop={handleAttachFileDrop} onDragLeave={handleAttachFileDragLeave} />}
  264. <Dialog maxWidth="md" open={open} onClose={props.onCancel} fullScreen={fullScreen}>
  265. <DialogTitle>
  266. {baseUrl && topic
  267. ? t("publish_dialog_title_topic", {
  268. topic: topicShortUrl(baseUrl, topic),
  269. })
  270. : t("publish_dialog_title_no_topic")}
  271. </DialogTitle>
  272. <DialogContent>
  273. {dropZone && <DropBox />}
  274. {showTopicUrl && (
  275. <ClosableRow
  276. closable={!!props.baseUrl && !!props.topic}
  277. disabled={disabled}
  278. closeLabel={t("publish_dialog_topic_reset")}
  279. onClose={() => {
  280. setBaseUrl(props.baseUrl);
  281. setTopic(props.topic);
  282. setShowTopicUrl(false);
  283. }}
  284. >
  285. <TextField
  286. margin="dense"
  287. label={t("publish_dialog_base_url_label")}
  288. placeholder={t("publish_dialog_base_url_placeholder")}
  289. value={baseUrl}
  290. onChange={(ev) => updateBaseUrl(ev.target.value)}
  291. disabled={disabled}
  292. type="url"
  293. variant="standard"
  294. sx={{ flexGrow: 1, marginRight: 1 }}
  295. inputProps={{
  296. "aria-label": t("publish_dialog_base_url_label"),
  297. }}
  298. />
  299. <TextField
  300. margin="dense"
  301. label={t("publish_dialog_topic_label")}
  302. placeholder={t("publish_dialog_topic_placeholder")}
  303. value={topic}
  304. onChange={(ev) => setTopic(ev.target.value)}
  305. disabled={disabled}
  306. type="text"
  307. variant="standard"
  308. autoFocus={!messageFocused}
  309. sx={{ flexGrow: 1 }}
  310. inputProps={{
  311. "aria-label": t("publish_dialog_topic_label"),
  312. }}
  313. />
  314. </ClosableRow>
  315. )}
  316. <TextField
  317. margin="dense"
  318. label={t("publish_dialog_title_label")}
  319. placeholder={t("publish_dialog_title_placeholder")}
  320. value={title}
  321. onChange={(ev) => setTitle(ev.target.value)}
  322. disabled={disabled}
  323. type="text"
  324. fullWidth
  325. variant="standard"
  326. inputProps={{
  327. "aria-label": t("publish_dialog_title_label"),
  328. }}
  329. />
  330. <TextField
  331. margin="dense"
  332. label={t("publish_dialog_message_label")}
  333. placeholder={t("publish_dialog_message_placeholder")}
  334. value={message}
  335. onChange={(ev) => setMessage(ev.target.value)}
  336. disabled={disabled}
  337. type="text"
  338. variant="standard"
  339. rows={5}
  340. autoFocus={messageFocused}
  341. fullWidth
  342. multiline
  343. inputProps={{
  344. "aria-label": t("publish_dialog_message_label"),
  345. }}
  346. onPaste={handlePaste}
  347. />
  348. <FormControlLabel
  349. label={t("publish_dialog_checkbox_markdown")}
  350. sx={{ marginRight: 2 }}
  351. control={
  352. <Checkbox
  353. size="small"
  354. checked={markdownEnabled}
  355. onChange={(ev) => setMarkdownEnabled(ev.target.checked)}
  356. inputProps={{
  357. "aria-label": t("publish_dialog_checkbox_markdown"),
  358. }}
  359. />
  360. }
  361. />
  362. <div style={{ display: "flex" }}>
  363. <EmojiPicker anchorEl={emojiPickerAnchorEl} onEmojiPick={handleEmojiPick} onClose={handleEmojiClose} />
  364. <DialogIconButton disabled={disabled} onClick={handleEmojiClick} aria-label={t("publish_dialog_emoji_picker_show")}>
  365. <InsertEmoticonIcon />
  366. </DialogIconButton>
  367. <TextField
  368. margin="dense"
  369. label={t("publish_dialog_tags_label")}
  370. placeholder={t("publish_dialog_tags_placeholder")}
  371. value={tags}
  372. onChange={(ev) => setTags(ev.target.value)}
  373. disabled={disabled}
  374. type="text"
  375. variant="standard"
  376. sx={{ flexGrow: 1, marginRight: 1 }}
  377. inputProps={{
  378. "aria-label": t("publish_dialog_tags_label"),
  379. }}
  380. />
  381. <FormControl variant="standard" margin="dense" sx={{ minWidth: 170, maxWidth: 300, flexGrow: 1 }}>
  382. <InputLabel />
  383. <Select
  384. label={t("publish_dialog_priority_label")}
  385. margin="dense"
  386. value={priority}
  387. onChange={(ev) => setPriority(ev.target.value)}
  388. disabled={disabled}
  389. inputProps={{
  390. "aria-label": t("publish_dialog_priority_label"),
  391. }}
  392. >
  393. {[5, 4, 3, 2, 1].map((p) => (
  394. <MenuItem
  395. key={`priorityMenuItem${p}`}
  396. value={p}
  397. aria-label={t("notifications_priority_x", {
  398. priority: p,
  399. })}
  400. >
  401. <div style={{ display: "flex", alignItems: "center" }}>
  402. <img
  403. src={priorities[p].file}
  404. style={{ marginRight: "8px" }}
  405. alt={t("notifications_priority_x", {
  406. priority: p,
  407. })}
  408. />
  409. <div>{priorities[p].label}</div>
  410. </div>
  411. </MenuItem>
  412. ))}
  413. </Select>
  414. </FormControl>
  415. </div>
  416. {showClickUrl && (
  417. <ClosableRow
  418. disabled={disabled}
  419. closeLabel={t("publish_dialog_click_reset")}
  420. onClose={() => {
  421. setClickUrl("");
  422. setShowClickUrl(false);
  423. }}
  424. >
  425. <TextField
  426. margin="dense"
  427. label={t("publish_dialog_click_label")}
  428. placeholder={t("publish_dialog_click_placeholder")}
  429. value={clickUrl}
  430. onChange={(ev) => setClickUrl(ev.target.value)}
  431. disabled={disabled}
  432. type="url"
  433. fullWidth
  434. variant="standard"
  435. inputProps={{
  436. "aria-label": t("publish_dialog_click_label"),
  437. }}
  438. />
  439. </ClosableRow>
  440. )}
  441. {showEmail && (
  442. <ClosableRow
  443. disabled={disabled}
  444. closeLabel={t("publish_dialog_email_reset")}
  445. onClose={() => {
  446. setEmail("");
  447. setShowEmail(false);
  448. }}
  449. >
  450. <TextField
  451. margin="dense"
  452. label={t("publish_dialog_email_label")}
  453. placeholder={t("publish_dialog_email_placeholder")}
  454. value={email}
  455. onChange={(ev) => setEmail(ev.target.value)}
  456. disabled={disabled}
  457. type="email"
  458. variant="standard"
  459. fullWidth
  460. inputProps={{
  461. "aria-label": t("publish_dialog_email_label"),
  462. }}
  463. />
  464. </ClosableRow>
  465. )}
  466. {showCall && (
  467. <ClosableRow
  468. disabled={disabled}
  469. closeLabel={t("publish_dialog_call_reset")}
  470. onClose={() => {
  471. setCall("");
  472. setShowCall(false);
  473. }}
  474. >
  475. <FormControl fullWidth variant="standard" margin="dense">
  476. <InputLabel />
  477. <Select
  478. label={t("publish_dialog_call_label")}
  479. margin="dense"
  480. value={call}
  481. onChange={(ev) => setCall(ev.target.value)}
  482. disabled={disabled}
  483. inputProps={{
  484. "aria-label": t("publish_dialog_call_label"),
  485. }}
  486. >
  487. {account?.phone_numbers?.map((phoneNumber) => (
  488. <MenuItem key={phoneNumber} value={phoneNumber} aria-label={phoneNumber}>
  489. {t("publish_dialog_call_item", { number: phoneNumber })}
  490. </MenuItem>
  491. ))}
  492. </Select>
  493. </FormControl>
  494. </ClosableRow>
  495. )}
  496. {showAttachUrl && (
  497. <ClosableRow
  498. disabled={disabled}
  499. closeLabel={t("publish_dialog_attach_reset")}
  500. onClose={() => {
  501. setAttachUrl("");
  502. setFilename("");
  503. setFilenameEdited(false);
  504. setShowAttachUrl(false);
  505. }}
  506. >
  507. <TextField
  508. margin="dense"
  509. label={t("publish_dialog_attach_label")}
  510. placeholder={t("publish_dialog_attach_placeholder")}
  511. value={attachUrl}
  512. onChange={(ev) => {
  513. const url = ev.target.value;
  514. setAttachUrl(url);
  515. if (!filenameEdited) {
  516. try {
  517. const u = new URL(url);
  518. const parts = u.pathname.split("/");
  519. if (parts.length > 0) {
  520. setFilename(parts[parts.length - 1]);
  521. }
  522. } catch (e) {
  523. // Do nothing
  524. }
  525. }
  526. }}
  527. disabled={disabled}
  528. type="url"
  529. variant="standard"
  530. sx={{ flexGrow: 5, marginRight: 1 }}
  531. inputProps={{
  532. "aria-label": t("publish_dialog_attach_label"),
  533. }}
  534. />
  535. <TextField
  536. margin="dense"
  537. label={t("publish_dialog_filename_label")}
  538. placeholder={t("publish_dialog_filename_placeholder")}
  539. value={filename}
  540. onChange={(ev) => {
  541. setFilename(ev.target.value);
  542. setFilenameEdited(true);
  543. }}
  544. disabled={disabled}
  545. type="text"
  546. variant="standard"
  547. sx={{ flexGrow: 1 }}
  548. inputProps={{
  549. "aria-label": t("publish_dialog_filename_label"),
  550. }}
  551. />
  552. </ClosableRow>
  553. )}
  554. <input type="file" ref={attachFileInput} onChange={handleAttachFileChanged} style={{ display: "none" }} aria-hidden />
  555. {showAttachFile && (
  556. <AttachmentBox
  557. file={attachFile}
  558. filename={filename}
  559. disabled={disabled}
  560. error={attachFileError}
  561. onChangeFilename={(f) => setFilename(f)}
  562. onClose={() => {
  563. setAttachFile(null);
  564. setAttachFileError("");
  565. setFilename("");
  566. }}
  567. />
  568. )}
  569. {showDelay && (
  570. <ClosableRow
  571. disabled={disabled}
  572. closeLabel={t("publish_dialog_delay_reset")}
  573. onClose={() => {
  574. setDelay("");
  575. setShowDelay(false);
  576. }}
  577. >
  578. <TextField
  579. margin="dense"
  580. label={t("publish_dialog_delay_label")}
  581. placeholder={t("publish_dialog_delay_placeholder", {
  582. unixTimestamp: "1649029748",
  583. relativeTime: "30m",
  584. naturalLanguage: "tomorrow, 9am",
  585. })}
  586. value={delay}
  587. onChange={(ev) => setDelay(ev.target.value)}
  588. disabled={disabled}
  589. type="text"
  590. variant="standard"
  591. fullWidth
  592. inputProps={{
  593. "aria-label": t("publish_dialog_delay_label"),
  594. }}
  595. />
  596. </ClosableRow>
  597. )}
  598. <Typography variant="body1" sx={{ marginTop: 2, marginBottom: 1 }}>
  599. {t("publish_dialog_other_features")}
  600. </Typography>
  601. <div>
  602. {!showClickUrl && (
  603. <Chip
  604. clickable
  605. disabled={disabled}
  606. label={t("publish_dialog_chip_click_label")}
  607. aria-label={t("publish_dialog_chip_click_label")}
  608. onClick={() => setShowClickUrl(true)}
  609. sx={{ marginRight: 1, marginBottom: 1 }}
  610. />
  611. )}
  612. {!showEmail && (
  613. <Chip
  614. clickable
  615. disabled={disabled}
  616. label={t("publish_dialog_chip_email_label")}
  617. aria-label={t("publish_dialog_chip_email_label")}
  618. onClick={() => setShowEmail(true)}
  619. sx={{ marginRight: 1, marginBottom: 1 }}
  620. />
  621. )}
  622. {account?.phone_numbers?.length > 0 && !showCall && (
  623. <Chip
  624. clickable
  625. disabled={disabled}
  626. label={t("publish_dialog_chip_call_label")}
  627. aria-label={t("publish_dialog_chip_call_label")}
  628. onClick={() => {
  629. setShowCall(true);
  630. setCall(account.phone_numbers[0]);
  631. }}
  632. sx={{ marginRight: 1, marginBottom: 1 }}
  633. />
  634. )}
  635. {!showAttachUrl && !showAttachFile && (
  636. <Chip
  637. clickable
  638. disabled={disabled}
  639. label={t("publish_dialog_chip_attach_url_label")}
  640. aria-label={t("publish_dialog_chip_attach_url_label")}
  641. onClick={() => setShowAttachUrl(true)}
  642. sx={{ marginRight: 1, marginBottom: 1 }}
  643. />
  644. )}
  645. {!showAttachFile && !showAttachUrl && (
  646. <Chip
  647. clickable
  648. disabled={disabled}
  649. label={t("publish_dialog_chip_attach_file_label")}
  650. aria-label={t("publish_dialog_chip_attach_file_label")}
  651. onClick={() => handleAttachFileClick()}
  652. sx={{ marginRight: 1, marginBottom: 1 }}
  653. />
  654. )}
  655. {!showDelay && (
  656. <Chip
  657. clickable
  658. disabled={disabled}
  659. label={t("publish_dialog_chip_delay_label")}
  660. aria-label={t("publish_dialog_chip_delay_label")}
  661. onClick={() => setShowDelay(true)}
  662. sx={{ marginRight: 1, marginBottom: 1 }}
  663. />
  664. )}
  665. {!showTopicUrl && (
  666. <Chip
  667. clickable
  668. disabled={disabled}
  669. label={t("publish_dialog_chip_topic_label")}
  670. aria-label={t("publish_dialog_chip_topic_label")}
  671. onClick={() => setShowTopicUrl(true)}
  672. sx={{ marginRight: 1, marginBottom: 1 }}
  673. />
  674. )}
  675. {account && !account?.phone_numbers && (
  676. <Tooltip title={t("publish_dialog_chip_call_no_verified_numbers_tooltip")}>
  677. <span>
  678. <Chip
  679. clickable
  680. disabled
  681. label={t("publish_dialog_chip_call_label")}
  682. aria-label={t("publish_dialog_chip_call_label")}
  683. sx={{ marginRight: 1, marginBottom: 1 }}
  684. />
  685. </span>
  686. </Tooltip>
  687. )}
  688. </div>
  689. <Typography variant="body1" sx={{ marginTop: 1, marginBottom: 1 }}>
  690. <Trans
  691. i18nKey="publish_dialog_details_examples_description"
  692. components={{
  693. docsLink: <Link href="https://ntfy.sh/docs" target="_blank" rel="noopener" />,
  694. }}
  695. />
  696. </Typography>
  697. </DialogContent>
  698. <DialogFooter status={status}>
  699. {activeRequest && <Button onClick={() => activeRequest.abort()}>{t("publish_dialog_button_cancel_sending")}</Button>}
  700. {!activeRequest && (
  701. <>
  702. <FormControlLabel
  703. label={t("publish_dialog_checkbox_publish_another")}
  704. sx={{ marginRight: 2 }}
  705. control={
  706. <Checkbox
  707. size="small"
  708. checked={publishAnother}
  709. onChange={(ev) => setPublishAnother(ev.target.checked)}
  710. inputProps={{
  711. "aria-label": t("publish_dialog_checkbox_publish_another"),
  712. }}
  713. />
  714. }
  715. />
  716. <Button onClick={props.onClose}>{t("publish_dialog_button_cancel")}</Button>
  717. <Button onClick={handleSubmit} disabled={!sendButtonEnabled}>
  718. {t("publish_dialog_button_send")}
  719. </Button>
  720. </>
  721. )}
  722. </DialogFooter>
  723. </Dialog>
  724. </>
  725. );
  726. };
  727. const Row = (props) => (
  728. <div style={{ display: "flex" }} role="row">
  729. {props.children}
  730. </div>
  731. );
  732. const ClosableRow = (props) => {
  733. const closable = props.closable !== undefined ? props.closable : true;
  734. return (
  735. <Row>
  736. {props.children}
  737. {closable && (
  738. <DialogIconButton disabled={props.disabled} onClick={props.onClose} sx={{ marginLeft: "6px" }} aria-label={props.closeLabel}>
  739. <Close />
  740. </DialogIconButton>
  741. )}
  742. </Row>
  743. );
  744. };
  745. const DialogIconButton = (props) => {
  746. const sx = props.sx || {};
  747. return (
  748. <IconButton
  749. color="inherit"
  750. size="large"
  751. edge="start"
  752. sx={{ height: "45px", marginTop: "17px", ...sx }}
  753. onClick={props.onClick}
  754. disabled={props.disabled}
  755. aria-label={props["aria-label"]}
  756. >
  757. {props.children}
  758. </IconButton>
  759. );
  760. };
  761. const AttachmentBox = (props) => {
  762. const { t } = useTranslation();
  763. const { file } = props;
  764. return (
  765. <>
  766. <Typography variant="body1" sx={{ marginTop: 2 }}>
  767. {t("publish_dialog_attached_file_title")}
  768. </Typography>
  769. <Box
  770. sx={{
  771. display: "flex",
  772. alignItems: "center",
  773. padding: 0.5,
  774. borderRadius: "4px",
  775. }}
  776. >
  777. <AttachmentIcon type={file.type} href={URL.createObjectURL(file)} />
  778. <Box sx={{ marginLeft: 1, textAlign: "left" }}>
  779. <ExpandingTextField
  780. minWidth={140}
  781. variant="body2"
  782. placeholder={t("publish_dialog_attached_file_filename_placeholder")}
  783. value={props.filename}
  784. onChange={(ev) => props.onChangeFilename(ev.target.value)}
  785. disabled={props.disabled}
  786. />
  787. <br />
  788. <Typography variant="body2" sx={{ color: "text.primary" }}>
  789. {formatBytes(file.size)}
  790. {props.error && (
  791. <Typography component="span" sx={{ color: "error.main" }} aria-live="polite">
  792. {" "}
  793. ({props.error})
  794. </Typography>
  795. )}
  796. </Typography>
  797. </Box>
  798. <DialogIconButton
  799. disabled={props.disabled}
  800. onClick={props.onClose}
  801. sx={{ marginLeft: "6px" }}
  802. aria-label={t("publish_dialog_attached_file_remove")}
  803. >
  804. <Close />
  805. </DialogIconButton>
  806. </Box>
  807. </>
  808. );
  809. };
  810. const ExpandingTextField = (props) => {
  811. const theme = useTheme();
  812. const invisibleFieldRef = useRef();
  813. const [textWidth, setTextWidth] = useState(props.minWidth);
  814. const determineTextWidth = () => {
  815. const boundingRect = invisibleFieldRef?.current?.getBoundingClientRect();
  816. if (!boundingRect) {
  817. return props.minWidth;
  818. }
  819. return boundingRect.width >= props.minWidth ? Math.round(boundingRect.width) : props.minWidth;
  820. };
  821. useEffect(() => {
  822. setTextWidth(determineTextWidth() + 5);
  823. }, [props.value]);
  824. return (
  825. <>
  826. <Typography ref={invisibleFieldRef} component="span" variant={props.variant} aria-hidden sx={{ position: "absolute", left: "-200%" }}>
  827. {props.value}
  828. </Typography>
  829. <TextField
  830. margin="dense"
  831. placeholder={props.placeholder}
  832. value={props.value}
  833. onChange={props.onChange}
  834. type="text"
  835. variant="standard"
  836. sx={{ width: `${textWidth}px`, borderBottom: "none" }}
  837. InputProps={{
  838. style: { fontSize: theme.typography[props.variant].fontSize },
  839. }}
  840. inputProps={{
  841. style: { paddingBottom: 0, paddingTop: 0 },
  842. "aria-label": props.placeholder,
  843. }}
  844. disabled={props.disabled}
  845. />
  846. </>
  847. );
  848. };
  849. const DropArea = (props) => {
  850. const allowDrag = (ev) => {
  851. // This is where we could disallow certain files to be dragged in.
  852. // For now we allow all files.
  853. // eslint-disable-next-line no-param-reassign
  854. ev.dataTransfer.dropEffect = "copy";
  855. ev.preventDefault();
  856. };
  857. return (
  858. <Box
  859. sx={{
  860. position: "absolute",
  861. left: 0,
  862. top: 0,
  863. right: 0,
  864. bottom: 0,
  865. zIndex: 10002,
  866. }}
  867. onDrop={props.onDrop}
  868. onDragEnter={allowDrag}
  869. onDragOver={allowDrag}
  870. onDragLeave={props.onDragLeave}
  871. />
  872. );
  873. };
  874. const DropBox = () => {
  875. const { t } = useTranslation();
  876. return (
  877. <Box
  878. sx={{
  879. position: "absolute",
  880. left: 0,
  881. top: 0,
  882. right: 0,
  883. bottom: 0,
  884. zIndex: 10000,
  885. backgroundColor: "#ffffffbb",
  886. }}
  887. >
  888. <Box
  889. sx={{
  890. position: "absolute",
  891. border: "3px dashed #ccc",
  892. borderRadius: "5px",
  893. left: "40px",
  894. top: "40px",
  895. right: "40px",
  896. bottom: "40px",
  897. zIndex: 10001,
  898. display: "flex",
  899. justifyContent: "center",
  900. alignItems: "center",
  901. }}
  902. >
  903. <Typography variant="h5">{t("publish_dialog_drop_file_here")}</Typography>
  904. </Box>
  905. </Box>
  906. );
  907. };
  908. PublishDialog.OPEN_MODE_DEFAULT = "default";
  909. PublishDialog.OPEN_MODE_DRAG = "drag";
  910. export default PublishDialog;