PublishDialog.jsx 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934
  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. const handleAttachFileChanged = async (ev) => {
  221. await updateAttachFile(ev.target.files[0]);
  222. };
  223. const handleAttachFileDrop = async (ev) => {
  224. ev.preventDefault();
  225. setDropZone(false);
  226. await updateAttachFile(ev.dataTransfer.files[0]);
  227. };
  228. const handleAttachFileDragLeave = () => {
  229. setDropZone(false);
  230. if (props.openMode === PublishDialog.OPEN_MODE_DRAG) {
  231. props.onClose(); // Only close dialog if it was not open before dragging file in
  232. }
  233. };
  234. const handleEmojiClick = (ev) => {
  235. setEmojiPickerAnchorEl(ev.currentTarget);
  236. };
  237. const handleEmojiPick = (emoji) => {
  238. setTags((prevTags) => (prevTags.trim() ? `${prevTags.trim()}, ${emoji}` : emoji));
  239. };
  240. const handleEmojiClose = () => {
  241. setEmojiPickerAnchorEl(null);
  242. };
  243. const priorities = {
  244. 1: { label: t("publish_dialog_priority_min"), file: priority1 },
  245. 2: { label: t("publish_dialog_priority_low"), file: priority2 },
  246. 3: { label: t("publish_dialog_priority_default"), file: priority3 },
  247. 4: { label: t("publish_dialog_priority_high"), file: priority4 },
  248. 5: { label: t("publish_dialog_priority_max"), file: priority5 },
  249. };
  250. return (
  251. <>
  252. {dropZone && <DropArea onDrop={handleAttachFileDrop} onDragLeave={handleAttachFileDragLeave} />}
  253. <Dialog maxWidth="md" open={open} onClose={props.onCancel} fullScreen={fullScreen}>
  254. <DialogTitle>
  255. {baseUrl && topic
  256. ? t("publish_dialog_title_topic", {
  257. topic: topicShortUrl(baseUrl, topic),
  258. })
  259. : t("publish_dialog_title_no_topic")}
  260. </DialogTitle>
  261. <DialogContent>
  262. {dropZone && <DropBox />}
  263. {showTopicUrl && (
  264. <ClosableRow
  265. closable={!!props.baseUrl && !!props.topic}
  266. disabled={disabled}
  267. closeLabel={t("publish_dialog_topic_reset")}
  268. onClose={() => {
  269. setBaseUrl(props.baseUrl);
  270. setTopic(props.topic);
  271. setShowTopicUrl(false);
  272. }}
  273. >
  274. <TextField
  275. margin="dense"
  276. label={t("publish_dialog_base_url_label")}
  277. placeholder={t("publish_dialog_base_url_placeholder")}
  278. value={baseUrl}
  279. onChange={(ev) => updateBaseUrl(ev.target.value)}
  280. disabled={disabled}
  281. type="url"
  282. variant="standard"
  283. sx={{ flexGrow: 1, marginRight: 1 }}
  284. inputProps={{
  285. "aria-label": t("publish_dialog_base_url_label"),
  286. }}
  287. />
  288. <TextField
  289. margin="dense"
  290. label={t("publish_dialog_topic_label")}
  291. placeholder={t("publish_dialog_topic_placeholder")}
  292. value={topic}
  293. onChange={(ev) => setTopic(ev.target.value)}
  294. disabled={disabled}
  295. type="text"
  296. variant="standard"
  297. autoFocus={!messageFocused}
  298. sx={{ flexGrow: 1 }}
  299. inputProps={{
  300. "aria-label": t("publish_dialog_topic_label"),
  301. }}
  302. />
  303. </ClosableRow>
  304. )}
  305. <TextField
  306. margin="dense"
  307. label={t("publish_dialog_title_label")}
  308. placeholder={t("publish_dialog_title_placeholder")}
  309. value={title}
  310. onChange={(ev) => setTitle(ev.target.value)}
  311. disabled={disabled}
  312. type="text"
  313. fullWidth
  314. variant="standard"
  315. inputProps={{
  316. "aria-label": t("publish_dialog_title_label"),
  317. }}
  318. />
  319. <TextField
  320. margin="dense"
  321. label={t("publish_dialog_message_label")}
  322. placeholder={t("publish_dialog_message_placeholder")}
  323. value={message}
  324. onChange={(ev) => setMessage(ev.target.value)}
  325. disabled={disabled}
  326. type="text"
  327. variant="standard"
  328. rows={5}
  329. autoFocus={messageFocused}
  330. fullWidth
  331. multiline
  332. inputProps={{
  333. "aria-label": t("publish_dialog_message_label"),
  334. }}
  335. />
  336. <FormControlLabel
  337. label={t("publish_dialog_checkbox_markdown")}
  338. sx={{ marginRight: 2 }}
  339. control={
  340. <Checkbox
  341. size="small"
  342. checked={markdownEnabled}
  343. onChange={(ev) => setMarkdownEnabled(ev.target.checked)}
  344. inputProps={{
  345. "aria-label": t("publish_dialog_checkbox_markdown"),
  346. }}
  347. />
  348. }
  349. />
  350. <div style={{ display: "flex" }}>
  351. <EmojiPicker anchorEl={emojiPickerAnchorEl} onEmojiPick={handleEmojiPick} onClose={handleEmojiClose} />
  352. <DialogIconButton disabled={disabled} onClick={handleEmojiClick} aria-label={t("publish_dialog_emoji_picker_show")}>
  353. <InsertEmoticonIcon />
  354. </DialogIconButton>
  355. <TextField
  356. margin="dense"
  357. label={t("publish_dialog_tags_label")}
  358. placeholder={t("publish_dialog_tags_placeholder")}
  359. value={tags}
  360. onChange={(ev) => setTags(ev.target.value)}
  361. disabled={disabled}
  362. type="text"
  363. variant="standard"
  364. sx={{ flexGrow: 1, marginRight: 1 }}
  365. inputProps={{
  366. "aria-label": t("publish_dialog_tags_label"),
  367. }}
  368. />
  369. <FormControl variant="standard" margin="dense" sx={{ minWidth: 170, maxWidth: 300, flexGrow: 1 }}>
  370. <InputLabel />
  371. <Select
  372. label={t("publish_dialog_priority_label")}
  373. margin="dense"
  374. value={priority}
  375. onChange={(ev) => setPriority(ev.target.value)}
  376. disabled={disabled}
  377. inputProps={{
  378. "aria-label": t("publish_dialog_priority_label"),
  379. }}
  380. >
  381. {[5, 4, 3, 2, 1].map((p) => (
  382. <MenuItem
  383. key={`priorityMenuItem${p}`}
  384. value={p}
  385. aria-label={t("notifications_priority_x", {
  386. priority: p,
  387. })}
  388. >
  389. <div style={{ display: "flex", alignItems: "center" }}>
  390. <img
  391. src={priorities[p].file}
  392. style={{ marginRight: "8px" }}
  393. alt={t("notifications_priority_x", {
  394. priority: p,
  395. })}
  396. />
  397. <div>{priorities[p].label}</div>
  398. </div>
  399. </MenuItem>
  400. ))}
  401. </Select>
  402. </FormControl>
  403. </div>
  404. {showClickUrl && (
  405. <ClosableRow
  406. disabled={disabled}
  407. closeLabel={t("publish_dialog_click_reset")}
  408. onClose={() => {
  409. setClickUrl("");
  410. setShowClickUrl(false);
  411. }}
  412. >
  413. <TextField
  414. margin="dense"
  415. label={t("publish_dialog_click_label")}
  416. placeholder={t("publish_dialog_click_placeholder")}
  417. value={clickUrl}
  418. onChange={(ev) => setClickUrl(ev.target.value)}
  419. disabled={disabled}
  420. type="url"
  421. fullWidth
  422. variant="standard"
  423. inputProps={{
  424. "aria-label": t("publish_dialog_click_label"),
  425. }}
  426. />
  427. </ClosableRow>
  428. )}
  429. {showEmail && (
  430. <ClosableRow
  431. disabled={disabled}
  432. closeLabel={t("publish_dialog_email_reset")}
  433. onClose={() => {
  434. setEmail("");
  435. setShowEmail(false);
  436. }}
  437. >
  438. <TextField
  439. margin="dense"
  440. label={t("publish_dialog_email_label")}
  441. placeholder={t("publish_dialog_email_placeholder")}
  442. value={email}
  443. onChange={(ev) => setEmail(ev.target.value)}
  444. disabled={disabled}
  445. type="email"
  446. variant="standard"
  447. fullWidth
  448. inputProps={{
  449. "aria-label": t("publish_dialog_email_label"),
  450. }}
  451. />
  452. </ClosableRow>
  453. )}
  454. {showCall && (
  455. <ClosableRow
  456. disabled={disabled}
  457. closeLabel={t("publish_dialog_call_reset")}
  458. onClose={() => {
  459. setCall("");
  460. setShowCall(false);
  461. }}
  462. >
  463. <FormControl fullWidth variant="standard" margin="dense">
  464. <InputLabel />
  465. <Select
  466. label={t("publish_dialog_call_label")}
  467. margin="dense"
  468. value={call}
  469. onChange={(ev) => setCall(ev.target.value)}
  470. disabled={disabled}
  471. inputProps={{
  472. "aria-label": t("publish_dialog_call_label"),
  473. }}
  474. >
  475. {account?.phone_numbers?.map((phoneNumber) => (
  476. <MenuItem key={phoneNumber} value={phoneNumber} aria-label={phoneNumber}>
  477. {t("publish_dialog_call_item", { number: phoneNumber })}
  478. </MenuItem>
  479. ))}
  480. </Select>
  481. </FormControl>
  482. </ClosableRow>
  483. )}
  484. {showAttachUrl && (
  485. <ClosableRow
  486. disabled={disabled}
  487. closeLabel={t("publish_dialog_attach_reset")}
  488. onClose={() => {
  489. setAttachUrl("");
  490. setFilename("");
  491. setFilenameEdited(false);
  492. setShowAttachUrl(false);
  493. }}
  494. >
  495. <TextField
  496. margin="dense"
  497. label={t("publish_dialog_attach_label")}
  498. placeholder={t("publish_dialog_attach_placeholder")}
  499. value={attachUrl}
  500. onChange={(ev) => {
  501. const url = ev.target.value;
  502. setAttachUrl(url);
  503. if (!filenameEdited) {
  504. try {
  505. const u = new URL(url);
  506. const parts = u.pathname.split("/");
  507. if (parts.length > 0) {
  508. setFilename(parts[parts.length - 1]);
  509. }
  510. } catch (e) {
  511. // Do nothing
  512. }
  513. }
  514. }}
  515. disabled={disabled}
  516. type="url"
  517. variant="standard"
  518. sx={{ flexGrow: 5, marginRight: 1 }}
  519. inputProps={{
  520. "aria-label": t("publish_dialog_attach_label"),
  521. }}
  522. />
  523. <TextField
  524. margin="dense"
  525. label={t("publish_dialog_filename_label")}
  526. placeholder={t("publish_dialog_filename_placeholder")}
  527. value={filename}
  528. onChange={(ev) => {
  529. setFilename(ev.target.value);
  530. setFilenameEdited(true);
  531. }}
  532. disabled={disabled}
  533. type="text"
  534. variant="standard"
  535. sx={{ flexGrow: 1 }}
  536. inputProps={{
  537. "aria-label": t("publish_dialog_filename_label"),
  538. }}
  539. />
  540. </ClosableRow>
  541. )}
  542. <input type="file" ref={attachFileInput} onChange={handleAttachFileChanged} style={{ display: "none" }} aria-hidden />
  543. {showAttachFile && (
  544. <AttachmentBox
  545. file={attachFile}
  546. filename={filename}
  547. disabled={disabled}
  548. error={attachFileError}
  549. onChangeFilename={(f) => setFilename(f)}
  550. onClose={() => {
  551. setAttachFile(null);
  552. setAttachFileError("");
  553. setFilename("");
  554. }}
  555. />
  556. )}
  557. {showDelay && (
  558. <ClosableRow
  559. disabled={disabled}
  560. closeLabel={t("publish_dialog_delay_reset")}
  561. onClose={() => {
  562. setDelay("");
  563. setShowDelay(false);
  564. }}
  565. >
  566. <TextField
  567. margin="dense"
  568. label={t("publish_dialog_delay_label")}
  569. placeholder={t("publish_dialog_delay_placeholder", {
  570. unixTimestamp: "1649029748",
  571. relativeTime: "30m",
  572. naturalLanguage: "tomorrow, 9am",
  573. })}
  574. value={delay}
  575. onChange={(ev) => setDelay(ev.target.value)}
  576. disabled={disabled}
  577. type="text"
  578. variant="standard"
  579. fullWidth
  580. inputProps={{
  581. "aria-label": t("publish_dialog_delay_label"),
  582. }}
  583. />
  584. </ClosableRow>
  585. )}
  586. <Typography variant="body1" sx={{ marginTop: 2, marginBottom: 1 }}>
  587. {t("publish_dialog_other_features")}
  588. </Typography>
  589. <div>
  590. {!showClickUrl && (
  591. <Chip
  592. clickable
  593. disabled={disabled}
  594. label={t("publish_dialog_chip_click_label")}
  595. aria-label={t("publish_dialog_chip_click_label")}
  596. onClick={() => setShowClickUrl(true)}
  597. sx={{ marginRight: 1, marginBottom: 1 }}
  598. />
  599. )}
  600. {!showEmail && (
  601. <Chip
  602. clickable
  603. disabled={disabled}
  604. label={t("publish_dialog_chip_email_label")}
  605. aria-label={t("publish_dialog_chip_email_label")}
  606. onClick={() => setShowEmail(true)}
  607. sx={{ marginRight: 1, marginBottom: 1 }}
  608. />
  609. )}
  610. {account?.phone_numbers?.length > 0 && !showCall && (
  611. <Chip
  612. clickable
  613. disabled={disabled}
  614. label={t("publish_dialog_chip_call_label")}
  615. aria-label={t("publish_dialog_chip_call_label")}
  616. onClick={() => {
  617. setShowCall(true);
  618. setCall(account.phone_numbers[0]);
  619. }}
  620. sx={{ marginRight: 1, marginBottom: 1 }}
  621. />
  622. )}
  623. {!showAttachUrl && !showAttachFile && (
  624. <Chip
  625. clickable
  626. disabled={disabled}
  627. label={t("publish_dialog_chip_attach_url_label")}
  628. aria-label={t("publish_dialog_chip_attach_url_label")}
  629. onClick={() => setShowAttachUrl(true)}
  630. sx={{ marginRight: 1, marginBottom: 1 }}
  631. />
  632. )}
  633. {!showAttachFile && !showAttachUrl && (
  634. <Chip
  635. clickable
  636. disabled={disabled}
  637. label={t("publish_dialog_chip_attach_file_label")}
  638. aria-label={t("publish_dialog_chip_attach_file_label")}
  639. onClick={() => handleAttachFileClick()}
  640. sx={{ marginRight: 1, marginBottom: 1 }}
  641. />
  642. )}
  643. {!showDelay && (
  644. <Chip
  645. clickable
  646. disabled={disabled}
  647. label={t("publish_dialog_chip_delay_label")}
  648. aria-label={t("publish_dialog_chip_delay_label")}
  649. onClick={() => setShowDelay(true)}
  650. sx={{ marginRight: 1, marginBottom: 1 }}
  651. />
  652. )}
  653. {!showTopicUrl && (
  654. <Chip
  655. clickable
  656. disabled={disabled}
  657. label={t("publish_dialog_chip_topic_label")}
  658. aria-label={t("publish_dialog_chip_topic_label")}
  659. onClick={() => setShowTopicUrl(true)}
  660. sx={{ marginRight: 1, marginBottom: 1 }}
  661. />
  662. )}
  663. {account && !account?.phone_numbers && (
  664. <Tooltip title={t("publish_dialog_chip_call_no_verified_numbers_tooltip")}>
  665. <span>
  666. <Chip
  667. clickable
  668. disabled
  669. label={t("publish_dialog_chip_call_label")}
  670. aria-label={t("publish_dialog_chip_call_label")}
  671. sx={{ marginRight: 1, marginBottom: 1 }}
  672. />
  673. </span>
  674. </Tooltip>
  675. )}
  676. </div>
  677. <Typography variant="body1" sx={{ marginTop: 1, marginBottom: 1 }}>
  678. <Trans
  679. i18nKey="publish_dialog_details_examples_description"
  680. components={{
  681. docsLink: <Link href="https://ntfy.sh/docs" target="_blank" rel="noopener" />,
  682. }}
  683. />
  684. </Typography>
  685. </DialogContent>
  686. <DialogFooter status={status}>
  687. {activeRequest && <Button onClick={() => activeRequest.abort()}>{t("publish_dialog_button_cancel_sending")}</Button>}
  688. {!activeRequest && (
  689. <>
  690. <FormControlLabel
  691. label={t("publish_dialog_checkbox_publish_another")}
  692. sx={{ marginRight: 2 }}
  693. control={
  694. <Checkbox
  695. size="small"
  696. checked={publishAnother}
  697. onChange={(ev) => setPublishAnother(ev.target.checked)}
  698. inputProps={{
  699. "aria-label": t("publish_dialog_checkbox_publish_another"),
  700. }}
  701. />
  702. }
  703. />
  704. <Button onClick={props.onClose}>{t("publish_dialog_button_cancel")}</Button>
  705. <Button onClick={handleSubmit} disabled={!sendButtonEnabled}>
  706. {t("publish_dialog_button_send")}
  707. </Button>
  708. </>
  709. )}
  710. </DialogFooter>
  711. </Dialog>
  712. </>
  713. );
  714. };
  715. const Row = (props) => (
  716. <div style={{ display: "flex" }} role="row">
  717. {props.children}
  718. </div>
  719. );
  720. const ClosableRow = (props) => {
  721. const closable = props.closable !== undefined ? props.closable : true;
  722. return (
  723. <Row>
  724. {props.children}
  725. {closable && (
  726. <DialogIconButton disabled={props.disabled} onClick={props.onClose} sx={{ marginLeft: "6px" }} aria-label={props.closeLabel}>
  727. <Close />
  728. </DialogIconButton>
  729. )}
  730. </Row>
  731. );
  732. };
  733. const DialogIconButton = (props) => {
  734. const sx = props.sx || {};
  735. return (
  736. <IconButton
  737. color="inherit"
  738. size="large"
  739. edge="start"
  740. sx={{ height: "45px", marginTop: "17px", ...sx }}
  741. onClick={props.onClick}
  742. disabled={props.disabled}
  743. aria-label={props["aria-label"]}
  744. >
  745. {props.children}
  746. </IconButton>
  747. );
  748. };
  749. const AttachmentBox = (props) => {
  750. const { t } = useTranslation();
  751. const { file } = props;
  752. return (
  753. <>
  754. <Typography variant="body1" sx={{ marginTop: 2 }}>
  755. {t("publish_dialog_attached_file_title")}
  756. </Typography>
  757. <Box
  758. sx={{
  759. display: "flex",
  760. alignItems: "center",
  761. padding: 0.5,
  762. borderRadius: "4px",
  763. }}
  764. >
  765. <AttachmentIcon type={file.type} />
  766. <Box sx={{ marginLeft: 1, textAlign: "left" }}>
  767. <ExpandingTextField
  768. minWidth={140}
  769. variant="body2"
  770. placeholder={t("publish_dialog_attached_file_filename_placeholder")}
  771. value={props.filename}
  772. onChange={(ev) => props.onChangeFilename(ev.target.value)}
  773. disabled={props.disabled}
  774. />
  775. <br />
  776. <Typography variant="body2" sx={{ color: "text.primary" }}>
  777. {formatBytes(file.size)}
  778. {props.error && (
  779. <Typography component="span" sx={{ color: "error.main" }} aria-live="polite">
  780. {" "}
  781. ({props.error})
  782. </Typography>
  783. )}
  784. </Typography>
  785. </Box>
  786. <DialogIconButton
  787. disabled={props.disabled}
  788. onClick={props.onClose}
  789. sx={{ marginLeft: "6px" }}
  790. aria-label={t("publish_dialog_attached_file_remove")}
  791. >
  792. <Close />
  793. </DialogIconButton>
  794. </Box>
  795. </>
  796. );
  797. };
  798. const ExpandingTextField = (props) => {
  799. const theme = useTheme();
  800. const invisibleFieldRef = useRef();
  801. const [textWidth, setTextWidth] = useState(props.minWidth);
  802. const determineTextWidth = () => {
  803. const boundingRect = invisibleFieldRef?.current?.getBoundingClientRect();
  804. if (!boundingRect) {
  805. return props.minWidth;
  806. }
  807. return boundingRect.width >= props.minWidth ? Math.round(boundingRect.width) : props.minWidth;
  808. };
  809. useEffect(() => {
  810. setTextWidth(determineTextWidth() + 5);
  811. }, [props.value]);
  812. return (
  813. <>
  814. <Typography ref={invisibleFieldRef} component="span" variant={props.variant} aria-hidden sx={{ position: "absolute", left: "-200%" }}>
  815. {props.value}
  816. </Typography>
  817. <TextField
  818. margin="dense"
  819. placeholder={props.placeholder}
  820. value={props.value}
  821. onChange={props.onChange}
  822. type="text"
  823. variant="standard"
  824. sx={{ width: `${textWidth}px`, borderBottom: "none" }}
  825. InputProps={{
  826. style: { fontSize: theme.typography[props.variant].fontSize },
  827. }}
  828. inputProps={{
  829. style: { paddingBottom: 0, paddingTop: 0 },
  830. "aria-label": props.placeholder,
  831. }}
  832. disabled={props.disabled}
  833. />
  834. </>
  835. );
  836. };
  837. const DropArea = (props) => {
  838. const allowDrag = (ev) => {
  839. // This is where we could disallow certain files to be dragged in.
  840. // For now we allow all files.
  841. // eslint-disable-next-line no-param-reassign
  842. ev.dataTransfer.dropEffect = "copy";
  843. ev.preventDefault();
  844. };
  845. return (
  846. <Box
  847. sx={{
  848. position: "absolute",
  849. left: 0,
  850. top: 0,
  851. right: 0,
  852. bottom: 0,
  853. zIndex: 10002,
  854. }}
  855. onDrop={props.onDrop}
  856. onDragEnter={allowDrag}
  857. onDragOver={allowDrag}
  858. onDragLeave={props.onDragLeave}
  859. />
  860. );
  861. };
  862. const DropBox = () => {
  863. const { t } = useTranslation();
  864. return (
  865. <Box
  866. sx={{
  867. position: "absolute",
  868. left: 0,
  869. top: 0,
  870. right: 0,
  871. bottom: 0,
  872. zIndex: 10000,
  873. backgroundColor: "#ffffffbb",
  874. }}
  875. >
  876. <Box
  877. sx={{
  878. position: "absolute",
  879. border: "3px dashed #ccc",
  880. borderRadius: "5px",
  881. left: "40px",
  882. top: "40px",
  883. right: "40px",
  884. bottom: "40px",
  885. zIndex: 10001,
  886. display: "flex",
  887. justifyContent: "center",
  888. alignItems: "center",
  889. }}
  890. >
  891. <Typography variant="h5">{t("publish_dialog_drop_file_here")}</Typography>
  892. </Box>
  893. </Box>
  894. );
  895. };
  896. PublishDialog.OPEN_MODE_DEFAULT = "default";
  897. PublishDialog.OPEN_MODE_DRAG = "drag";
  898. export default PublishDialog;