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() {
/>
+
+
>;
}
\ 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 && (
-
- : }
- sx={{ minWidth: 240, justifyContent: 'space-between' }}
- onClick={handleChatLinkCreate}>
- Share on {Brand.Title.Base}
-
-
+
)}
- }
- sx={{ minWidth: 240, justifyContent: 'space-between' }}
- onClick={handlePublishConversation}>
- Publish to Paste.gg
-
+
+
{/*
-
- {/* [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 <>
+
+
+ : }
+ sx={{ minWidth: 240, justifyContent: 'space-between' }}
+ onClick={handleConfirm}>
+ Share on {Brand.Title.Base}
+
+
+
+ {/* [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 <>
+
+ }
+ sx={{ minWidth: 240, justifyContent: 'space-between' }}
+ onClick={handlePublishConversation}>
+ Publish to Paste.gg
+
+
+ {/* [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);