diff --git a/src/modules/trade/chatlink/ChatLinkDetails.tsx b/src/modules/trade/chatlink/ChatLinkDetails.tsx index 5bdf6f2de..8b58e3237 100644 --- a/src/modules/trade/chatlink/ChatLinkDetails.tsx +++ b/src/modules/trade/chatlink/ChatLinkDetails.tsx @@ -1,9 +1,11 @@ import * as React from 'react'; import TimeAgo from 'react-timeago'; -import { Button, Card, Input, Stack, Tooltip, Typography } from '@mui/joy'; +import { Box, Button, Card, IconButton, Input, Stack, Tooltip, Typography } from '@mui/joy'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import DeleteForeverIcon from '@mui/icons-material/DeleteForever'; import DoneIcon from '@mui/icons-material/Done'; +import EditIcon from '@mui/icons-material/Edit'; import IosShareIcon from '@mui/icons-material/IosShare'; import LaunchIcon from '@mui/icons-material/Launch'; import LinkIcon from '@mui/icons-material/Link'; @@ -12,6 +14,7 @@ import { Brand } from '~/common/app.config'; import { ConfirmationModal } from '~/common/components/ConfirmationModal'; import { GoodModal } from '~/common/components/GoodModal'; import { InlineError } from '~/common/components/InlineError'; +import { InlineTextarea } from '~/common/components/InlineTextarea'; import { Link } from '~/common/components/Link'; import { apiAsyncNode } from '~/common/util/trpc.client'; import { copyToClipboard } from '~/common/util/clipboardUtils'; @@ -23,12 +26,18 @@ import type { StorageDeleteSchema, StoragePutSchema } from '../server/link'; import { forgetChatLinkItem } from './store-chatlink'; -export function ChatLinkDetails(props: { onClose: () => void, storageItem: StoragePutSchema, open: boolean }) { +export function ChatLinkDetails(props: { + open: boolean, + onClose: () => void, + storageItem: StoragePutSchema, + onChangeDeletionKey: (deletionKey: string) => void, +}) { // state const [opened, setOpened] = React.useState(false); const [copied, setCopied] = React.useState(false); const [native, setNative] = React.useState(false); + const [isEditingDeletionKey, setIsEditingDeletionKey] = React.useState(false); const [confirmDeletion, setConfirmDeletion] = React.useState(false); const [deletionResponse, setDeletionResponse] = React.useState(null); @@ -47,6 +56,26 @@ export function ChatLinkDetails(props: { onClose: () => void, storageItem: Stora const fullUrl = getOriginUrl() + relativeUrl; + // Deletion Key Edit + + const handleKeyEditBegin = () => setIsEditingDeletionKey(true); + + const handleKeyEditCancel = () => setIsEditingDeletionKey(false); + + const handleKeyEditChange = (text: string) => { + if (text) { + setIsEditingDeletionKey(false); + props.onChangeDeletionKey(text.trim()); + } + }; + + // Deletion Key Copy + + const handleKeyCopy = () => { + copyToClipboard(deletionKey, 'Link Deletion Key'); + }; + + const onOpen = () => setOpened(true); const onCopy = () => { @@ -138,7 +167,50 @@ export function ChatLinkDetails(props: { onClose: () => void, storageItem: Stora Deletion Key - + + {isEditingDeletionKey ? ( + + ) : ( + + + + + + + + + + + } + sx={{ flexGrow: 1 }} + /> + )} + + IMPORTANT - keep this key safe, you will need it if you decide to delete the link at a later time, diff --git a/src/modules/trade/chatlink/ChatLinkExport.tsx b/src/modules/trade/chatlink/ChatLinkExport.tsx index cc59c113b..8c510cab9 100644 --- a/src/modules/trade/chatlink/ChatLinkExport.tsx +++ b/src/modules/trade/chatlink/ChatLinkExport.tsx @@ -7,14 +7,15 @@ import IosShareIcon from '@mui/icons-material/IosShare'; import { Brand } from '~/common/app.config'; import { ConfirmationModal } from '~/common/components/ConfirmationModal'; import { Link } from '~/common/components/Link'; +import { addSnackbar } from '~/common/components/useSnackbarsStore'; import { apiAsyncNode } from '~/common/util/trpc.client'; import { conversationTitle, DConversationId, getConversation } from '~/common/state/store-chats'; import { useUICounter } from '~/common/state/store-ui'; -import type { StoragePutSchema } from '../server/link'; +import type { StoragePutSchema, StorageUpdateDeletionKeySchema } from '../server/link'; import { ChatLinkDetails } from './ChatLinkDetails'; import { conversationToJsonV1 } from '../trade.client'; -import { rememberChatLinkItem, useLinkStorageOwnerId } from './store-chatlink'; +import { rememberChatLinkItem, updateChatLinkDeletionKey, useLinkStorageOwnerId } from './store-chatlink'; export function ChatLinkExport(props: { @@ -70,6 +71,45 @@ export function ChatLinkExport(props: { setIsUploading(false); }; + const handleChangeDeletionKey = async (newKey: string) => { + if (!linkPutResult || linkPutResult.type !== 'success') return; + const { objectId, deletionKey: formerKey } = linkPutResult; + if (!objectId || !formerKey || !newKey || formerKey === newKey) return; + + // update it in the Storage + try { + const response: StorageUpdateDeletionKeySchema = await apiAsyncNode.trade.storageUpdateDeletionKey.mutate({ + objectId, + formerKey, + newKey, + }); + if (response.type === 'error') { + addSnackbar({ + key: 'chatlink-deletion-key-update-error', + type: 'issue', + message: `Failed to update deletion key: ${response.error}`, + }); + return; + } + } catch (error: any) { + addSnackbar({ + key: 'chatlink-deletion-key-update-exception', + type: 'issue', + message: `Failed to update deletion key: ${error?.message ?? error?.toString() ?? 'unknown error'}`, + }); + return; + } + + // update it in the local storage + updateChatLinkDeletionKey(objectId, newKey); + + // update it in the status + setLinkPutResult({ + ...linkPutResult, + deletionKey: newKey, + }); + }; + const handleCloseDetails = () => setLinkPutResult(null); @@ -107,7 +147,12 @@ export function ChatLinkExport(props: { {/* [chat link] response */} {!!linkPutResult && ( - + )} ; diff --git a/src/modules/trade/chatlink/store-chatlink.ts b/src/modules/trade/chatlink/store-chatlink.ts index 2bfc89346..d8d0324fe 100644 --- a/src/modules/trade/chatlink/store-chatlink.ts +++ b/src/modules/trade/chatlink/store-chatlink.ts @@ -17,6 +17,7 @@ interface ModuleTradeStore { chatLinkItems: ChatLinkItem[]; rememberChatLinkItem: (chatTitle: string | undefined, objectId: string, createdAt: Date, expiresAt: Date | null, deletionKey: string) => void; forgetChatLinkItem: (objectId: string) => void; + updateChatLinkDeletionKey: (objectId: string, deletionKey: string) => void; // ID assigned by the server upon first PUT linkStorageOwnerId: string | undefined; @@ -35,6 +36,9 @@ const useTradeStore = create()( forgetChatLinkItem: (objectId: string) => set(state => ({ chatLinkItems: state.chatLinkItems.filter(item => item.objectId !== objectId), })), + updateChatLinkDeletionKey: (objectId: string, deletionKey: string) => set(state => ({ + chatLinkItems: state.chatLinkItems.map(item => item.objectId === objectId ? { ...item, deletionKey } : item), + })), linkStorageOwnerId: undefined, setLinkStorageOwnerId: (linkStorageOwnerId: string) => set({ linkStorageOwnerId }), @@ -59,3 +63,4 @@ export const useLinkStorageOwnerId = () => }), shallow); export const rememberChatLinkItem = useTradeStore.getState().rememberChatLinkItem; export const forgetChatLinkItem = useTradeStore.getState().forgetChatLinkItem; +export const updateChatLinkDeletionKey = useTradeStore.getState().updateChatLinkDeletionKey; diff --git a/src/modules/trade/server/link.ts b/src/modules/trade/server/link.ts index 2ce4558dd..0a9652b7b 100644 --- a/src/modules/trade/server/link.ts +++ b/src/modules/trade/server/link.ts @@ -71,9 +71,22 @@ export const storageDeleteOutputSchema = z.object({ error: z.string().optional(), }); +const storageUpdateDeletionKeyInputSchema = z.object({ + objectId: z.string(), + ownerId: z.string().optional(), + formerKey: z.string(), + newKey: z.string(), +}); + +export const storageUpdateDeletionKeyOutputSchema = z.object({ + type: z.enum(['success', 'error']), + error: z.string().optional(), +}); + export type StoragePutSchema = z.infer; export type StorageDeleteSchema = z.infer; +export type StorageUpdateDeletionKeySchema = z.infer; /// tRPC procedures @@ -222,3 +235,33 @@ export const storageMarkAsDeletedProcedure = error: success ? undefined : 'Not found', }; }); + + +/** + * Update the deletion Key of a public object by ID and deletion key + */ +export const storageUpdateDeletionKeyProcedure = + publicProcedure + .input(storageUpdateDeletionKeyInputSchema) + .output(storageUpdateDeletionKeyOutputSchema) + .mutation(async ({ input: { objectId, ownerId, formerKey, newKey } }) => { + + const result = await db.linkStorage.updateMany({ + where: { + id: objectId, + ownerId: ownerId || undefined, + deletionKey: formerKey, + isDeleted: false, + }, + data: { + deletionKey: newKey, + }, + }); + + const success = result.count === 1; + + return { + type: success ? 'success' : 'error', + error: success ? undefined : 'Not found', + }; + }); \ No newline at end of file diff --git a/src/modules/trade/server/trade.router.ts b/src/modules/trade/server/trade.router.ts index 99c8ea193..54f0b3fa4 100644 --- a/src/modules/trade/server/trade.router.ts +++ b/src/modules/trade/server/trade.router.ts @@ -6,7 +6,7 @@ import { fetchTextOrTRPCError } from '~/server/api/trpc.serverutils'; import { chatGptParseConversation, chatGptSharedChatSchema } from './chatgpt'; import { postToPasteGGOrThrow, publishToInputSchema, publishToOutputSchema } from './pastegg'; -import { storageGetProcedure, storageMarkAsDeletedProcedure, storagePutProcedure } from './link'; +import { storageGetProcedure, storageMarkAsDeletedProcedure, storagePutProcedure, storageUpdateDeletionKeyProcedure } from './link'; export const importChatGptShareInputSchema = z.union([ @@ -65,6 +65,11 @@ export const tradeRouter = createTRPCRouter({ */ storageDelete: storageMarkAsDeletedProcedure, + /** + * Update the deletion Key of a stored object by ID and deletion key + */ + storageUpdateDeletionKey: storageUpdateDeletionKeyProcedure, + /** * Publish a text file (with title, content, name) to a sharing service * For now only 'paste.gg' is supported