diff --git a/apps/interactor/package.json b/apps/interactor/package.json index 3f8431cc..658f1962 100644 --- a/apps/interactor/package.json +++ b/apps/interactor/package.json @@ -23,11 +23,13 @@ "@sentry/react": "^7.111.0", "axios": "^1.2.4", "classnames": "^2.3.2", + "crypto-js": "^4.2.0", "jszip": "^3.10.1", "lodash.debounce": "^4.0.8", "lodash.mergewith": "^4.6.2", "lodash.trim": "^4.5.1", "normalize.css": "^8.0.1", + "qrcode.react": "^4.0.1", "query-string": "^8.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -44,6 +46,7 @@ "uuid": "^9.0.0" }, "devDependencies": { + "@types/crypto-js": "^4.2.2", "@types/jest": "^29.5.8", "@types/lodash.debounce": "^4.0.9", "@types/lodash.mergewith": "^4.6.9", diff --git a/apps/interactor/public/images/logo.png b/apps/interactor/public/images/logo.png new file mode 100644 index 00000000..bd47eb4e Binary files /dev/null and b/apps/interactor/public/images/logo.png differ diff --git a/apps/interactor/root.tsx b/apps/interactor/root.tsx index 16e43ddd..c8efb160 100644 --- a/apps/interactor/root.tsx +++ b/apps/interactor/root.tsx @@ -261,6 +261,7 @@ export const loadNarration = async (): Promise => { ReactDOM.createRoot(document.getElementById('root')!).render( ({ + botId: '', isProduction: false, isInteractionDisabled: false, apiEndpoint: '', diff --git a/apps/interactor/src/App.tsx b/apps/interactor/src/App.tsx index 7579e400..a472cee1 100644 --- a/apps/interactor/src/App.tsx +++ b/apps/interactor/src/App.tsx @@ -15,6 +15,7 @@ import './App.scss'; function App(props: AppProps) { const contextValue = { + botId: props.botId, isProduction: props.isProduction, freeSmart: props.freeSmart, freeTTS: props.freeTTS, diff --git a/apps/interactor/src/components/history/DeviceExport.scss b/apps/interactor/src/components/history/DeviceExport.scss new file mode 100644 index 00000000..ca647dc8 --- /dev/null +++ b/apps/interactor/src/components/history/DeviceExport.scss @@ -0,0 +1,98 @@ +@import '../../variables'; + +.deviceExport { + &__button { + color: $color-white; + padding: 0 4px; + border-radius: 10px; + transition: ease-in-out 0.3s; + + &:hover { + color: $secondary-color; + } + } + + &__loading { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; + } + + &__container { + display: flex; + align-items: center; + flex-direction: column; + position: relative; + width: 100%; + + &__close { + position: absolute; + top: 0; + right: 0; + cursor: pointer; + + &:hover { + color: $color-red; + } + } + + &__header { + margin-bottom: 10px; + + & p { + font-size: 0.8rem; + font-style: italic; + margin-top: 6px; + color: $color-gray; + font-weight: 500; + } + } + + &__hash { + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + border: 1px solid $color-gray; + padding: 4px 6px; + gap: 8px; + border-radius: 8px; + margin-top: 10px; + + & p { + width: 100%; + text-wrap: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + & button { + color: $color-gray; + + &:hover { + color: white; + } + } + } + + &__expiration { + font-size: 0.8rem; + margin-top: 4px; + color: $color-gray; + font-style: italic; + } + } + + &__modal { + max-width: 400px !important; + } +} +.disabled-export { + color: $color-gray; + cursor: not-allowed; + &:hover { + color: $color-gray; + } +} diff --git a/apps/interactor/src/components/history/DeviceExport.tsx b/apps/interactor/src/components/history/DeviceExport.tsx new file mode 100644 index 00000000..c39aa8df --- /dev/null +++ b/apps/interactor/src/components/history/DeviceExport.tsx @@ -0,0 +1,167 @@ +import { Loader, Modal, Tooltip } from '@mikugg/ui-kit'; +import CryptoJS from 'crypto-js'; +import { QRCodeCanvas } from 'qrcode.react'; +import React, { useEffect, useState } from 'react'; +import { IoQrCode } from 'react-icons/io5'; + +import { FaCheck, FaClipboard } from 'react-icons/fa'; +import { v4 as randomUUID } from 'uuid'; +import { useAppContext } from '../../App.context'; +import { setDeviceExportModal } from '../../state/slices/settingsSlice'; +import { useAppDispatch, useAppSelector } from '../../state/store'; + +import { IoIosCloseCircleOutline } from 'react-icons/io'; +import { toast } from 'react-toastify'; +import './DeviceExport.scss'; +import { uploadNarration } from '../../libs/platformAPI'; +import { CustomEventType, postMessage } from '../../libs/stateEvents'; + +function stringToBase64(str: string): string { + return CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(str)); +} + +interface QRProps { + value: string | null; + expirationDate: number; + loading: boolean; + copied: boolean; +} + +const intialQRState: QRProps = { + value: null, + expirationDate: Date.now(), + loading: false, + copied: false, +}; + +export const DeviceExport = (): React.ReactNode => { + const dispatch = useAppDispatch(); + const [QR, setQR] = useState(intialQRState); + const state = useAppSelector((state) => state); + const { isPremium } = useAppSelector((state) => state.settings.user); + const isModalOpen = useAppSelector((state) => state.settings.modals.deviceExport); + const { isProduction, apiEndpoint, botId } = useAppContext(); + const [_, forceUpdate] = useState(0); + + const getEncryptedJson = (): { + encryptionKey: string; + encryptedData: string; + } => { + const clonedState = JSON.parse(JSON.stringify(state)); + clonedState.settings.modals.history = false; + clonedState.botId = botId; + const json = JSON.stringify(clonedState); + const encryptionKey = randomUUID(); + const encryptedData: string = CryptoJS.AES.encrypt(json, encryptionKey).toString(); + return { encryptionKey, encryptedData }; + }; + + const handleExport = async () => { + dispatch(setDeviceExportModal(true)); + setQR((qr) => ({ ...qr, loading: true })); + + try { + const { encryptedData, encryptionKey } = getEncryptedJson(); + const uploadResult = await uploadNarration(apiEndpoint, encryptedData); + const qrValue = uploadResult?.filename ? stringToBase64(`${uploadResult.filename}#${encryptionKey}`) : null; + console.log('uploadResult?.expiration', uploadResult?.expiration); + console.log('Date.now()', Date.now()); + setQR({ + loading: false, + value: qrValue, + copied: false, + expirationDate: uploadResult?.expiration ? new Date(uploadResult.expiration).getTime() : Date.now(), + }); + } catch (error) { + setQR({ ...QR, loading: false }); + dispatch(setDeviceExportModal(false)); + toast.error('Error encrypting narration'); + } + }; + + const handleCopyHash = () => { + postMessage(CustomEventType.COPY_TO_CLIPBOARD, QR.value || ''); + setQR((qr) => ({ ...qr, copied: true })); + toast.success('Key copied to clipboard'); + setTimeout(() => { + setQR((qr) => ({ ...qr, copied: false })); + }, 4000); + }; + + const handleCloseModal = () => { + if (QR.loading) return undefined; + setQR(intialQRState); + dispatch(setDeviceExportModal(false)); + }; + const timeLeft = Math.floor((QR.expirationDate - Date.now()) / 1000 / 60); + + useEffect(() => { + setInterval(() => { + forceUpdate((prev) => prev + 1); + }, 60000); + }, []); + + if (!isProduction) return null; + return ( + <> + + + + {QR.loading ? ( +
+ +

Encrypting narration as QR...

+
+ ) : ( +
+ +
+

Export narration

+

Scan the QR code or copy the key to import this narration to another device.

+
+
+ {/* eslint-disable-next-line */} + {/* @ts-ignore */} + +
+
+

{QR.value}

+ +
+
+ {timeLeft > 0 ? `Expires in ${timeLeft} minutes` : 'Expired'} +
+
+ )} +
+ + ); +}; diff --git a/apps/interactor/src/components/history/History.tsx b/apps/interactor/src/components/history/History.tsx index 61aebeb3..82d16eb0 100644 --- a/apps/interactor/src/components/history/History.tsx +++ b/apps/interactor/src/components/history/History.tsx @@ -27,6 +27,7 @@ import { migrateV1toV2, migrateV2toV3 } from '../../state/versioning/migrations' import { VersionId as V1VersionId } from '../../state/versioning/v1.state'; import { VersionId as V2VersionId } from '../../state/versioning/v2.state'; import { VersionId as V3VersionId } from '../../state/versioning/v3.state'; +import { DeviceExport } from './DeviceExport'; import { RenPyExportButton } from './ExportToRenpy'; import './History.scss'; import { useI18n } from '../../libs/i18n'; @@ -105,6 +106,7 @@ const HistoryActions = () => {
{!isMobileApp && hasInteractions ? : null} + {!hasInteractions ? (