diff --git a/pages/link/chat/[chatLinkId].tsx b/pages/link/chat/[chatLinkId].tsx index 54945d39cc..341f9a861a 100644 --- a/pages/link/chat/[chatLinkId].tsx +++ b/pages/link/chat/[chatLinkId].tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -import { AppChatLink } from '../../../src/apps/link/AppChatLink'; +import { AppLinkChat } from '../../../src/apps/link/AppLinkChat'; import { useRouterQuery } from '~/common/app.routes'; import { withLayout } from '~/common/layout/withLayout'; @@ -11,5 +11,5 @@ export default function ChatLinkPage() { // external state const { chatLinkId } = useRouterQuery<{ chatLinkId: string | undefined }>(); - return withLayout({ type: 'optima', suspendAutoModelsSetup: true }, ); + return withLayout({ type: 'optima', suspendAutoModelsSetup: true }, ); } \ No newline at end of file diff --git a/src/apps/chat/components/applayout/ChatNavigationItem.tsx b/src/apps/chat/components/applayout/ChatNavigationItem.tsx index 671c5f967c..ce009c3e48 100644 --- a/src/apps/chat/components/applayout/ChatNavigationItem.tsx +++ b/src/apps/chat/components/applayout/ChatNavigationItem.tsx @@ -15,6 +15,10 @@ import { DConversationId, useChatStore } from '~/common/state/store-chats'; import { InlineTextarea } from '~/common/components/InlineTextarea'; +// set to true to display the conversation IDs +// const DEBUG_CONVERSATION_IDS = false; + + const FadeInButton = styled(IconButton)({ opacity: 0.5, transition: 'opacity 0.2s', @@ -136,6 +140,7 @@ function ChatNavigationItem(props: { flex: 1, }} > + {/*{DEBUG_CONVERSATION_IDS && `${conversationId} - `}*/} {title.trim() ? title : 'Chat'}{assistantTyping && '...'} ) : ( diff --git a/src/apps/chat/components/message/ChatMessage.tsx b/src/apps/chat/components/message/ChatMessage.tsx index 45ad5d794a..41880e3b24 100644 --- a/src/apps/chat/components/message/ChatMessage.tsx +++ b/src/apps/chat/components/message/ChatMessage.tsx @@ -48,7 +48,7 @@ import { parseBlocks } from './blocks'; // How long is the user collapsed message const USER_COLLAPSED_LINES: number = 8; -// Enable the automatic menu on text selection +// Enable the menu on text selection const ENABLE_SELECTION_RIGHT_CLICK_MENU: boolean = true; // Enable the hover button to copy the whole message. The Copy button is also available in Blocks, or in the Avatar Menu. diff --git a/src/apps/link/AppChatLink.tsx b/src/apps/link/AppChatLink.tsx deleted file mode 100644 index de9c531fb3..0000000000 --- a/src/apps/link/AppChatLink.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import * as React from 'react'; -import Head from 'next/head'; -import { useQuery } from '@tanstack/react-query'; - -import { Box, Typography } from '@mui/joy'; - -import { createConversationFromJsonV1 } from '~/modules/trade/trade.client'; - -import { Brand } from '~/common/app.config'; -import { InlineError } from '~/common/components/InlineError'; -import { LogoProgress } from '~/common/components/LogoProgress'; -import { apiAsyncNode } from '~/common/util/trpc.client'; -import { capitalizeFirstLetter } from '~/common/util/textUtils'; -import { conversationTitle } from '~/common/state/store-chats'; -import { themeBgAppDarker } from '~/common/app.theme'; -import { usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout'; - -import { AppChatLinkDrawerContent } from './AppChatLinkDrawerContent'; -import { AppChatLinkMenuItems } from './AppChatLinkMenuItems'; -import { ViewChatLink } from './ViewChatLink'; - - -const Centerer = (props: { backgroundColor: string, children?: React.ReactNode }) => - - {props.children} - ; - -const ShowLoading = () => - - - - Loading Chat... - - ; - -const ShowError = (props: { error: any }) => - - - ; - - -/** - * Fetches the object using tRPC - * Note: we don't have react-query for the Node functions, so we use the immediate API here, - * and wrap it in a react-query hook - */ -async function fetchStoredChatV1(objectId: string) { - // fetch - const result = await apiAsyncNode.trade.storageGet.query({ objectId }); - if (result.type === 'error') - throw result.error; - - // validate a CHAT_V1 - const { dataType, dataObject, storedAt, expiresAt } = result; - if (dataType !== 'CHAT_V1') - throw new Error('Unsupported data type: ' + dataType); - - // convert to DConversation - const restored = createConversationFromJsonV1(dataObject as any); - if (!restored) - throw new Error('Could not restore conversation'); - - return { conversation: restored, storedAt, expiresAt }; -} - - -export function AppChatLink(props: { linkId: string }) { - - // external state - const { data, isError, error, isLoading } = useQuery({ - enabled: !!props.linkId, - queryKey: ['chat-link', props.linkId], - queryFn: () => fetchStoredChatV1(props.linkId), - refetchOnWindowFocus: false, - staleTime: 1000 * 60 * 60 * 24, // 24 hours - }); - // const hasLinkItems = useHasChatLinkItems(); - - - // pluggable UI - - const drawerContent = React.useMemo(() => , []); - const menuItems = React.useMemo(() => , []); - usePluggableOptimaLayout(drawerContent, null, menuItems, 'AppChatLink'); - - - const pageTitle = (data?.conversation && conversationTitle(data.conversation)) || 'Chat Link'; - - return <> - - - {capitalizeFirstLetter(pageTitle)} ยท {Brand.Title.Base} ๐Ÿš€ - - - {isLoading - ? - : isError - ? - : !!data?.conversation - ? - : } - - ; -} \ No newline at end of file diff --git a/src/apps/link/AppChatLinkDrawerContent.tsx b/src/apps/link/AppChatLinkDrawerContent.tsx deleted file mode 100644 index a257a4a80b..0000000000 --- a/src/apps/link/AppChatLinkDrawerContent.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import * as React from 'react'; -import TimeAgo from 'react-timeago'; - -import { Box, ListDivider, ListItem, ListItemButton, ListItemDecorator, Typography } from '@mui/joy'; -import ArrowBackIcon from '@mui/icons-material/ArrowBack'; - -import { useChatLinkItems } from '~/modules/trade/store-module-trade'; - -import { Brand } from '~/common/app.config'; -import { Link } from '~/common/components/Link'; -import { PageDrawerHeader } from '~/common/layout/optima/components/PageDrawerHeader'; -import { PageDrawerList } from '~/common/layout/optima/components/PageDrawerList'; -import { getChatLinkRelativePath, ROUTE_INDEX } from '~/common/app.routes'; -import { useOptimaDrawers } from '~/common/layout/optima/useOptimaDrawers'; - - -/** - * Drawer Items are all the links already shared, for quick access. - * This is stores in the Trade Store (local storage). - */ -export function AppChatLinkDrawerContent() { - - // external state - const { closeDrawer, closeDrawerOnMobile } = useOptimaDrawers(); - const chatLinkItems = useChatLinkItems() - .slice() - .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); - const notEmpty = chatLinkItems.length > 0; - - return <> - - - - - - {notEmpty && ( - - - {Brand.Title.Base} - - )} - - {notEmpty && } - - - - {notEmpty ? 'Links shared by you' : 'No prior shared links'} - - - - {notEmpty && - {chatLinkItems.map(item => ( - - - - {item.chatTitle || 'Untitled Chat'} - - - - - - - ))} - } - - - ; - -} \ No newline at end of file diff --git a/src/apps/link/AppLinkChat.tsx b/src/apps/link/AppLinkChat.tsx new file mode 100644 index 0000000000..fe64379c0e --- /dev/null +++ b/src/apps/link/AppLinkChat.tsx @@ -0,0 +1,263 @@ +import * as React from 'react'; +import Head from 'next/head'; +import { useQuery } from '@tanstack/react-query'; + +import { Box, Button, Card, CardContent, Divider, Input, Typography } from '@mui/joy'; +import WarningRoundedIcon from '@mui/icons-material/WarningRounded'; + +import { createConversationFromJsonV1 } from '~/modules/trade/trade.client'; +import { forgetChatLinkItem, useSharedChatLinkItems } from '~/modules/trade/link/store-link'; + +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 { LogoProgress } from '~/common/components/LogoProgress'; +import { apiAsyncNode } from '~/common/util/trpc.client'; +import { capitalizeFirstLetter } from '~/common/util/textUtils'; +import { conversationTitle } from '~/common/state/store-chats'; +import { themeBgAppDarker } from '~/common/app.theme'; +import { usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout'; + +import { LinkChat } from './LinkChat'; +import { LinkChatDrawer } from './LinkChatDrawer'; +import { LinkChatMenuItems } from './LinkChatMenuItems'; +import { addSnackbar } from '~/common/components/useSnackbarsStore'; +import { navigateToChatLinkList } from '~/common/app.routes'; + + +const SPECIAL_LIST_PAGE_ID = 'list'; + + +const Centerer = (props: { backgroundColor: string, children?: React.ReactNode }) => + + {props.children} + ; + +const ListPlaceholder = (props: { hasLinks: boolean }) => + + + + + Shared Conversations + + + {props.hasLinks + ? 'Here you can see formely exported shared conversations. Please select a conversation from the drawer.' + : 'No shared conversations found. Please export a conversation from this browser first.'} + + + + ; + + +const ShowLoading = () => + + + + Loading Chat... + + ; + +const ShowError = (props: { error: any }) => + + + ; + + +/** + * Fetches the object using tRPC + * Note: we don't have react-query for the Node functions, so we use the immediate API here, + * and wrap it in a react-query hook + */ +async function fetchStoredChatV1(objectId: string | null) { + if (!objectId) + throw new Error('No Stored Chat'); + + // fetch + const result = await apiAsyncNode.trade.storageGet.query({ objectId }); + if (result.type === 'error') + throw result.error; + + // validate a CHAT_V1 + const { dataType, dataObject, storedAt, expiresAt } = result; + if (dataType !== 'CHAT_V1') + throw new Error('Unsupported data type: ' + dataType); + + // convert to DConversation + const restored = createConversationFromJsonV1(dataObject as any); + if (!restored) + throw new Error('Could not restore conversation'); + + return { conversation: restored, storedAt, expiresAt }; +} + + +export function AppLinkChat(props: { chatLinkId: string | null }) { + + // state + const [deleteConfirmId, setDeleteConfirmId] = React.useState(null); + const [deleteConfirmKey, setDeleteConfirmKey] = React.useState(null); + + // derived state 1 + const isListPage = props.chatLinkId === SPECIAL_LIST_PAGE_ID; + const linkId = isListPage ? null : props.chatLinkId; + + // external state + const sharedChatLinkItems = useSharedChatLinkItems(); + const { data, isError, error, isLoading } = useQuery({ + enabled: !!linkId, + queryKey: ['chat-link', linkId], + queryFn: () => fetchStoredChatV1(linkId), + refetchOnWindowFocus: false, + staleTime: 1000 * 60 * 60 * 24, // 24 hours + }); + + // derived state 2 + const hasLinks = sharedChatLinkItems.length > 0; + const pageTitle = (data?.conversation && conversationTitle(data.conversation)) || 'Shared Chat'; // also the (nav) App title + + + const handleDelete = React.useCallback(async (objectId: string, deletionKey: string) => { + setDeleteConfirmId(null); + setDeleteConfirmKey(null); + + // delete from storage + let err: string | null = null; + try { + const response = await apiAsyncNode.trade.storageDelete.mutate({ objectId, deletionKey }); + if (response.type === 'error') + err = response.error || 'unknown error'; + } catch (error: any) { + err = error?.message ?? error?.toString() ?? 'unknown error'; + } + + // delete from local store + if (!err) + forgetChatLinkItem(objectId); + + // UI feedback + addSnackbar({ + key: err ? 'chatlink-deletion-issue' : 'chatlink-deletion-success', + type: err ? 'issue' : 'success', + message: err ? 'Could not delete link: ' + err : 'Link deleted successfully', + }); + + // move to the list page + if (!err) + void navigateToChatLinkList(); + }, []); + + + // Delete: ID confirmation + + const handleConfirmDeletion = React.useCallback((linkId: string) => linkId && setDeleteConfirmId(linkId), []); + + const handleCancelDeletion = React.useCallback(() => setDeleteConfirmId(null), []); + + // Delete: Key confirmation + + const handleConfirmDeletionKey = React.useCallback(() => { + if (!deleteConfirmId) return; + + // if we already have the key, we can delete right away + const item = sharedChatLinkItems.find(i => i.objectId === deleteConfirmId); + let deletionKey = (item && item.deletionKey) ? item.deletionKey : null; + if (deletionKey) + return handleDelete(deleteConfirmId, deletionKey); + + // otherwise ask for the key + setDeleteConfirmKey(''); + }, [deleteConfirmId, handleDelete, sharedChatLinkItems]); + + const handleCancelDeletionKey = React.useCallback(() => { + setDeleteConfirmId(null); + setDeleteConfirmKey(null); + }, []); + + const handleDeletionKeyConfirmed = React.useCallback(() => { + deleteConfirmId && deleteConfirmKey && handleDelete(deleteConfirmId, deleteConfirmKey); + }, [deleteConfirmId, deleteConfirmKey, handleDelete]); + + + // pluggable UI + + const drawerContent = React.useMemo(() => , [handleConfirmDeletion, linkId, sharedChatLinkItems]); + + const menuItems = React.useMemo(() => , [handleConfirmDeletion, linkId]); + + usePluggableOptimaLayout(drawerContent, null, menuItems, 'AppChatLink'); + + + return <> + + + {capitalizeFirstLetter(pageTitle)} ยท {Brand.Title.Base} ๐Ÿš€ + + + {isListPage + ? + : isLoading + ? + : isError + ? + : !!data?.conversation + ? + : } + + + {/* Delete confirmation */} + {!!deleteConfirmId && (deleteConfirmKey === null) && ( + + )} + + {/* Deletion Key Input */} + {!!deleteConfirmId && (deleteConfirmKey !== null) && ( + } + onClose={handleCancelDeletionKey} + hideBottomClose + > + + + You need to enter the original deletion key to delete this conversation. + + setDeleteConfirmKey(event.target.value)} + sx={{ flexGrow: 1 }} + /> + + + + + + )} + + ; +} \ No newline at end of file diff --git a/src/apps/link/ViewChatLink.tsx b/src/apps/link/LinkChat.tsx similarity index 82% rename from src/apps/link/ViewChatLink.tsx rename to src/apps/link/LinkChat.tsx index 8545ed6138..9addf7e613 100644 --- a/src/apps/link/ViewChatLink.tsx +++ b/src/apps/link/LinkChat.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import TimeAgo from 'react-timeago'; -import { Box, Button, Card, List, ListItem, Tooltip, Typography } from '@mui/joy'; +import { Box, Button, Card, CardContent, List, ListItem, Tooltip, Typography } from '@mui/joy'; import TelegramIcon from '@mui/icons-material/Telegram'; import { ChatMessage } from '../chat/components/message/ChatMessage'; @@ -9,6 +9,7 @@ import { ScrollToBottom } from '../chat/components/scroll-to-bottom/ScrollToBott import { useChatShowSystemMessages } from '../chat/store-app-chat'; import { Brand } from '~/common/app.config'; +import { capitalizeFirstLetter } from '~/common/util/textUtils'; import { conversationTitle, DConversation, useChatStore } from '~/common/state/store-chats'; import { launchAppChat } from '~/common/app.routes'; import { themeBgAppDarker } from '~/common/app.theme'; @@ -18,7 +19,7 @@ import { useUIPreferencesStore } from '~/common/state/store-ui'; /** * Renders a chat link view with conversation details and messages. */ -export function ViewChatLink(props: { conversation: DConversation, storedAt: Date, expiresAt: Date | null }) { +export function LinkChat(props: { conversation: DConversation, storedAt: Date, expiresAt: Date | null }) { // state const [cloning, setCloning] = React.useState(false); @@ -76,23 +77,26 @@ export function ViewChatLink(props: { conversation: DConversation, storedAt: Dat py: { xs: 4, md: 5, xl: 6 }, }}> - {/* Heading */} - - }> - {conversationTitle(props.conversation, 'Chat')} - - - Uploaded - {!!props.expiresAt && <>, expires }. - - + + }> + {capitalizeFirstLetter(conversationTitle(props.conversation, 'Chat'))} + + + Uploaded + {!!props.expiresAt && <>, expires }. + + + {/* Messages */} - Like the chat? Clone it and keep the talk going! ๐Ÿš€ + Like the chat? Import it and keep the talk going! ๐Ÿš€ @@ -164,7 +168,9 @@ export function ViewChatLink(props: { conversation: DConversation, storedAt: Dat boxShadow: 'md', }} > - Clone on {Brand.Title.Base} + {hasExistingChat + ? `Import as New` + : `Import on ${Brand.Title.Base}`} {hasExistingChat && ( @@ -175,7 +181,7 @@ export function ViewChatLink(props: { conversation: DConversation, storedAt: Dat endDecorator={} onClick={() => handleClone(true)} > - Replace Existing + Import Over )} diff --git a/src/apps/link/LinkChatDrawer.tsx b/src/apps/link/LinkChatDrawer.tsx new file mode 100644 index 0000000000..62adf18fb6 --- /dev/null +++ b/src/apps/link/LinkChatDrawer.tsx @@ -0,0 +1,96 @@ +import * as React from 'react'; +import TimeAgo from 'react-timeago'; + +import { Box, ListDivider, ListItem, ListItemButton, ListItemDecorator, Typography } from '@mui/joy'; +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; + +import type { SharedChatLinkItem } from '~/modules/trade/link/store-link'; + +import { Link } from '~/common/components/Link'; +import { PageDrawerHeader } from '~/common/layout/optima/components/PageDrawerHeader'; +import { PageDrawerList } from '~/common/layout/optima/components/PageDrawerList'; +import { getChatLinkRelativePath } from '~/common/app.routes'; +import { useOptimaDrawers } from '~/common/layout/optima/useOptimaDrawers'; + + +/** + * Drawer Items are all the links already shared, for quick access. + * This is stores in the Trade Store (local storage). + */ +export function LinkChatDrawer(props: { + activeLinkId: string | null, + sharedChatLinkItems: SharedChatLinkItem[] + onDeleteLink: (linkId: string) => void, +}) { + + // external state + const { closeDrawer } = useOptimaDrawers(); + + // derived state + const { activeLinkId, onDeleteLink } = props; + const chatLinkItems = props.sharedChatLinkItems.toSorted((a, b) => b.createdAt.localeCompare(a.createdAt)); + const hasLinks = chatLinkItems.length > 0; + + + const handleDeleteLink = React.useCallback(() => { + activeLinkId && onDeleteLink(activeLinkId); + }, [activeLinkId, onDeleteLink]); + + + return <> + + + + + + + + + {hasLinks ? 'Links shared by you' : 'No prior shared links'} + + + + + + {hasLinks && + {chatLinkItems.map(item => ( + + + + {item.chatTitle || 'Untitled Chat'} + + + + + + + ))} + } + + + + + + + + + + Delete + + + + + ; + +} \ No newline at end of file diff --git a/src/apps/link/AppChatLinkMenuItems.tsx b/src/apps/link/LinkChatMenuItems.tsx similarity index 77% rename from src/apps/link/AppChatLinkMenuItems.tsx rename to src/apps/link/LinkChatMenuItems.tsx index ddfe48edd8..5a4ff59253 100644 --- a/src/apps/link/AppChatLinkMenuItems.tsx +++ b/src/apps/link/LinkChatMenuItems.tsx @@ -1,7 +1,8 @@ import * as React from 'react'; import { shallow } from 'zustand/shallow'; -import { MenuItem, Switch, Typography } from '@mui/joy'; +import { ListItemDecorator, MenuItem, Switch, Typography } from '@mui/joy'; +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; import { useUIPreferencesStore } from '~/common/state/store-ui'; @@ -11,7 +12,10 @@ import { useChatShowSystemMessages } from '../chat/store-app-chat'; /** * Menu Items are the settings for the chat. */ -export function AppChatLinkMenuItems() { +export function LinkChatMenuItems(props: { + activeLinkId: string | null, + onDeleteLink: (linkId: string) => void, +}) { // external state const [showSystemMessages, setShowSystemMessages] = useChatShowSystemMessages(); @@ -25,9 +29,18 @@ export function AppChatLinkMenuItems() { const handleRenderSystemMessageChange = (event: React.ChangeEvent) => setShowSystemMessages(event.target.checked); + const handleRenderMarkdownChange = (event: React.ChangeEvent) => setRenderMarkdown(event.target.checked); + const handleZenModeChange = (event: React.ChangeEvent) => setZenMode(event.target.checked ? 'cleaner' : 'clean'); + const { activeLinkId, onDeleteLink } = props; + + const handleDeleteLink = React.useCallback(() => { + activeLinkId && onDeleteLink(activeLinkId); + }, [activeLinkId, onDeleteLink]); + + const zenOn = zenMode === 'cleaner'; @@ -66,5 +79,12 @@ export function AppChatLinkMenuItems() { /> + + Delete + + + + + ; } \ No newline at end of file diff --git a/src/common/app.nav.ts b/src/common/app.nav.ts index add12385c1..af63b1ae88 100644 --- a/src/common/app.nav.ts +++ b/src/common/app.nav.ts @@ -13,13 +13,14 @@ import EventNoteIcon from '@mui/icons-material/EventNote'; import EventNoteOutlinedIcon from '@mui/icons-material/EventNoteOutlined'; import FormatPaintIcon from '@mui/icons-material/FormatPaint'; import FormatPaintOutlinedIcon from '@mui/icons-material/FormatPaintOutlined'; +import ImageIcon from '@mui/icons-material/Image'; +import ImageOutlinedIcon from '@mui/icons-material/ImageOutlined'; +import IosShareIcon from '@mui/icons-material/IosShare'; +import IosShareOutlinedIcon from '@mui/icons-material/IosShareOutlined'; import TextsmsIcon from '@mui/icons-material/Textsms'; import TextsmsOutlinedIcon from '@mui/icons-material/TextsmsOutlined'; import WorkspacesIcon from '@mui/icons-material/Workspaces'; import WorkspacesOutlinedIcon from '@mui/icons-material/WorkspacesOutlined'; -// Automatic apps -import ImageOutlinedIcon from '@mui/icons-material/ImageOutlined'; -import IosShareIcon from '@mui/icons-material/IosShare'; // Link icons import GitHubIcon from '@mui/icons-material/GitHub'; import { DiscordIcon } from '~/common/components/icons/DiscordIcon'; @@ -29,11 +30,14 @@ import SettingsIcon from '@mui/icons-material/Settings'; import { Brand } from '~/common/app.config'; +import { hasNoChatLinkItems } from '~/modules/trade/link/store-link'; // enable to show all items, for layout development const SHOW_ALL_APPS = false; +const SPECIAL_DIVIDER = '__DIVIDER__'; + // Nav items @@ -47,12 +51,14 @@ interface ItemBase { export interface NavItemApp extends ItemBase { type: 'app', route: string, + landingRoute?: string, // specify a different route than the nextjs page router route, to land to barTitle?: string, // set to override the name as the bar title (unless custom bar content is used) + hideIcon?: boolean + | (() => boolean), // set to true to hide the icon, unless this is the active app hideBar?: boolean, // set to true to hide the page bar hideDrawer?: boolean, // set to true to hide the drawer - hideNav?: boolean, // set to hide the Nav bar (note: must have a way to navigate back) - hideOnMobile?: boolean, // set to true to hide on mobile - automatic?: boolean, // only accessible by the machine + hideNav?: boolean + | (() => boolean), // set to hide the Nav bar (note: must have a way to navigate back) fullWidth?: boolean, // set to true to override the user preference _delete?: boolean, // delete from the UI } @@ -131,6 +137,13 @@ export const navItems: { route: '/workspace', _delete: true, }, + // <-- divider here --> + { + name: SPECIAL_DIVIDER, + type: 'app', + route: SPECIAL_DIVIDER, + icon: () => null, + }, { name: 'Personas', icon: Diversity2OutlinedIcon, @@ -139,33 +152,32 @@ export const navItems: { route: '/personas', hideBar: true, }, - { - name: 'News', - icon: EventNoteOutlinedIcon, - iconActive: EventNoteIcon, - type: 'app', - route: '/news', - hideBar: true, - hideDrawer: true, - }, - - // non-user-selectable ('automatic') Apps { name: 'Media Library', icon: ImageOutlinedIcon, + iconActive: ImageIcon, type: 'app', route: '/media', - automatic: true, - hideNav: true, _delete: true, }, { name: 'Shared Chat', - icon: IosShareIcon, + icon: IosShareOutlinedIcon, + iconActive: IosShareIcon, type: 'app', route: '/link/chat/[chatLinkId]', - automatic: true, - hideNav: true, + landingRoute: '/link/chat/list', + hideIcon: hasNoChatLinkItems, + hideNav: hasNoChatLinkItems, + }, + { + name: 'News', + icon: EventNoteOutlinedIcon, + iconActive: EventNoteIcon, + type: 'app', + route: '/news', + hideBar: true, + hideDrawer: true, }, ], @@ -210,4 +222,16 @@ export const navItems: { }; // apply UI filtering right away - do it here, once, and for all -navItems.apps = navItems.apps.filter(app => !app._delete || SHOW_ALL_APPS); \ No newline at end of file +navItems.apps = navItems.apps.filter(app => !app._delete || SHOW_ALL_APPS); + +export function checkDivider(app?: NavItemApp) { + return app?.name === SPECIAL_DIVIDER; +} + +export function checkVisibileIcon(app: NavItemApp, currentApp?: NavItemApp) { + return app === currentApp ? true : typeof app.hideIcon === 'function' ? !app.hideIcon() : !app.hideIcon; +} + +export function checkVisibleNav(app?: NavItemApp) { + return !app ? false : typeof app.hideNav === 'function' ? !app.hideNav() : !app.hideNav; +} \ No newline at end of file diff --git a/src/common/app.routes.ts b/src/common/app.routes.ts index 8749b03006..9947a116ff 100644 --- a/src/common/app.routes.ts +++ b/src/common/app.routes.ts @@ -14,7 +14,7 @@ import { isBrowser } from './util/pwaUtils'; export const ROUTE_INDEX = '/'; export const ROUTE_APP_CHAT = '/'; export const ROUTE_APP_CALL = '/call'; -export const ROUTE_APP_LINK_CHAT = '/link/chat/:linkId'; +export const ROUTE_APP_LINK_CHAT = '/link/chat/[chatLinkId]'; export const ROUTE_APP_NEWS = '/news'; export const ROUTE_APP_PERSONAS = '/personas'; const ROUTE_CALLBACK_OPENROUTER = '/link/callback_openrouter'; @@ -34,7 +34,8 @@ export const getCallbackUrl = (source: 'openrouter') => { return callbackUrl.toString(); }; -export const getChatLinkRelativePath = (chatLinkId: string) => ROUTE_APP_LINK_CHAT.replace(':linkId', chatLinkId); +export const getChatLinkRelativePath = (chatLinkId: string) => ROUTE_APP_LINK_CHAT + .replace('[chatLinkId]', chatLinkId); export function useRouterQuery(): TQuery { const { query } = useRouter(); @@ -55,6 +56,8 @@ export const navigateToNews = navigateFn(ROUTE_APP_NEWS); export const navigateToPersonas = navigateFn(ROUTE_APP_PERSONAS); +export const navigateToChatLinkList = navigateFn(ROUTE_APP_LINK_CHAT.replace('[chatLinkId]', 'list')); + export const navigateBack = Router.back; export const reloadPage = () => isBrowser && window.location.reload(); @@ -92,7 +95,7 @@ export function launchAppCall(conversationId: string, personaId: string) { query: { conversationId, personaId, - backTo: 'app-chat' + backTo: 'app-chat', } satisfies AppCallIntent, }, // ROUTE_APP_CALL, diff --git a/src/common/layout/optima/DesktopDrawer.tsx b/src/common/layout/optima/DesktopDrawer.tsx index c1c9482e53..0d43b194d8 100644 --- a/src/common/layout/optima/DesktopDrawer.tsx +++ b/src/common/layout/optima/DesktopDrawer.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { Box, Sheet, styled } from '@mui/joy'; -import type { NavItemApp } from '~/common/app.nav'; +import { checkVisibleNav, NavItemApp } from '~/common/app.nav'; import { themeZIndexDesktopDrawer } from '~/common/app.theme'; import { useOptimaDrawers } from './useOptimaDrawers'; @@ -81,7 +81,7 @@ export function DesktopDrawer(props: { currentApp?: NavItemApp }) { }, [closeDrawer, currentAppUsesDrawer]); // [special case] remove in the future - const shallOpenNavForSharedLink = !props.currentApp?.hideDrawer && !!props.currentApp?.hideNav; + const shallOpenNavForSharedLink = !props.currentApp?.hideDrawer && checkVisibleNav(props.currentApp); React.useEffect(() => { if (shallOpenNavForSharedLink) openDrawer(); diff --git a/src/common/layout/optima/DesktopNav.tsx b/src/common/layout/optima/DesktopNav.tsx index 436422dde4..5e9b347584 100644 --- a/src/common/layout/optima/DesktopNav.tsx +++ b/src/common/layout/optima/DesktopNav.tsx @@ -7,7 +7,7 @@ import MenuIcon from '@mui/icons-material/Menu'; import { useModelsStore } from '~/modules/llms/store-llms'; import { AgiSquircleIcon } from '~/common/components/icons/AgiSquircleIcon'; -import { NavItemApp, navItems } from '~/common/app.nav'; +import { checkDivider, checkVisibileIcon, NavItemApp, navItems } from '~/common/app.nav'; import { themeZIndexDesktopNav } from '~/common/app.theme'; import { BringTheLove } from './components/BringTheLove'; @@ -47,27 +47,29 @@ export function DesktopNav(props: { currentApp?: NavItemApp }) { // App items const navAppItems = React.useMemo(() => { - return navItems.apps.filter(app => !app.hideNav).map(item => { - const isActive = item === props.currentApp; - const isDrawerable = isActive && !item.hideDrawer; - const isPaneOpen = isDrawerable && isDrawerOpen; - const isNotForUser = !!item.automatic && !isActive; - return ( - - Router.push(item.route)} - className={`${navItemClasses.typeApp} ${isActive ? navItemClasses.active : ''} ${isPaneOpen ? navItemClasses.paneOpen : ''}`} - > - {/*{(isActive && item.iconActive) ? : }*/} - - - - ); - }) - // (disabled) add this code after the map to add a divider - .toSpliced(-2, 0, ); + return navItems.apps + .filter(_app => checkVisibileIcon(_app, props.currentApp)) + .map((app, appIdx) => { + const isActive = app === props.currentApp; + const isDrawerable = isActive && !app.hideDrawer; + const isPaneOpen = isDrawerable && isDrawerOpen; + + if (checkDivider(app)) + return ; + + return ( + + Router.push(app.landingRoute || app.route)} + className={`${navItemClasses.typeApp} ${isActive ? navItemClasses.active : ''} ${isPaneOpen ? navItemClasses.paneOpen : ''}`} + > + {/*{(isActive && app.iconActive) ? : }*/} + + + + ); + }); }, [props.currentApp, isDrawerOpen, toggleDrawer]); diff --git a/src/common/layout/optima/MobileNavListItem.tsx b/src/common/layout/optima/MobileNavListItem.tsx index 96c03ae0fe..b0bac5b6cd 100644 --- a/src/common/layout/optima/MobileNavListItem.tsx +++ b/src/common/layout/optima/MobileNavListItem.tsx @@ -1,8 +1,8 @@ import * as React from 'react'; import Router from 'next/router'; -import { Button, ButtonGroup, ListItem } from '@mui/joy'; +import { Button, ButtonGroup, ListItem, Tooltip } from '@mui/joy'; -import { NavItemApp, navItems } from '~/common/app.nav'; +import { checkDivider, checkVisibileIcon, NavItemApp, navItems } from '~/common/app.nav'; import { BringTheLove } from './components/BringTheLove'; @@ -27,17 +27,28 @@ export function MobileNavListItem(props: { currentApp?: NavItemApp }) { gap: 1, }} > - {navItems.apps.filter(app => !app.hideOnMobile && !app.hideNav).map(app => - , - )} + {navItems.apps + .filter(app => checkVisibileIcon(app)) + .map((app, appIdx) => { + const isActive = app === props.currentApp; + + if (checkDivider(app)) + return null; + // return ; + + return ( + + + + ); + })} {/* Group 2: Social Links */} diff --git a/src/common/layout/optima/OptimaLayout.tsx b/src/common/layout/optima/OptimaLayout.tsx index d745c69541..10a67d9784 100644 --- a/src/common/layout/optima/OptimaLayout.tsx +++ b/src/common/layout/optima/OptimaLayout.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { useRouter } from 'next/router'; import { PanelGroup } from 'react-resizable-panels'; -import { navItems } from '~/common/app.nav'; +import { checkVisibleNav, navItems } from '~/common/app.nav'; import { useIsMobile } from '~/common/components/useMatchMedia'; import { DesktopDrawer } from './DesktopDrawer'; @@ -50,7 +50,7 @@ export function OptimaLayout(props: { suspendAutoModelsSetup?: boolean, children - {!currentApp?.hideNav && } + {checkVisibleNav(currentApp) && } diff --git a/src/common/layout/optima/PageBar.tsx b/src/common/layout/optima/PageBar.tsx index c47a93422e..6de96e61b6 100644 --- a/src/common/layout/optima/PageBar.tsx +++ b/src/common/layout/optima/PageBar.tsx @@ -9,7 +9,7 @@ import MenuIcon from '@mui/icons-material/Menu'; import MoreVertIcon from '@mui/icons-material/MoreVert'; import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined'; -import type { NavItemApp } from '~/common/app.nav'; +import { checkVisibleNav, NavItemApp } from '~/common/app.nav'; import { AgiSquircleIcon } from '~/common/components/icons/AgiSquircleIcon'; import { Brand } from '~/common/app.config'; import { CloseableMenu } from '~/common/components/CloseableMenu'; @@ -125,10 +125,10 @@ export function PageBar(props: { currentApp?: NavItemApp, isMobile?: boolean, sx {/* [Mobile] Drawer button */} - {(!!props.isMobile || props.currentApp?.hideNav) && ( + {(!!props.isMobile || !checkVisibleNav(props.currentApp)) && ( - {(!appDrawerContent || props.currentApp?.hideNav) ? ( + {(!appDrawerContent || !checkVisibleNav(props.currentApp)) ? ( diff --git a/src/modules/trade/ExportChats.tsx b/src/modules/trade/ExportChats.tsx index 7f5b7bc3ec..3bb055646d 100644 --- a/src/modules/trade/ExportChats.tsx +++ b/src/modules/trade/ExportChats.tsx @@ -1,44 +1,20 @@ import * as React from 'react'; -import { Badge, Box, Button, Typography } from '@mui/joy'; +import { Box, Button, Typography } from '@mui/joy'; import DoneIcon from '@mui/icons-material/Done'; -import ExitToAppIcon from '@mui/icons-material/ExitToApp'; import FileDownloadIcon from '@mui/icons-material/FileDownload'; -import IosShareIcon from '@mui/icons-material/IosShare'; - -import { getChatShowSystemMessages } from '../../apps/chat/store-app-chat'; import { backendCaps } from '~/modules/backend/state-backend'; -import { Brand } from '~/common/app.config'; -import { ConfirmationModal } from '~/common/components/ConfirmationModal'; -import { Link } from '~/common/components/Link'; -import { apiAsyncNode } from '~/common/util/trpc.client'; -import { conversationTitle, DConversationId, getConversation } from '~/common/state/store-chats'; -import { isBrowser } from '~/common/util/pwaUtils'; -import { useUICounter } from '~/common/state/store-ui'; +import { DConversationId, getConversation } from '~/common/state/store-chats'; -import type { PublishedSchema } from './server/pastegg'; -import type { StoragePutSchema } from './server/link'; -import { ExportedChatLink } from './ExportedChatLink'; -import { ExportedPublish } from './ExportedPublish'; -import { addChatLinkItem, useLinkStorageOwnerId } from './store-module-trade'; -import { conversationToJsonV1, conversationToMarkdown, downloadAllConversationsJson, downloadConversationJson } from './trade.client'; +import { ChatLinkExport } from './link/ChatLinkExport'; +import { PublishExport } from './publish/PublishExport'; +import { downloadAllConversationsJson, downloadConversationJson } from './trade.client'; export type ExportConfig = { dir: 'export', conversationId: DConversationId | null }; -/// Returns a pretty link to the current page, for promo -function linkToOrigin() { - let origin = isBrowser ? window.location.href : ''; - if (!origin || origin.includes('//localhost')) - origin = Brand.URIs.OpenRepo; - origin = origin.replace('https://', ''); - if (origin.endsWith('/')) - origin = origin.slice(0, -1); - return origin; -} - /** * Export Buttons and functionality @@ -48,94 +24,12 @@ export function ExportChats(props: { config: ExportConfig, onClose: () => void } // state const [downloadedState, setDownloadedState] = React.useState<'ok' | 'fail' | null>(null); const [downloadedAllState, setDownloadedAllState] = React.useState<'ok' | 'fail' | null>(null); - const [chatLinkConfirmId, setChatLinkConfirmId] = React.useState(null); - const [chatLinkUploading, setChatLinkUploading] = React.useState(false); - const [chatLinkResponse, setChatLinkResponse] = React.useState(null); - const [publishConversationId, setPublishConversationId] = React.useState(null); - const [publishUploading, setPublishUploading] = React.useState(false); - const [publishResponse, setPublishResponse] = React.useState(null); // external state const enableSharing = backendCaps().hasDB; - const { novel: chatLinkBadge, touch: clearChatLinkBadge } = useUICounter('share-chat-link'); - const { linkStorageOwnerId, setLinkStorageOwnerId } = useLinkStorageOwnerId(); - - - // chat link - - const handleChatLinkCreate = () => setChatLinkConfirmId(props.config.conversationId); - - const handleChatLinkConfirmed = async () => { - if (!chatLinkConfirmId) return; - - const conversation = getConversation(chatLinkConfirmId); - setChatLinkConfirmId(null); - if (!conversation) return; - - setChatLinkUploading(true); - try { - const chatV1 = conversationToJsonV1(conversation); - const chatTitle = conversationTitle(conversation) || undefined; - const response: StoragePutSchema = await apiAsyncNode.trade.storagePut.mutate({ - ownerId: linkStorageOwnerId, - dataType: 'CHAT_V1', - dataTitle: chatTitle, - dataObject: chatV1, - }); - setChatLinkResponse(response); - if (response.type === 'success') { - addChatLinkItem(chatTitle, response.objectId, response.createdAt, response.expiresAt, response.deletionKey); - if (!linkStorageOwnerId) - setLinkStorageOwnerId(response.ownerId); - } - clearChatLinkBadge(); - } catch (error: any) { - setChatLinkResponse({ - type: 'error', - error: error?.message ?? error?.toString() ?? 'unknown error', - }); - } - setChatLinkUploading(false); - }; - - - // publish - - const handlePublishConversation = () => setPublishConversationId(props.config.conversationId); - - const handlePublishConfirmed = async () => { - if (!publishConversationId) return; - - const conversation = getConversation(publishConversationId); - setPublishConversationId(null); - if (!conversation) return; - - setPublishUploading(true); - const showSystemMessages = getChatShowSystemMessages(); - const markdownContent = conversationToMarkdown(conversation, !showSystemMessages); - try { - const paste = await apiAsyncNode.trade.publishTo.mutate({ - to: 'paste.gg', - title: '๐Ÿค–๐Ÿ’ฌ Chat Conversation', - fileContent: markdownContent, - fileName: 'my-chat.md', - origin: linkToOrigin(), - }); - setPublishResponse(paste); - } catch (error: any) { - alert(`Failed to publish conversation: ${error?.message ?? error?.toString() ?? 'unknown error'}`); - setPublishResponse(null); - } - setPublishUploading(false); - }; - - const handlePublishResponseClosed = () => { - setPublishResponse(null); - props.onClose(); - }; - // download + // download chats const handleDownloadConversation = () => { if (!props.config.conversationId) return; @@ -158,6 +52,7 @@ export function ExportChats(props: { config: ExportConfig, onClose: () => void } return <> + Share / Download current chat: @@ -171,26 +66,18 @@ export function ExportChats(props: { config: ExportConfig, onClose: () => void } {enableSharing && ( - - - + )} - + + {/* - - {/* [chat link] confirmation */} - {enableSharing && !!chatLinkConfirmId && ( - setChatLinkConfirmId(null)} onPositive={handleChatLinkConfirmed} - title='Upload Confirmation' - confirmationText={<> - Everyone who has the unlisted link will be able to access this chat. - It will be automatically deleted after 30 days. - For more information, please see the privacy - policy of this server.
- Do you wish to continue? - } positiveActionText={'Yes, Create Link'} - /> - )} - - {/* [chat link] response */} - {enableSharing && !!chatLinkResponse && ( - setChatLinkResponse(null)} response={chatLinkResponse} /> - )} - - - {/* [publish] confirmation */} - {publishConversationId && ( - setPublishConversationId(null)} onPositive={handlePublishConfirmed} - confirmationText={<> - Share your conversation anonymously on paste.gg? - It will be unlisted and available to share and read for 30 days. Keep in mind, deletion may not be possible. - Do you wish to continue? - } positiveActionText={'Understood, Upload to Paste.gg'} - /> - )} - - {/* [publish] response */} - {!!publishResponse && ( - - )} - ; } \ No newline at end of file diff --git a/src/modules/trade/ImportChats.tsx b/src/modules/trade/ImportChats.tsx index 3aa71ee0d7..961be73505 100644 --- a/src/modules/trade/ImportChats.tsx +++ b/src/modules/trade/ImportChats.tsx @@ -30,7 +30,7 @@ const chatGptMedia: FormRadioOption<'source' | 'link'>[] = [ * Components and functionality to import conversations * Supports our own JSON files, and ChatGPT Share Links */ -export function ImportConversations(props: { onConversationActivate: (conversationId: DConversationId) => void, onClose: () => void }) { +export function ImportChats(props: { onConversationActivate: (conversationId: DConversationId) => void, onClose: () => void }) { // state const [importMedia, importMediaControl] = useFormRadio('link', chatGptMedia); diff --git a/src/modules/trade/TradeModal.tsx b/src/modules/trade/TradeModal.tsx index 796aa31fc2..9f0046fd10 100644 --- a/src/modules/trade/TradeModal.tsx +++ b/src/modules/trade/TradeModal.tsx @@ -6,7 +6,7 @@ import { DConversationId } from '~/common/state/store-chats'; import { GoodModal } from '~/common/components/GoodModal'; import { ExportChats, ExportConfig } from './ExportChats'; -import { ImportConfig, ImportConversations } from './ImportChats'; +import { ImportChats, ImportConfig } from './ImportChats'; export type TradeConfig = ImportConfig | ExportConfig; @@ -14,7 +14,7 @@ export function TradeModal(props: { config: TradeConfig, onConversationActivate: return ( {props.config.dir === 'import' ? 'Import ' : props.config.dir === 'export' ? 'Export ' : ''} conversations} open onClose={props.onClose}> - {props.config.dir === 'import' && } + {props.config.dir === 'import' && } {props.config.dir === 'export' && } diff --git a/src/modules/trade/ExportedChatLink.tsx b/src/modules/trade/link/ChatLinkDetails.tsx similarity index 67% rename from src/modules/trade/ExportedChatLink.tsx rename to src/modules/trade/link/ChatLinkDetails.tsx index d1d89b160f..73e8217f03 100644 --- a/src/modules/trade/ExportedChatLink.tsx +++ b/src/modules/trade/link/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'; @@ -19,34 +22,60 @@ import { getChatLinkRelativePath } from '~/common/app.routes'; import { getOriginUrl } from '~/common/util/urlUtils'; import { webShare, webSharePresent } from '~/common/util/pwaUtils'; -import type { StorageDeleteSchema, StoragePutSchema } from './server/link'; -import { removeChatLinkItem } from './store-module-trade'; +import type { StorageDeleteSchema, StoragePutSchema } from '../server/link'; +import { forgetChatLinkItem } from './store-link'; -export function ExportedChatLink(props: { onClose: () => void, response: 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); // in case of 'put' error, just display the message - if (props.response.type === 'error') { + if (props.storageItem.type === 'error') { return ( - + ); } // success - const { objectId, deletionKey, expiresAt } = props.response; + const { objectId, deletionKey, expiresAt } = props.storageItem; const relativeUrl = getChatLinkRelativePath(objectId); 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 = () => { @@ -64,7 +93,7 @@ export function ExportedChatLink(props: { onClose: () => void, response: Storage const result: StorageDeleteSchema = await apiAsyncNode.trade.storageDelete.mutate({ objectId, deletionKey }); setDeletionResponse(result); if (result.type === 'success') - removeChatLinkItem(objectId); + forgetChatLinkItem(objectId); setConfirmDeletion(false); }; @@ -138,7 +167,50 @@ export function ExportedChatLink(props: { onClose: () => void, response: Storage 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/link/ChatLinkExport.tsx b/src/modules/trade/link/ChatLinkExport.tsx new file mode 100644 index 0000000000..e2b3a504be --- /dev/null +++ b/src/modules/trade/link/ChatLinkExport.tsx @@ -0,0 +1,159 @@ +import * as React from 'react'; + +import { Badge, Button } from '@mui/joy'; +import DoneIcon from '@mui/icons-material/Done'; +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, StorageUpdateDeletionKeySchema } from '../server/link'; +import { ChatLinkDetails } from './ChatLinkDetails'; +import { conversationToJsonV1 } from '../trade.client'; +import { rememberChatLinkItem, updateChatLinkDeletionKey, useLinkStorageOwnerId } from './store-link'; + + +export function ChatLinkExport(props: { + conversationId: DConversationId | null; + enableSharing: boolean; + onClose: () => void; +}) { + + // state + const [confirmConversationId, setConfirmConversationId] = React.useState(null); + const [isUploading, setIsUploading] = React.useState(false); + const [linkPutResult, setLinkPutResult] = React.useState(null); + + // external state + const { novel: chatLinkBadge, touch: clearChatLinkBadge } = useUICounter('share-chat-link'); + const { linkStorageOwnerId, setLinkStorageOwnerId } = useLinkStorageOwnerId(); + + + const handleConfirm = () => setConfirmConversationId(props.conversationId); + + const handleCancel = () => setConfirmConversationId(null); + + const handleCreate = async () => { + if (!confirmConversationId) return; + + const conversation = getConversation(confirmConversationId); + setConfirmConversationId(null); + if (!conversation) return; + + setIsUploading(true); + try { + const chatV1 = conversationToJsonV1(conversation); + const chatTitle = conversationTitle(conversation) || undefined; + const response: StoragePutSchema = await apiAsyncNode.trade.storagePut.mutate({ + ownerId: linkStorageOwnerId, + dataType: 'CHAT_V1', + dataTitle: chatTitle, + dataObject: chatV1, + }); + setLinkPutResult(response); + if (response.type === 'success') { + if (!linkStorageOwnerId) + setLinkStorageOwnerId(response.ownerId); + rememberChatLinkItem(chatTitle, response.objectId, response.createdAt, response.expiresAt, response.deletionKey); + } + clearChatLinkBadge(); + } catch (error: any) { + setLinkPutResult({ + type: 'error', + error: error?.message ?? error?.toString() ?? 'unknown error', + }); + } + 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); + + + const hasConversation = !!props.conversationId; + + + return <> + + + + + + {/* [chat link] confirmation */} + {!!confirmConversationId && ( + + Everyone who has the unlisted link will be able to access this chat. + It will be automatically deleted after 30 days. + For more information, please see the privacy + policy of this server.
+ Do you wish to continue? + } + positiveActionText={'Yes, Create Link'} + /> + )} + + {/* [chat link] response */} + {!!linkPutResult && ( + + )} + + ; +} \ No newline at end of file diff --git a/src/modules/trade/link/store-link.ts b/src/modules/trade/link/store-link.ts new file mode 100644 index 0000000000..e0881e7fa1 --- /dev/null +++ b/src/modules/trade/link/store-link.ts @@ -0,0 +1,69 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import { shallow } from 'zustand/shallow'; + + +export interface SharedChatLinkItem { + chatTitle?: string; + objectId: string; + createdAt: string; + expiresAt: string | null; + deletionKey: string; +} + +interface LinkStore { + + // exported items + chatLinkItems: SharedChatLinkItem[]; + chatLinkItemAdd: (chatTitle: string | undefined, objectId: string, createdAt: Date, expiresAt: Date | null, deletionKey: string) => void; + chatLinkItemRemove: (objectId: string) => void; + chatLinkItemChangeDeletionKey: (objectId: string, deletionKey: string) => void; + + // ID assigned by the server upon first PUT + linkStorageOwnerId: string | undefined; + setLinkStorageOwnerId: (linkStorageOwnerId: string) => void; + +} + +const useLinkStore = create()( + persist( + (set) => ({ + + chatLinkItems: [], + chatLinkItemAdd: (chatTitle: string | undefined, objectId: string, createdAt: Date, expiresAt: Date | null, deletionKey: string) => set(state => ({ + chatLinkItems: [...state.chatLinkItems, { chatTitle, objectId, createdAt: createdAt.toISOString(), expiresAt: expiresAt?.toISOString() ?? null, deletionKey }], + })), + chatLinkItemRemove: (objectId: string) => set(state => ({ + chatLinkItems: state.chatLinkItems.filter(item => item.objectId !== objectId), + })), + chatLinkItemChangeDeletionKey: (objectId: string, deletionKey: string) => set(state => ({ + chatLinkItems: state.chatLinkItems.map(item => item.objectId === objectId ? { ...item, deletionKey } : item), + })), + + linkStorageOwnerId: undefined, + setLinkStorageOwnerId: (linkStorageOwnerId: string) => set({ linkStorageOwnerId }), + + }), + { + name: 'app-sharing', + }, + ), +); + + +// by AppChatLink +export const useSharedChatLinkItems = () => useLinkStore(state => state.chatLinkItems, shallow); + +// by ChatLinkExport/ChatLinkDetails +export const rememberChatLinkItem = useLinkStore.getState().chatLinkItemAdd; +export const updateChatLinkDeletionKey = useLinkStore.getState().chatLinkItemChangeDeletionKey; +export const forgetChatLinkItem = useLinkStore.getState().chatLinkItemRemove; +export const useLinkStorageOwnerId = () => useLinkStore(state => ({ + linkStorageOwnerId: state.linkStorageOwnerId, + setLinkStorageOwnerId: state.setLinkStorageOwnerId, +}), shallow); + +// by Nav +export function hasNoChatLinkItems() { + return !useLinkStore.getState().chatLinkItems.length; +} diff --git a/src/modules/trade/ExportedPublish.tsx b/src/modules/trade/publish/PublishDetails.tsx similarity index 92% rename from src/modules/trade/ExportedPublish.tsx rename to src/modules/trade/publish/PublishDetails.tsx index 2255b3b4fa..4128037f19 100644 --- a/src/modules/trade/ExportedPublish.tsx +++ b/src/modules/trade/publish/PublishDetails.tsx @@ -4,14 +4,14 @@ import { Alert, Box, Button, Divider, Input, Modal, ModalDialog, Stack, Typograp import { Link } from '~/common/components/Link'; -import type { PublishedSchema } from './server/pastegg'; +import type { PublishedSchema } from '../server/pastegg'; /** * Displays the result of a Paste.gg paste as a modal dialog. * This is to give the user the chance to write down the deletion key, mainly. */ -export function ExportedPublish(props: { onClose: () => void, response: PublishedSchema, open: boolean }) { +export function PublishDetails(props: { onClose: () => void, response: PublishedSchema, open: boolean }) { if (!props.response?.url) return null; diff --git a/src/modules/trade/publish/PublishExport.tsx b/src/modules/trade/publish/PublishExport.tsx new file mode 100644 index 0000000000..521888e1de --- /dev/null +++ b/src/modules/trade/publish/PublishExport.tsx @@ -0,0 +1,109 @@ +import * as React from 'react'; + +import { Button } from '@mui/joy'; +import ExitToAppIcon from '@mui/icons-material/ExitToApp'; + +import { getChatShowSystemMessages } from '../../../apps/chat/store-app-chat'; + +import { Brand } from '~/common/app.config'; +import { ConfirmationModal } from '~/common/components/ConfirmationModal'; +import { DConversationId, getConversation } from '~/common/state/store-chats'; +import { Link } from '~/common/components/Link'; +import { apiAsyncNode } from '~/common/util/trpc.client'; +import { isBrowser } from '~/common/util/pwaUtils'; + +import type { PublishedSchema } from '../server/pastegg'; +import { PublishDetails } from './PublishDetails'; +import { conversationToMarkdown } from '../trade.client'; + + +/// Returns a pretty link to the current page, for promo +function linkToOrigin() { + let origin = isBrowser ? window.location.href : ''; + if (!origin || origin.includes('//localhost')) + origin = Brand.URIs.OpenRepo; + origin = origin.replace('https://', ''); + if (origin.endsWith('/')) + origin = origin.slice(0, -1); + return origin; +} + + +export function PublishExport(props: { + conversationId: DConversationId | null; + onClose: () => void; +}) { + + // local state + const [publishConversationId, setPublishConversationId] = React.useState(null); + const [publishUploading, setPublishUploading] = React.useState(false); + const [publishResponse, setPublishResponse] = React.useState(null); + + + const handlePublishConversation = () => setPublishConversationId(props.conversationId); + + const handlePublishConfirmed = async () => { + if (!publishConversationId) return; + + const conversation = getConversation(publishConversationId); + setPublishConversationId(null); + if (!conversation) return; + + setPublishUploading(true); + const showSystemMessages = getChatShowSystemMessages(); + const markdownContent = conversationToMarkdown(conversation, !showSystemMessages); + try { + const paste = await apiAsyncNode.trade.publishTo.mutate({ + to: 'paste.gg', + title: '๐Ÿค–๐Ÿ’ฌ Chat Conversation', + fileContent: markdownContent, + fileName: 'my-chat.md', + origin: linkToOrigin(), + }); + setPublishResponse(paste); + } catch (error: any) { + alert(`Failed to publish conversation: ${error?.message ?? error?.toString() ?? 'unknown error'}`); + setPublishResponse(null); + } + setPublishUploading(false); + }; + + const handlePublishResponseClosed = () => { + setPublishResponse(null); + props.onClose(); + }; + + + const hasConversation = !!props.conversationId; + + + return <> + + + + {/* [publish] confirmation */} + {publishConversationId && ( + setPublishConversationId(null)} onPositive={handlePublishConfirmed} + confirmationText={<> + Share your conversation anonymously on paste.gg? + It will be unlisted and available to share and read for 30 days. Keep in mind, deletion may not be possible. + Do you wish to continue? + } positiveActionText={'Understood, Upload to Paste.gg'} + /> + )} + + {/* [publish] response */} + {!!publishResponse && ( + + )} + + ; +} \ No newline at end of file diff --git a/src/modules/trade/server/link.ts b/src/modules/trade/server/link.ts index 2ce4558ddf..2a79e3e540 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 @@ -207,7 +220,7 @@ export const storageMarkAsDeletedProcedure = id: objectId, ownerId: ownerId || undefined, deletionKey, - isDeleted: false, + // isDeleted: false, }, data: { isDeleted: true, @@ -219,6 +232,36 @@ export const storageMarkAsDeletedProcedure = return { type: success ? 'success' : 'error', - error: success ? undefined : 'Not found', + error: success ? undefined : 'invalid deletion key?', }; }); + + +/** + * 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 : 'invalid former key', + }; + }); \ 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 99c8ea1933..54f0b3fa4e 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 diff --git a/src/modules/trade/store-module-trade.ts b/src/modules/trade/store-module-trade.ts deleted file mode 100644 index bc3a3821a6..0000000000 --- a/src/modules/trade/store-module-trade.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { create } from 'zustand'; -import { persist } from 'zustand/middleware'; -import { shallow } from 'zustand/shallow'; - - -interface ChatLinkItem { - chatTitle?: string; - objectId: string; - createdAt: string; - expiresAt: string | null; - deletionKey: string; -} - -interface ModuleTradeStore { - - // ID assigned by the server upon first PUT - linkStorageOwnerId: string | undefined; - setLinkStorageOwnerId: (linkStorageOwnerId: string) => void; - - // exported items - chatLinkItems: ChatLinkItem[]; - addChatLinkItem: (chatTitle: string | undefined, objectId: string, createdAt: Date, expiresAt: Date | null, deletionKey: string) => void; - removeChatLinkItem: (objectId: string) => void; - -} - -const useTradeStore = create()( - persist( - (set) => ({ - - linkStorageOwnerId: undefined, - setLinkStorageOwnerId: (linkStorageOwnerId: string) => set({ linkStorageOwnerId }), - - chatLinkItems: [], - addChatLinkItem: (chatTitle: string | undefined, objectId: string, createdAt: Date, expiresAt: Date | null, deletionKey: string) => set(state => ({ - chatLinkItems: [...state.chatLinkItems, { chatTitle, objectId, createdAt: createdAt.toISOString(), expiresAt: expiresAt?.toISOString() ?? null, deletionKey }], - })), - removeChatLinkItem: (objectId: string) => set(state => ({ - chatLinkItems: state.chatLinkItems.filter(item => item.objectId !== objectId), - })), - - }), - { - name: 'app-sharing', - }, - ), -); - -// by Export -export const useLinkStorageOwnerId = () => - useTradeStore(state => ({ - linkStorageOwnerId: state.linkStorageOwnerId, - setLinkStorageOwnerId: state.setLinkStorageOwnerId, - }), shallow); -export const addChatLinkItem = useTradeStore.getState().addChatLinkItem; -export const removeChatLinkItem = useTradeStore.getState().removeChatLinkItem; - -// by AppChatLink -export const useChatLinkItems = () => useTradeStore(state => state.chatLinkItems, shallow); -export const useHasChatLinkItems = () => useTradeStore(state => state.chatLinkItems.length > 0);