Skip to content

Commit

Permalink
[MIK-323] Add export for other devices button. (#158)
Browse files Browse the repository at this point in the history
* add export to devices modal in slice

* install que generator and add logo image

* create and use deviceExport modal with qr generation

* add encrypt library and make a function for encrypt the json

* add backend post

* add user id into interactor

* add missing props in tests

* Fix qr upload

* Add botId and redirect clipboard to parent window

* change button style and add time left to expire

* hot fix

* fix encryption function

* Fix time left

---------

Co-authored-by: Mikudev <[email protected]>
  • Loading branch information
Lredigonda and miku448 authored Oct 29, 2024
1 parent bfac775 commit 8f109a8
Show file tree
Hide file tree
Showing 16 changed files with 343 additions and 7 deletions.
3 changes: 3 additions & 0 deletions apps/interactor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Binary file added apps/interactor/public/images/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions apps/interactor/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ export const loadNarration = async (): Promise<RootState> => {
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App
botId={params.botId}
isProduction={params.production}
isInteractionDisabled={params.disabled}
servicesEndpoint={params.servicesEndpoint}
Expand Down
2 changes: 2 additions & 0 deletions apps/interactor/src/App.context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { PersonaResult } from './libs/listSearch';
import { AssetDisplayPrefix, AssetType } from '@mikugg/bot-utils';

export interface AppProps {
botId: string;
isProduction: boolean;
isInteractionDisabled: boolean;
apiEndpoint: string;
Expand All @@ -27,6 +28,7 @@ export interface AppProps {
}

const AppContext = createContext<AppProps>({
botId: '',
isProduction: false,
isInteractionDisabled: false,
apiEndpoint: '',
Expand Down
1 change: 1 addition & 0 deletions apps/interactor/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
98 changes: 98 additions & 0 deletions apps/interactor/src/components/history/DeviceExport.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
167 changes: 167 additions & 0 deletions apps/interactor/src/components/history/DeviceExport.tsx
Original file line number Diff line number Diff line change
@@ -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<QRProps>(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 (
<>
<button
data-tooltip-id="device-export-tooltip"
data-tooltip-content={
isPremium
? 'Export this narration to other device.'
: 'Export to other devices is only available for premium members.'
}
className={`deviceExport__button ${!isPremium ? 'disabled-export' : ''}`}
onClick={handleExport}
disabled={!isPremium}
>
<IoQrCode />
</button>
<Tooltip id="device-export-tooltip" place="bottom" />
<Modal className="deviceExport__modal" opened={isModalOpen} onCloseModal={handleCloseModal}>
{QR.loading ? (
<div className="deviceExport__loading">
<Loader />
<p>Encrypting narration as QR...</p>
</div>
) : (
<div className="deviceExport__container">
<IoIosCloseCircleOutline onClick={handleCloseModal} size={20} className="deviceExport__container__close" />
<div className="deviceExport__container__header">
<h2>Export narration</h2>
<p>Scan the QR code or copy the key to import this narration to another device.</p>
</div>
<div className="deviceExport__container__code">
{/* eslint-disable-next-line */}
{/* @ts-ignore */}
<QRCodeCanvas
size={256}
bgColor="transparent"
fgColor="#ffffff"
value={QR.value || ''}
imageSettings={{
src: '../../../public/images/logo.png',
x: undefined,
y: undefined,
height: 50,
width: 50,
opacity: 1,
excavate: true,
}}
/>
</div>
<div className="deviceExport__container__hash">
<p>{QR.value}</p>
<button disabled={QR.copied} onClick={handleCopyHash}>
{QR.copied ? <FaCheck color="#00ff33" /> : <FaClipboard />}
</button>
</div>
<div className="deviceExport__container__expiration">
{timeLeft > 0 ? `Expires in ${timeLeft} minutes` : 'Expired'}
</div>
</div>
)}
</Modal>
</>
);
};
2 changes: 2 additions & 0 deletions apps/interactor/src/components/history/History.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -105,6 +106,7 @@ const HistoryActions = () => {
<div className="History__actions">
<Tooltip id="history-actions-tooltip" place="bottom" />
{!isMobileApp && hasInteractions ? <RenPyExportButton state={state} /> : null}
<DeviceExport />
{!hasInteractions ? (
<label
className="icon-button"
Expand Down
27 changes: 27 additions & 0 deletions apps/interactor/src/libs/platformAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,30 @@ export const getUnlockedItems = async (apiEndpoint: string): Promise<NovelV3.Inv
return [];
}
};

export const uploadNarration = async (
apiEndpoint: string,
ecryptedJSON: string,
): Promise<{
filename: string;
expiration: string;
} | null> => {
try {
const response = await axios.post<{
filename: string;
expiration: string;
}>(
`${apiEndpoint}/bot/save-history`,
{
data: ecryptedJSON,
},
{
withCredentials: true,
},
);
return response.data;
} catch (error) {
console.warn(error);
return null;
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export default {
model: 'RP',
displayingLastSentence: false,
user: {
id: '',
name: 'Anon',
isPremium: false,
nsfw: 0,
Expand All @@ -67,6 +68,7 @@ export default {
edit: { opened: false, id: '' },
modelSelector: false,
memoryCapacity: false,
deviceExport: false,
},
chatBox: {
isDraggable: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export default {
model: 'RP',
displayingLastSentence: false,
user: {
id: '',
name: 'Anon',
isPremium: false,
nsfw: 0,
Expand All @@ -67,6 +68,7 @@ export default {
edit: { opened: false, id: '' },
modelSelector: false,
memoryCapacity: false,
deviceExport: false,
},
chatBox: {
isDraggable: false,
Expand Down
Loading

0 comments on commit 8f109a8

Please sign in to comment.