ShareDialog.tsx 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  1. import { trackEvent } from "@excalidraw/excalidraw/analytics";
  2. import { copyTextToSystemClipboard } from "@excalidraw/excalidraw/clipboard";
  3. import { Dialog } from "@excalidraw/excalidraw/components/Dialog";
  4. import { FilledButton } from "@excalidraw/excalidraw/components/FilledButton";
  5. import { TextField } from "@excalidraw/excalidraw/components/TextField";
  6. import {
  7. copyIcon,
  8. LinkIcon,
  9. playerPlayIcon,
  10. playerStopFilledIcon,
  11. share,
  12. shareIOS,
  13. shareWindows,
  14. } from "@excalidraw/excalidraw/components/icons";
  15. import { useUIAppState } from "@excalidraw/excalidraw/context/ui-appState";
  16. import { useCopyStatus } from "@excalidraw/excalidraw/hooks/useCopiedIndicator";
  17. import { useI18n } from "@excalidraw/excalidraw/i18n";
  18. import { KEYS, getFrame } from "@excalidraw/common";
  19. import { useEffect, useRef, useState } from "react";
  20. import { atom, useAtom, useAtomValue } from "../app-jotai";
  21. import { activeRoomLinkAtom } from "../collab/Collab";
  22. import "./ShareDialog.scss";
  23. import type { CollabAPI } from "../collab/Collab";
  24. type OnExportToBackend = () => void;
  25. type ShareDialogType = "share" | "collaborationOnly";
  26. export const shareDialogStateAtom = atom<
  27. { isOpen: false } | { isOpen: true; type: ShareDialogType }
  28. >({ isOpen: false });
  29. const getShareIcon = () => {
  30. const navigator = window.navigator as any;
  31. const isAppleBrowser = /Apple/.test(navigator.vendor);
  32. const isWindowsBrowser = navigator.appVersion.indexOf("Win") !== -1;
  33. if (isAppleBrowser) {
  34. return shareIOS;
  35. } else if (isWindowsBrowser) {
  36. return shareWindows;
  37. }
  38. return share;
  39. };
  40. export type ShareDialogProps = {
  41. collabAPI: CollabAPI | null;
  42. handleClose: () => void;
  43. onExportToBackend: OnExportToBackend;
  44. type: ShareDialogType;
  45. };
  46. const ActiveRoomDialog = ({
  47. collabAPI,
  48. activeRoomLink,
  49. handleClose,
  50. }: {
  51. collabAPI: CollabAPI;
  52. activeRoomLink: string;
  53. handleClose: () => void;
  54. }) => {
  55. const { t } = useI18n();
  56. const [, setJustCopied] = useState(false);
  57. const timerRef = useRef<number>(0);
  58. const ref = useRef<HTMLInputElement>(null);
  59. const isShareSupported = "share" in navigator;
  60. const { onCopy, copyStatus } = useCopyStatus();
  61. const copyRoomLink = async () => {
  62. try {
  63. await copyTextToSystemClipboard(activeRoomLink);
  64. } catch (e) {
  65. collabAPI.setCollabError(t("errors.copyToSystemClipboardFailed"));
  66. }
  67. setJustCopied(true);
  68. if (timerRef.current) {
  69. window.clearTimeout(timerRef.current);
  70. }
  71. timerRef.current = window.setTimeout(() => {
  72. setJustCopied(false);
  73. }, 3000);
  74. ref.current?.select();
  75. };
  76. const shareRoomLink = async () => {
  77. try {
  78. await navigator.share({
  79. title: t("roomDialog.shareTitle"),
  80. text: t("roomDialog.shareTitle"),
  81. url: activeRoomLink,
  82. });
  83. } catch (error: any) {
  84. // Just ignore.
  85. }
  86. };
  87. return (
  88. <>
  89. <h3 className="ShareDialog__active__header">
  90. {t("labels.liveCollaboration").replace(/\./g, "")}
  91. </h3>
  92. <TextField
  93. defaultValue={collabAPI.getUsername()}
  94. placeholder="Your name"
  95. label="Your name"
  96. onChange={collabAPI.setUsername}
  97. onKeyDown={(event) => event.key === KEYS.ENTER && handleClose()}
  98. />
  99. <div className="ShareDialog__active__linkRow">
  100. <TextField
  101. ref={ref}
  102. label="Link"
  103. readonly
  104. fullWidth
  105. value={activeRoomLink}
  106. />
  107. {isShareSupported && (
  108. <FilledButton
  109. size="large"
  110. variant="icon"
  111. label="Share"
  112. icon={getShareIcon()}
  113. className="ShareDialog__active__share"
  114. onClick={shareRoomLink}
  115. />
  116. )}
  117. <FilledButton
  118. size="large"
  119. label={t("buttons.copyLink")}
  120. icon={copyIcon}
  121. status={copyStatus}
  122. onClick={() => {
  123. copyRoomLink();
  124. onCopy();
  125. }}
  126. />
  127. </div>
  128. <div className="ShareDialog__active__description">
  129. <p>
  130. <span
  131. role="img"
  132. aria-hidden="true"
  133. className="ShareDialog__active__description__emoji"
  134. >
  135. 🔒{" "}
  136. </span>
  137. {t("roomDialog.desc_privacy")}
  138. </p>
  139. <p>{t("roomDialog.desc_exitSession")}</p>
  140. </div>
  141. <div className="ShareDialog__active__actions">
  142. <FilledButton
  143. size="large"
  144. variant="outlined"
  145. color="danger"
  146. label={t("roomDialog.button_stopSession")}
  147. icon={playerStopFilledIcon}
  148. onClick={() => {
  149. trackEvent("share", "room closed");
  150. collabAPI.stopCollaboration();
  151. if (!collabAPI.isCollaborating()) {
  152. handleClose();
  153. }
  154. }}
  155. />
  156. </div>
  157. </>
  158. );
  159. };
  160. const ShareDialogPicker = (props: ShareDialogProps) => {
  161. const { t } = useI18n();
  162. const { collabAPI } = props;
  163. const startCollabJSX = collabAPI ? (
  164. <>
  165. <div className="ShareDialog__picker__header">
  166. {t("labels.liveCollaboration").replace(/\./g, "")}
  167. </div>
  168. <div className="ShareDialog__picker__description">
  169. <div style={{ marginBottom: "1em" }}>{t("roomDialog.desc_intro")}</div>
  170. {t("roomDialog.desc_privacy")}
  171. </div>
  172. <div className="ShareDialog__picker__button">
  173. <FilledButton
  174. size="large"
  175. label={t("roomDialog.button_startSession")}
  176. icon={playerPlayIcon}
  177. onClick={() => {
  178. trackEvent("share", "room creation", `ui (${getFrame()})`);
  179. collabAPI.startCollaboration(null);
  180. }}
  181. />
  182. </div>
  183. {props.type === "share" && (
  184. <div className="ShareDialog__separator">
  185. <span>{t("shareDialog.or")}</span>
  186. </div>
  187. )}
  188. </>
  189. ) : null;
  190. return (
  191. <>
  192. {startCollabJSX}
  193. {props.type === "share" && (
  194. <>
  195. <div className="ShareDialog__picker__header">
  196. {t("exportDialog.link_title")}
  197. </div>
  198. <div className="ShareDialog__picker__description">
  199. {t("exportDialog.link_details")}
  200. </div>
  201. <div className="ShareDialog__picker__button">
  202. <FilledButton
  203. size="large"
  204. label={t("exportDialog.link_button")}
  205. icon={LinkIcon}
  206. onClick={async () => {
  207. await props.onExportToBackend();
  208. props.handleClose();
  209. }}
  210. />
  211. </div>
  212. </>
  213. )}
  214. </>
  215. );
  216. };
  217. const ShareDialogInner = (props: ShareDialogProps) => {
  218. const activeRoomLink = useAtomValue(activeRoomLinkAtom);
  219. return (
  220. <Dialog size="small" onCloseRequest={props.handleClose} title={false}>
  221. <div className="ShareDialog">
  222. {props.collabAPI && activeRoomLink ? (
  223. <ActiveRoomDialog
  224. collabAPI={props.collabAPI}
  225. activeRoomLink={activeRoomLink}
  226. handleClose={props.handleClose}
  227. />
  228. ) : (
  229. <ShareDialogPicker {...props} />
  230. )}
  231. </div>
  232. </Dialog>
  233. );
  234. };
  235. export const ShareDialog = (props: {
  236. collabAPI: CollabAPI | null;
  237. onExportToBackend: OnExportToBackend;
  238. }) => {
  239. const [shareDialogState, setShareDialogState] = useAtom(shareDialogStateAtom);
  240. const { openDialog } = useUIAppState();
  241. useEffect(() => {
  242. if (openDialog) {
  243. setShareDialogState({ isOpen: false });
  244. }
  245. }, [openDialog, setShareDialogState]);
  246. if (!shareDialogState.isOpen) {
  247. return null;
  248. }
  249. return (
  250. <ShareDialogInner
  251. handleClose={() => setShareDialogState({ isOpen: false })}
  252. collabAPI={props.collabAPI}
  253. onExportToBackend={props.onExportToBackend}
  254. type={shareDialogState.type}
  255. />
  256. );
  257. };