🔗 feat: Enhance Share Functionality, Optimize DataTable & Fix Critical Bugs (#5220)

* 🔄 refactor: frontend and backend share link logic; feat: qrcode for share link; feat: refresh link

* 🐛 fix: Conditionally render shared link and refactor share link creation logic

* 🐛 fix: Correct conditional check for shareId in ShareButton component

* 🔄 refactor: Update shared links API and data handling; improve query parameters and response structure

* 🔄 refactor: Update shared links pagination and response structure; replace pageNumber with cursor for improved data fetching

* 🔄 refactor: DataTable performance optimization

* fix: delete shared link cache update

* 🔄 refactor: Enhance shared links functionality; add conversationId to shared link model and update related components

* 🔄 refactor: Add delete functionality to SharedLinkButton; integrate delete mutation and confirmation dialog

* 🔄 feat: Add AnimatedSearchInput component with gradient animations and search functionality; update search handling in API and localization

* 🔄 refactor: Improve SharedLinks component; enhance delete functionality and loading states, optimize AnimatedSearchInput, and refine DataTable scrolling behavior

* fix: mutation type issues with deleted shared link mutation

* fix: MutationOptions types

* fix: Ensure only public shared links are retrieved in getSharedLink function

* fix: `qrcode.react` install location

* fix: ensure non-public shared links are not fetched when checking for existing shared links, and remove deprecated .exec() method for queries

* fix: types and import order

* refactor: cleanup share button UI logic, make more intuitive

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Marco Beretta 2025-01-21 15:31:05 +01:00 committed by GitHub
parent 460cde0c0b
commit fa9e778399
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 1779 additions and 1975 deletions

View file

@ -1,82 +1,71 @@
const { nanoid } = require('nanoid');
const { Constants } = require('librechat-data-provider');
const { Conversation } = require('~/models/Conversation');
const SharedLink = require('./schema/shareSchema');
const { getMessages } = require('./Message');
const logger = require('~/config/winston');
/**
* Anonymizes a conversation ID
* @returns {string} The anonymized conversation ID
*/
function anonymizeConvoId() {
return `convo_${nanoid()}`;
class ShareServiceError extends Error {
constructor(message, code) {
super(message);
this.name = 'ShareServiceError';
this.code = code;
}
}
/**
* Anonymizes an assistant ID
* @returns {string} The anonymized assistant ID
*/
function anonymizeAssistantId() {
return `a_${nanoid()}`;
const memoizedAnonymizeId = (prefix) => {
const memo = new Map();
return (id) => {
if (!memo.has(id)) {
memo.set(id, `${prefix}_${nanoid()}`);
}
return memo.get(id);
};
};
/**
* Anonymizes a message ID
* @param {string} id - The original message ID
* @returns {string} The anonymized message ID
*/
function anonymizeMessageId(id) {
return id === Constants.NO_PARENT ? id : `msg_${nanoid()}`;
}
const anonymizeConvoId = memoizedAnonymizeId('convo');
const anonymizeAssistantId = memoizedAnonymizeId('a');
const anonymizeMessageId = (id) =>
id === Constants.NO_PARENT ? id : memoizedAnonymizeId('msg')(id);
/**
* Anonymizes a conversation object
* @param {object} conversation - The conversation object
* @returns {object} The anonymized conversation object
*/
function anonymizeConvo(conversation) {
if (!conversation) {
return null;
}
const newConvo = { ...conversation };
if (newConvo.assistant_id) {
newConvo.assistant_id = anonymizeAssistantId();
newConvo.assistant_id = anonymizeAssistantId(newConvo.assistant_id);
}
return newConvo;
}
/**
* Anonymizes messages in a conversation
* @param {TMessage[]} messages - The original messages
* @param {string} newConvoId - The new conversation ID
* @returns {TMessage[]} The anonymized messages
*/
function anonymizeMessages(messages, newConvoId) {
if (!Array.isArray(messages)) {
return [];
}
const idMap = new Map();
return messages.map((message) => {
const newMessageId = anonymizeMessageId(message.messageId);
idMap.set(message.messageId, newMessageId);
const anonymizedMessage = Object.assign(message, {
return {
...message,
messageId: newMessageId,
parentMessageId:
idMap.get(message.parentMessageId) || anonymizeMessageId(message.parentMessageId),
conversationId: newConvoId,
});
if (anonymizedMessage.model && anonymizedMessage.model.startsWith('asst_')) {
anonymizedMessage.model = anonymizeAssistantId();
}
return anonymizedMessage;
model: message.model?.startsWith('asst_')
? anonymizeAssistantId(message.model)
: message.model,
};
});
}
/**
* Retrieves shared messages for a given share ID
* @param {string} shareId - The share ID
* @returns {Promise<object|null>} The shared conversation data or null if not found
*/
async function getSharedMessages(shareId) {
try {
const share = await SharedLink.findOne({ shareId })
const share = await SharedLink.findOne({ shareId, isPublic: true })
.populate({
path: 'messages',
select: '-_id -__v -user',
@ -84,165 +73,264 @@ async function getSharedMessages(shareId) {
.select('-_id -__v -user')
.lean();
if (!share || !share.conversationId || !share.isPublic) {
if (!share?.conversationId || !share.isPublic) {
return null;
}
const newConvoId = anonymizeConvoId();
return Object.assign(share, {
const newConvoId = anonymizeConvoId(share.conversationId);
const result = {
...share,
conversationId: newConvoId,
messages: anonymizeMessages(share.messages, newConvoId),
});
};
return result;
} catch (error) {
logger.error('[getShare] Error getting share link', error);
throw new Error('Error getting share link');
logger.error('[getShare] Error getting share link', {
error: error.message,
shareId,
});
throw new ShareServiceError('Error getting share link', 'SHARE_FETCH_ERROR');
}
}
/**
* Retrieves shared links for a user
* @param {string} user - The user ID
* @param {number} [pageNumber=1] - The page number
* @param {number} [pageSize=25] - The page size
* @param {boolean} [isPublic=true] - Whether to retrieve public links only
* @returns {Promise<object>} The shared links and pagination data
*/
async function getSharedLinks(user, pageNumber = 1, pageSize = 25, isPublic = true) {
const query = { user, isPublic };
async function getSharedLinks(user, pageParam, pageSize, isPublic, sortBy, sortDirection, search) {
try {
const [totalConvos, sharedLinks] = await Promise.all([
SharedLink.countDocuments(query),
SharedLink.find(query)
.sort({ updatedAt: -1 })
.skip((pageNumber - 1) * pageSize)
.limit(pageSize)
.select('-_id -__v -user')
.lean(),
]);
const query = { user, isPublic };
const totalPages = Math.ceil((totalConvos || 1) / pageSize);
if (pageParam) {
if (sortDirection === 'desc') {
query[sortBy] = { $lt: pageParam };
} else {
query[sortBy] = { $gt: pageParam };
}
}
if (search && search.trim()) {
try {
const searchResults = await Conversation.meiliSearch(search);
if (!searchResults?.hits?.length) {
return {
links: [],
nextCursor: undefined,
hasNextPage: false,
};
}
const conversationIds = searchResults.hits.map((hit) => hit.conversationId);
query['conversationId'] = { $in: conversationIds };
} catch (searchError) {
logger.error('[getSharedLinks] Meilisearch error', {
error: searchError.message,
user,
});
return {
links: [],
nextCursor: undefined,
hasNextPage: false,
};
}
}
const sort = {};
sort[sortBy] = sortDirection === 'desc' ? -1 : 1;
if (Array.isArray(query.conversationId)) {
query.conversationId = { $in: query.conversationId };
}
const sharedLinks = await SharedLink.find(query)
.sort(sort)
.limit(pageSize + 1)
.select('-__v -user')
.lean();
const hasNextPage = sharedLinks.length > pageSize;
const links = sharedLinks.slice(0, pageSize);
const nextCursor = hasNextPage ? links[links.length - 1][sortBy] : undefined;
return {
sharedLinks,
pages: totalPages,
pageNumber,
pageSize,
links: links.map((link) => ({
shareId: link.shareId,
title: link?.title || 'Untitled',
isPublic: link.isPublic,
createdAt: link.createdAt,
conversationId: link.conversationId,
})),
nextCursor,
hasNextPage,
};
} catch (error) {
logger.error('[getShareByPage] Error getting shares', error);
throw new Error('Error getting shares');
}
}
/**
* Creates a new shared link
* @param {string} user - The user ID
* @param {object} shareData - The share data
* @param {string} shareData.conversationId - The conversation ID
* @returns {Promise<object>} The created shared link
*/
async function createSharedLink(user, { conversationId, ...shareData }) {
try {
const share = await SharedLink.findOne({ conversationId }).select('-_id -__v -user').lean();
if (share) {
const newConvoId = anonymizeConvoId();
const sharedConvo = anonymizeConvo(share);
return Object.assign(sharedConvo, {
conversationId: newConvoId,
messages: anonymizeMessages(share.messages, newConvoId),
logger.error('[getSharedLinks] Error getting shares', {
error: error.message,
user,
});
}
const shareId = nanoid();
const messages = await getMessages({ conversationId });
const update = { ...shareData, shareId, messages, user };
const newShare = await SharedLink.findOneAndUpdate({ conversationId, user }, update, {
new: true,
upsert: true,
}).lean();
const newConvoId = anonymizeConvoId();
const sharedConvo = anonymizeConvo(newShare);
return Object.assign(sharedConvo, {
conversationId: newConvoId,
messages: anonymizeMessages(newShare.messages, newConvoId),
});
} catch (error) {
logger.error('[createSharedLink] Error creating shared link', error);
throw new Error('Error creating shared link');
throw new ShareServiceError('Error getting shares', 'SHARES_FETCH_ERROR');
}
}
/**
* Updates an existing shared link
* @param {string} user - The user ID
* @param {object} shareData - The share data to update
* @param {string} shareData.conversationId - The conversation ID
* @returns {Promise<object>} The updated shared link
*/
async function updateSharedLink(user, { conversationId, ...shareData }) {
try {
const share = await SharedLink.findOne({ conversationId }).select('-_id -__v -user').lean();
if (!share) {
return { message: 'Share not found' };
}
const messages = await getMessages({ conversationId });
const update = { ...shareData, messages, user };
const updatedShare = await SharedLink.findOneAndUpdate({ conversationId, user }, update, {
new: true,
upsert: false,
}).lean();
const newConvoId = anonymizeConvoId();
const sharedConvo = anonymizeConvo(updatedShare);
return Object.assign(sharedConvo, {
conversationId: newConvoId,
messages: anonymizeMessages(updatedShare.messages, newConvoId),
});
} catch (error) {
logger.error('[updateSharedLink] Error updating shared link', error);
throw new Error('Error updating shared link');
}
}
/**
* Deletes a shared link
* @param {string} user - The user ID
* @param {object} params - The deletion parameters
* @param {string} params.shareId - The share ID to delete
* @returns {Promise<object>} The result of the deletion
*/
async function deleteSharedLink(user, { shareId }) {
try {
const result = await SharedLink.findOneAndDelete({ shareId, user });
return result ? { message: 'Share deleted successfully' } : { message: 'Share not found' };
} catch (error) {
logger.error('[deleteSharedLink] Error deleting shared link', error);
throw new Error('Error deleting shared link');
}
}
/**
* Deletes all shared links for a specific user
* @param {string} user - The user ID
* @returns {Promise<object>} The result of the deletion
*/
async function deleteAllSharedLinks(user) {
try {
const result = await SharedLink.deleteMany({ user });
return {
message: 'All shared links have been deleted successfully',
message: 'All shared links deleted successfully',
deletedCount: result.deletedCount,
};
} catch (error) {
logger.error('[deleteAllSharedLinks] Error deleting shared links', error);
throw new Error('Error deleting shared links');
logger.error('[deleteAllSharedLinks] Error deleting shared links', {
error: error.message,
user,
});
throw new ShareServiceError('Error deleting shared links', 'BULK_DELETE_ERROR');
}
}
async function createSharedLink(user, conversationId) {
if (!user || !conversationId) {
throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS');
}
try {
const [existingShare, conversationMessages] = await Promise.all([
SharedLink.findOne({ conversationId, isPublic: true }).select('-_id -__v -user').lean(),
getMessages({ conversationId }),
]);
if (existingShare && existingShare.isPublic) {
throw new ShareServiceError('Share already exists', 'SHARE_EXISTS');
} else if (existingShare) {
await SharedLink.deleteOne({ conversationId });
}
const conversation = await Conversation.findOne({ conversationId }).lean();
const title = conversation?.title || 'Untitled';
const shareId = nanoid();
await SharedLink.create({
shareId,
conversationId,
messages: conversationMessages,
title,
user,
});
return { shareId, conversationId };
} catch (error) {
logger.error('[createSharedLink] Error creating shared link', {
error: error.message,
user,
conversationId,
});
throw new ShareServiceError('Error creating shared link', 'SHARE_CREATE_ERROR');
}
}
async function getSharedLink(user, conversationId) {
if (!user || !conversationId) {
throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS');
}
try {
const share = await SharedLink.findOne({ conversationId, user, isPublic: true })
.select('shareId -_id')
.lean();
if (!share) {
return { shareId: null, success: false };
}
return { shareId: share.shareId, success: true };
} catch (error) {
logger.error('[getSharedLink] Error getting shared link', {
error: error.message,
user,
conversationId,
});
throw new ShareServiceError('Error getting shared link', 'SHARE_FETCH_ERROR');
}
}
async function updateSharedLink(user, shareId) {
if (!user || !shareId) {
throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS');
}
try {
const share = await SharedLink.findOne({ shareId }).select('-_id -__v -user').lean();
if (!share) {
throw new ShareServiceError('Share not found', 'SHARE_NOT_FOUND');
}
const [updatedMessages] = await Promise.all([
getMessages({ conversationId: share.conversationId }),
]);
const newShareId = nanoid();
const update = {
messages: updatedMessages,
user,
shareId: newShareId,
};
const updatedShare = await SharedLink.findOneAndUpdate({ shareId, user }, update, {
new: true,
upsert: false,
runValidators: true,
}).lean();
if (!updatedShare) {
throw new ShareServiceError('Share update failed', 'SHARE_UPDATE_ERROR');
}
anonymizeConvo(updatedShare);
return { shareId: newShareId, conversationId: updatedShare.conversationId };
} catch (error) {
logger.error('[updateSharedLink] Error updating shared link', {
error: error.message,
user,
shareId,
});
throw new ShareServiceError(
error.code === 'SHARE_UPDATE_ERROR' ? error.message : 'Error updating shared link',
error.code || 'SHARE_UPDATE_ERROR',
);
}
}
async function deleteSharedLink(user, shareId) {
if (!user || !shareId) {
throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS');
}
try {
const result = await SharedLink.findOneAndDelete({ shareId, user }).lean();
if (!result) {
return null;
}
return {
success: true,
shareId,
message: 'Share deleted successfully',
};
} catch (error) {
logger.error('[deleteSharedLink] Error deleting shared link', {
error: error.message,
user,
shareId,
});
throw new ShareServiceError('Error deleting shared link', 'SHARE_DELETE_ERROR');
}
}
module.exports = {
SharedLink,
getSharedLink,
getSharedLinks,
createSharedLink,
updateSharedLink,

View file

@ -20,14 +20,6 @@ const shareSchema = mongoose.Schema(
index: true,
},
isPublic: {
type: Boolean,
default: false,
},
isVisible: {
type: Boolean,
default: false,
},
isAnonymous: {
type: Boolean,
default: true,
},

View file

@ -1,6 +1,7 @@
const express = require('express');
const {
getSharedLink,
getSharedMessages,
createSharedLink,
updateSharedLink,
@ -45,29 +46,60 @@ if (allowSharedLinks) {
*/
router.get('/', requireJwtAuth, async (req, res) => {
try {
let pageNumber = req.query.pageNumber || 1;
pageNumber = parseInt(pageNumber, 10);
const params = {
pageParam: req.query.cursor,
pageSize: Math.max(1, parseInt(req.query.pageSize) || 10),
isPublic: isEnabled(req.query.isPublic),
sortBy: ['createdAt', 'title'].includes(req.query.sortBy) ? req.query.sortBy : 'createdAt',
sortDirection: ['asc', 'desc'].includes(req.query.sortDirection)
? req.query.sortDirection
: 'desc',
search: req.query.search
? decodeURIComponent(req.query.search.trim())
: undefined,
};
if (isNaN(pageNumber) || pageNumber < 1) {
return res.status(400).json({ error: 'Invalid page number' });
}
const result = await getSharedLinks(
req.user.id,
params.pageParam,
params.pageSize,
params.isPublic,
params.sortBy,
params.sortDirection,
params.search,
);
let pageSize = req.query.pageSize || 25;
pageSize = parseInt(pageSize, 10);
if (isNaN(pageSize) || pageSize < 1) {
return res.status(400).json({ error: 'Invalid page size' });
}
const isPublic = req.query.isPublic === 'true';
res.status(200).send(await getSharedLinks(req.user.id, pageNumber, pageSize, isPublic));
res.status(200).send({
links: result.links,
nextCursor: result.nextCursor,
hasNextPage: result.hasNextPage,
});
} catch (error) {
res.status(500).json({ message: 'Error getting shared links' });
console.error('Error getting shared links:', error);
res.status(500).json({
message: 'Error getting shared links',
error: error.message,
});
}
});
router.post('/', requireJwtAuth, async (req, res) => {
router.get('/link/:conversationId', requireJwtAuth, async (req, res) => {
try {
const created = await createSharedLink(req.user.id, req.body);
const share = await getSharedLink(req.user.id, req.params.conversationId);
return res.status(200).json({
success: share.success,
shareId: share.shareId,
conversationId: req.params.conversationId,
});
} catch (error) {
res.status(500).json({ message: 'Error getting shared link' });
}
});
router.post('/:conversationId', requireJwtAuth, async (req, res) => {
try {
const created = await createSharedLink(req.user.id, req.params.conversationId);
if (created) {
res.status(200).json(created);
} else {
@ -78,11 +110,11 @@ router.post('/', requireJwtAuth, async (req, res) => {
}
});
router.patch('/', requireJwtAuth, async (req, res) => {
router.patch('/:shareId', requireJwtAuth, async (req, res) => {
try {
const updated = await updateSharedLink(req.user.id, req.body);
if (updated) {
res.status(200).json(updated);
const updatedShare = await updateSharedLink(req.user.id, req.params.shareId);
if (updatedShare) {
res.status(200).json(updatedShare);
} else {
res.status(404).end();
}
@ -93,14 +125,15 @@ router.patch('/', requireJwtAuth, async (req, res) => {
router.delete('/:shareId', requireJwtAuth, async (req, res) => {
try {
const deleted = await deleteSharedLink(req.user.id, { shareId: req.params.shareId });
if (deleted) {
res.status(200).json(deleted);
} else {
res.status(404).end();
const result = await deleteSharedLink(req.user.id, req.params.shareId);
if (!result) {
return res.status(404).json({ message: 'Share not found' });
}
return res.status(200).json(result);
} catch (error) {
res.status(500).json({ message: 'Error deleting shared link' });
return res.status(400).json({ message: error.message });
}
});

View file

@ -72,6 +72,7 @@
"lucide-react": "^0.394.0",
"match-sorter": "^6.3.4",
"msedge-tts": "^1.3.4",
"qrcode.react": "^4.2.0",
"rc-input-number": "^7.4.2",
"react": "^18.2.0",
"react-avatar-editor": "^13.0.2",

View file

@ -141,7 +141,6 @@ function ConvoOptions({
/>
{showShareDialog && (
<ShareButton
title={title ?? ''}
conversationId={conversationId ?? ''}
open={showShareDialog}
onOpenChange={setShowShareDialog}

View file

@ -1,112 +1,102 @@
import React, { useState, useEffect } from 'react';
import { OGDialog } from '~/components/ui';
import { useToastContext } from '~/Providers';
import type { TSharedLink } from 'librechat-data-provider';
import { useCreateSharedLinkMutation } from '~/data-provider';
import { QRCodeSVG } from 'qrcode.react';
import { Copy, CopyCheck } from 'lucide-react';
import { useGetSharedLinkQuery } from 'librechat-data-provider/react-query';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { useLocalize, useCopyToClipboard } from '~/hooks';
import { Button, Spinner, OGDialog } from '~/components';
import SharedLinkButton from './SharedLinkButton';
import { NotificationSeverity } from '~/common';
import { Spinner } from '~/components/svg';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
export default function ShareButton({
conversationId,
title,
open,
onOpenChange,
triggerRef,
children,
}: {
conversationId: string;
title: string;
open: boolean;
onOpenChange: React.Dispatch<React.SetStateAction<boolean>>;
triggerRef?: React.RefObject<HTMLButtonElement>;
children?: React.ReactNode;
}) {
const localize = useLocalize();
const { showToast } = useToastContext();
const { mutate, isLoading } = useCreateSharedLinkMutation();
const [share, setShare] = useState<TSharedLink | null>(null);
const [isUpdated, setIsUpdated] = useState(false);
const [isNewSharedLink, setIsNewSharedLink] = useState(false);
const [showQR, setShowQR] = useState(false);
const [sharedLink, setSharedLink] = useState('');
const [isCopying, setIsCopying] = useState(false);
const { data: share, isLoading } = useGetSharedLinkQuery(conversationId);
const copyLink = useCopyToClipboard({ text: sharedLink });
useEffect(() => {
if (!open && triggerRef && triggerRef.current) {
triggerRef.current.focus();
if (share?.shareId !== undefined) {
const link = `${window.location.protocol}//${window.location.host}/share/${share.shareId}`;
setSharedLink(link);
}
}, [open, triggerRef]);
}, [share]);
useEffect(() => {
if (isLoading || share) {
return;
}
const data = {
conversationId,
title,
isAnonymous: true,
};
mutate(data, {
onSuccess: (result) => {
setShare(result);
setIsNewSharedLink(!result.isPublic);
},
onError: () => {
showToast({
message: localize('com_ui_share_error'),
severity: NotificationSeverity.ERROR,
showIcon: true,
});
},
});
// mutation.mutate should only be called once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (!conversationId) {
return null;
}
const buttons = share && (
const button =
isLoading === true ? null : (
<SharedLinkButton
share={share}
conversationId={conversationId}
setShare={setShare}
isUpdated={isUpdated}
setIsUpdated={setIsUpdated}
setShareDialogOpen={onOpenChange}
showQR={showQR}
setShowQR={setShowQR}
setSharedLink={setSharedLink}
/>
);
const shareId = share?.shareId ?? '';
return (
<OGDialog open={open} onOpenChange={onOpenChange} triggerRef={triggerRef}>
{children}
<OGDialogTemplate
buttons={buttons}
buttons={button}
showCloseButton={true}
showCancelButton={false}
title={localize('com_ui_share_link_to_chat')}
className="max-w-[550px]"
main={
<div>
<div className="h-full py-2 text-gray-400 dark:text-gray-200">
<div className="h-full py-2 text-text-primary">
{(() => {
if (isLoading) {
if (isLoading === true) {
return <Spinner className="m-auto h-14 animate-spin" />;
}
if (isUpdated) {
return isNewSharedLink
? localize('com_ui_share_created_message')
: localize('com_ui_share_updated_message');
}
return share?.isPublic === true
return share?.success === true
? localize('com_ui_share_update_message')
: localize('com_ui_share_create_message');
})()}
</div>
<div className="relative items-center rounded-lg p-2">
{showQR && (
<div className="mb-4 flex flex-col items-center">
<QRCodeSVG value={sharedLink} size={200} marginSize={2} className="rounded-2xl" />
</div>
)}
{shareId && (
<div className="flex items-center gap-2 rounded-md bg-surface-secondary p-2">
<div className="flex-1 break-all text-sm text-text-secondary">{sharedLink}</div>
<Button
size="sm"
variant="outline"
onClick={() => {
if (isCopying) {
return;
}
copyLink(setIsCopying);
}}
className={cn('shrink-0', isCopying ? 'cursor-default' : '')}
>
{isCopying ? <CopyCheck className="size-4" /> : <Copy className="size-4" />}
</Button>
</div>
)}
</div>
</div>
}
/>

View file

@ -1,31 +1,38 @@
import { useState } from 'react';
import copy from 'copy-to-clipboard';
import { Copy, Link } from 'lucide-react';
import type { TSharedLink } from 'librechat-data-provider';
import { useUpdateSharedLinkMutation } from '~/data-provider';
import { useState, useCallback } from 'react';
import { QrCode, RotateCw, Trash2 } from 'lucide-react';
import type { TSharedLinkGetResponse } from 'librechat-data-provider';
import {
useCreateSharedLinkMutation,
useUpdateSharedLinkMutation,
useDeleteSharedLinkMutation,
} from '~/data-provider';
import { Button, OGDialog, Spinner, TooltipAnchor, Label } from '~/components';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers';
import { Spinner } from '~/components/svg';
import { useLocalize } from '~/hooks';
export default function SharedLinkButton({
conversationId,
share,
setShare,
isUpdated,
setIsUpdated,
conversationId,
setShareDialogOpen,
showQR,
setShowQR,
setSharedLink,
}: {
share: TSharedLinkGetResponse | undefined;
conversationId: string;
share: TSharedLink;
setShare: (share: TSharedLink) => void;
isUpdated: boolean;
setIsUpdated: (isUpdated: boolean) => void;
setShareDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
showQR: boolean;
setShowQR: (showQR: boolean) => void;
setSharedLink: (sharedLink: string) => void;
}) {
const localize = useLocalize();
const { showToast } = useToastContext();
const [isCopying, setIsCopying] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const shareId = share?.shareId ?? '';
const { mutateAsync, isLoading } = useUpdateSharedLinkMutation({
const { mutateAsync: mutate, isLoading: isCreateLoading } = useCreateSharedLinkMutation({
onError: () => {
showToast({
message: localize('com_ui_share_error'),
@ -35,92 +42,145 @@ export default function SharedLinkButton({
},
});
const copyLink = () => {
if (!share) {
return;
}
setIsCopying(true);
const sharedLink =
window.location.protocol + '//' + window.location.host + '/share/' + share.shareId;
copy(sharedLink);
setTimeout(() => {
setIsCopying(false);
}, 1500);
};
const updateSharedLink = async () => {
if (!share) {
return;
}
const result = await mutateAsync({
shareId: share.shareId,
conversationId: conversationId,
isPublic: true,
isVisible: true,
isAnonymous: true,
const { mutateAsync, isLoading: isUpdateLoading } = useUpdateSharedLinkMutation({
onError: () => {
showToast({
message: localize('com_ui_share_error'),
severity: NotificationSeverity.ERROR,
showIcon: true,
});
},
});
if (result) {
setShare(result);
setIsUpdated(true);
copyLink();
}
};
const getHandler = () => {
if (isUpdated) {
return {
handler: () => {
copyLink();
const deleteMutation = useDeleteSharedLinkMutation({
onSuccess: async () => {
setShowDeleteDialog(false);
setShareDialogOpen(false);
},
label: (
<>
<Copy className="mr-2 h-4 w-4" />
{localize('com_ui_copy_link')}
</>
),
};
}
if (share.isPublic) {
return {
handler: async () => {
await updateSharedLink();
onError: (error) => {
console.error('Delete error:', error);
showToast({
message: localize('com_ui_share_delete_error'),
severity: NotificationSeverity.ERROR,
});
},
});
label: (
<>
<Link className="mr-2 h-4 w-4" />
{localize('com_ui_update_link')}
</>
),
};
const generateShareLink = useCallback((shareId: string) => {
return `${window.location.protocol}//${window.location.host}/share/${shareId}`;
}, []);
const updateSharedLink = async () => {
if (!shareId) {
return;
}
const updateShare = await mutateAsync({ shareId });
const newLink = generateShareLink(updateShare.shareId);
setSharedLink(newLink);
};
const createShareLink = async () => {
const share = await mutate({ conversationId });
const newLink = generateShareLink(share.shareId);
setSharedLink(newLink);
};
const handleDelete = async () => {
if (!shareId) {
return;
}
try {
await deleteMutation.mutateAsync({ shareId });
showToast({
message: localize('com_ui_shared_link_delete_success'),
severity: NotificationSeverity.SUCCESS,
});
} catch (error) {
console.error('Failed to delete shared link:', error);
showToast({
message: localize('com_ui_share_delete_error'),
severity: NotificationSeverity.ERROR,
});
}
return {
handler: updateSharedLink,
label: (
<>
<Link className="mr-2 h-4 w-4" />
{localize('com_ui_create_link')}
</>
),
};
};
const handlers = getHandler();
return (
<button
disabled={isLoading || isCopying}
onClick={() => {
handlers.handler();
}}
className="btn btn-primary flex items-center justify-center"
>
{isCopying && (
<>
<Copy className="mr-2 h-4 w-4" />
{localize('com_ui_copied')}
</>
<div className="flex gap-2">
{!shareId && (
<Button disabled={isCreateLoading} variant="submit" onClick={createShareLink}>
{!isCreateLoading && localize('com_ui_create_link')}
{isCreateLoading && <Spinner className="size-4" />}
</Button>
)}
{!isCopying && !isLoading && handlers.label}
{!isCopying && isLoading && <Spinner className="h-4 w-4" />}
</button>
{shareId && (
<div className="flex items-center gap-2">
<TooltipAnchor
description={localize('com_ui_refresh_link')}
render={(props) => (
<Button
{...props}
onClick={() => updateSharedLink()}
variant="outline"
disabled={isUpdateLoading}
>
{isUpdateLoading ? (
<Spinner className="size-4" />
) : (
<RotateCw className="size-4" />
)}
</Button>
)}
/>
<TooltipAnchor
description={showQR ? localize('com_ui_hide_qr') : localize('com_ui_show_qr')}
render={(props) => (
<Button {...props} onClick={() => setShowQR(!showQR)} variant="outline">
<QrCode className="size-4" />
</Button>
)}
/>
<TooltipAnchor
description={localize('com_ui_delete')}
render={(props) => (
<Button {...props} onClick={() => setShowDeleteDialog(true)} variant="destructive">
<Trash2 className="size-4" />
</Button>
)}
/>
</div>
)}
<OGDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_shared_link')}
className="max-w-[450px]"
main={
<>
<div className="flex w-full flex-col items-center gap-2">
<div className="grid w-full items-center gap-2">
<Label
htmlFor="dialog-confirm-delete"
className="text-left text-sm font-medium"
>
{localize('com_ui_delete_confirm')} <strong>&quot;{shareId}&quot;</strong>
</Label>
</div>
</div>
</>
}
selection={{
selectHandler: handleDelete,
selectClasses:
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white',
selectText: localize('com_ui_delete'),
}}
/>
</OGDialog>
</div>
</>
);
}

View file

@ -1,198 +0,0 @@
import { useState, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Link as LinkIcon, TrashIcon } from 'lucide-react';
import type { SharedLinksResponse, TSharedLink } from 'librechat-data-provider';
import { useDeleteSharedLinkMutation, useSharedLinksInfiniteQuery } from '~/data-provider';
import { useAuthContext, useLocalize, useNavScrolling } from '~/hooks';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers';
import { cn } from '~/utils';
import {
Button,
Label,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TooltipAnchor,
Skeleton,
Spinner,
OGDialog,
OGDialogTrigger,
} from '~/components';
function ShareLinkRow({ sharedLink }: { sharedLink: TSharedLink }) {
const [isDeleting, setIsDeleting] = useState(false);
const localize = useLocalize();
const { showToast } = useToastContext();
const mutation = useDeleteSharedLinkMutation({
onError: () => {
showToast({
message: localize('com_ui_share_delete_error'),
severity: NotificationSeverity.ERROR,
});
setIsDeleting(false);
},
});
const confirmDelete = async (shareId: TSharedLink['shareId']) => {
if (mutation.isLoading) {
return;
}
setIsDeleting(true);
await mutation.mutateAsync({ shareId });
setIsDeleting(false);
};
return (
<TableRow className={(cn(isDeleting && 'opacity-50'), 'hover:bg-transparent')}>
<TableCell>
<Link
to={`/share/${sharedLink.shareId}`}
target="_blank"
rel="noreferrer"
className="flex items-center text-blue-500 hover:underline"
>
<LinkIcon className="mr-2 h-4 w-4" />
{sharedLink.title}
</Link>
</TableCell>
<TableCell>
{new Date(sharedLink.createdAt).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
})}
</TableCell>
<TableCell className="text-right">
{sharedLink.conversationId && (
<OGDialog>
<OGDialogTrigger asChild>
<TooltipAnchor
description={localize('com_ui_delete')}
render={
<Button
aria-label="Delete shared link"
variant="ghost"
size="icon"
className="size-8"
>
<TrashIcon className="size-4" />
</Button>
}
></TooltipAnchor>
</OGDialogTrigger>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_shared_link')}
className="max-w-[450px]"
main={
<>
<div className="flex w-full flex-col items-center gap-2">
<div className="grid w-full items-center gap-2">
<Label
htmlFor="dialog-confirm-delete"
className="text-left text-sm font-medium"
>
{localize('com_ui_delete_confirm')} <strong>{sharedLink.title}</strong>
</Label>
</div>
</div>
</>
}
selection={{
selectHandler: () => confirmDelete(sharedLink.shareId),
selectClasses:
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white',
selectText: localize('com_ui_delete'),
}}
/>
</OGDialog>
)}
</TableCell>
</TableRow>
);
}
export default function ShareLinkTable({ className }) {
const localize = useLocalize();
const { isAuthenticated } = useAuthContext();
const [showLoading, setShowLoading] = useState(false);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isError, isLoading } =
useSharedLinksInfiniteQuery({ pageNumber: '1', isPublic: true }, { enabled: isAuthenticated });
const { containerRef } = useNavScrolling<SharedLinksResponse>({
setShowLoading,
hasNextPage: hasNextPage,
fetchNextPage: fetchNextPage,
isFetchingNextPage: isFetchingNextPage,
});
const sharedLinks = useMemo(() => data?.pages.flatMap((page) => page.sharedLinks) || [], [data]);
const getRandomWidth = () => Math.floor(Math.random() * (400 - 170 + 1)) + 170;
const skeletons = Array.from({ length: 11 }, (_, index) => {
const randomWidth = getRandomWidth();
return (
<div key={index} className="flex h-10 w-full items-center">
<div className="flex w-[410px] items-center">
<Skeleton className="h-4" style={{ width: `${randomWidth}px` }} />
</div>
<div className="flex flex-grow justify-center">
<Skeleton className="h-4 w-28" />
</div>
<div className="mr-2 flex justify-end">
<Skeleton className="h-4 w-12" />
</div>
</div>
);
});
if (isLoading) {
return <div className="text-gray-300">{skeletons}</div>;
}
if (isError) {
return (
<div className="rounded-md border border-red-500 bg-red-500/10 px-3 py-2 text-sm text-gray-600 dark:text-gray-200">
{localize('com_ui_share_retrieve_error')}
</div>
);
}
if (sharedLinks.length === 0) {
return <div className="text-gray-300">{localize('com_nav_shared_links_empty')}</div>;
}
return (
<div
className={cn(
'-mr-2 grid max-h-[350px] w-full flex-1 flex-col gap-2 overflow-y-auto pr-2 transition-opacity duration-500',
className,
)}
ref={containerRef}
>
<Table>
<TableHeader>
<TableRow>
<TableHead>{localize('com_nav_shared_links_name')}</TableHead>
<TableHead>{localize('com_nav_shared_links_date_shared')}</TableHead>
<TableHead className="text-right">{localize('com_assistants_actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sharedLinks.map((sharedLink) => (
<ShareLinkRow key={sharedLink.shareId} sharedLink={sharedLink} />
))}
</TableBody>
</Table>
{(isFetchingNextPage || showLoading) && <Spinner className="mx-auto my-4" />}
</div>
);
}

View file

@ -1,27 +1,324 @@
import { useLocalize } from '~/hooks';
import { OGDialog, OGDialogTrigger } from '~/components/ui';
import { useCallback, useState, useMemo, useEffect } from 'react';
import { Link } from 'react-router-dom';
import debounce from 'lodash/debounce';
import { TrashIcon, MessageSquare, ArrowUpDown } from 'lucide-react';
import type { SharedLinkItem, SharedLinksListParams } from 'librechat-data-provider';
import {
OGDialog,
OGDialogTrigger,
OGDialogContent,
OGDialogHeader,
OGDialogTitle,
Button,
TooltipAnchor,
Label,
} from '~/components/ui';
import { useDeleteSharedLinkMutation, useSharedLinksQuery } from '~/data-provider';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { useLocalize, useMediaQuery } from '~/hooks';
import DataTable from '~/components/ui/DataTable';
import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers';
import { formatDate } from '~/utils';
import { Spinner } from '~/components/svg';
import ShareLinkTable from './SharedLinkTable';
const PAGE_SIZE = 25;
const DEFAULT_PARAMS: SharedLinksListParams = {
pageSize: PAGE_SIZE,
isPublic: true,
sortBy: 'createdAt',
sortDirection: 'desc',
search: '',
};
export default function SharedLinks() {
const localize = useLocalize();
const { showToast } = useToastContext();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const [queryParams, setQueryParams] = useState<SharedLinksListParams>(DEFAULT_PARAMS);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch, isLoading } =
useSharedLinksQuery(queryParams, {
enabled: isOpen,
staleTime: 0,
cacheTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
refetchOnMount: false,
});
const handleSort = useCallback((sortField: string, sortOrder: 'asc' | 'desc') => {
setQueryParams((prev) => ({
...prev,
sortBy: sortField as 'title' | 'createdAt',
sortDirection: sortOrder,
}));
}, []);
const handleFilterChange = useCallback((value: string) => {
const encodedValue = encodeURIComponent(value.trim());
setQueryParams((prev) => ({
...prev,
search: encodedValue,
}));
}, []);
const debouncedFilterChange = useMemo(
() => debounce(handleFilterChange, 300),
[handleFilterChange],
);
useEffect(() => {
return () => {
debouncedFilterChange.cancel();
};
}, [debouncedFilterChange]);
const allLinks = useMemo(() => {
if (!data?.pages) {
return [];
}
return data.pages.flatMap((page) => page.links.filter(Boolean));
}, [data?.pages]);
const deleteMutation = useDeleteSharedLinkMutation({
onSuccess: async () => {
setIsDeleteOpen(false);
setDeleteRow(null);
await refetch();
},
onError: (error) => {
console.error('Delete error:', error);
showToast({
message: localize('com_ui_share_delete_error'),
severity: NotificationSeverity.ERROR,
});
},
});
const handleDelete = useCallback(
async (selectedRows: SharedLinkItem[]) => {
const validRows = selectedRows.filter(
(row) => typeof row.shareId === 'string' && row.shareId.length > 0,
);
if (validRows.length === 0) {
showToast({
message: localize('com_ui_no_valid_items'),
severity: NotificationSeverity.WARNING,
});
return;
}
try {
for (const row of validRows) {
await deleteMutation.mutateAsync({ shareId: row.shareId });
}
showToast({
message: localize(
validRows.length === 1
? 'com_ui_shared_link_delete_success'
: 'com_ui_shared_link_bulk_delete_success',
),
severity: NotificationSeverity.SUCCESS,
});
} catch (error) {
console.error('Failed to delete shared links:', error);
showToast({
message: localize('com_ui_bulk_delete_error'),
severity: NotificationSeverity.ERROR,
});
}
},
[deleteMutation, showToast, localize],
);
const handleFetchNextPage = useCallback(async () => {
if (hasNextPage !== true || isFetchingNextPage) {
return;
}
await fetchNextPage();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
const [deleteRow, setDeleteRow] = useState<SharedLinkItem | null>(null);
const confirmDelete = useCallback(() => {
if (deleteRow) {
handleDelete([deleteRow]);
}
setIsDeleteOpen(false);
}, [deleteRow, handleDelete]);
const columns = useMemo(
() => [
{
accessorKey: 'title',
header: ({ column }) => {
return (
<Button
variant="ghost"
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
onClick={() => handleSort('title', column.getIsSorted() === 'asc' ? 'desc' : 'asc')}
>
{localize('com_ui_name')}
<ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
</Button>
);
},
cell: ({ row }) => {
const { title, shareId } = row.original;
return (
<div className="flex items-center gap-2">
<Link
to={`/share/${shareId}`}
target="_blank"
rel="noopener noreferrer"
className="block truncate text-blue-500 hover:underline"
title={title}
>
{title}
</Link>
</div>
);
},
meta: {
size: '35%',
mobileSize: '50%',
},
},
{
accessorKey: 'createdAt',
header: ({ column }) => {
return (
<Button
variant="ghost"
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
onClick={() =>
handleSort('createdAt', column.getIsSorted() === 'asc' ? 'desc' : 'asc')
}
>
{localize('com_ui_date')}
<ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
</Button>
);
},
cell: ({ row }) => formatDate(row.original.createdAt?.toString() ?? '', isSmallScreen),
meta: {
size: '10%',
mobileSize: '20%',
},
},
{
accessorKey: 'actions',
header: () => (
<Label className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm">
{localize('com_assistants_actions')}
</Label>
),
meta: {
size: '7%',
mobileSize: '25%',
},
cell: ({ row }) => (
<div className="flex items-center gap-2">
<TooltipAnchor
description={localize('com_ui_view_source')}
render={
<Button
variant="ghost"
className="h-8 w-8 p-0 hover:bg-surface-hover"
onClick={() => {
window.open(`/c/${row.original.conversationId}`, '_blank');
}}
title={localize('com_ui_view_source')}
>
<MessageSquare className="size-4" />
</Button>
}
></TooltipAnchor>
<TooltipAnchor
description={localize('com_ui_delete')}
render={
<Button
variant="ghost"
className="h-8 w-8 p-0 hover:bg-surface-hover"
onClick={() => {
setDeleteRow(row.original);
setIsDeleteOpen(true);
}}
title={localize('com_ui_delete')}
>
<TrashIcon className="size-4" />
</Button>
}
></TooltipAnchor>
</div>
),
},
],
[isSmallScreen, localize],
);
return (
<div className="flex items-center justify-between">
<div>{localize('com_nav_shared_links')}</div>
<OGDialog>
<OGDialogTrigger asChild>
<OGDialog open={isOpen} onOpenChange={setIsOpen}>
<OGDialogTrigger asChild onClick={() => setIsOpen(true)}>
<button className="btn btn-neutral relative">
{localize('com_nav_shared_links_manage')}
</button>
</OGDialogTrigger>
<OGDialogContent
title={localize('com_nav_my_files')}
className="w-11/12 max-w-5xl bg-background text-text-primary shadow-2xl"
>
<OGDialogHeader>
<OGDialogTitle>{localize('com_nav_shared_links')}</OGDialogTitle>
</OGDialogHeader>
<DataTable
columns={columns}
data={allLinks}
onDelete={handleDelete}
filterColumn="title"
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
fetchNextPage={handleFetchNextPage}
showCheckboxes={false}
onFilterChange={debouncedFilterChange}
filterValue={queryParams.search}
/>
</OGDialogContent>
</OGDialog>
<OGDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
<OGDialogTemplate
title={localize('com_nav_shared_links')}
className="max-w-[1000px]"
showCancelButton={false}
main={<ShareLinkTable className="w-full" />}
showCloseButton={false}
title={localize('com_ui_delete_shared_link')}
className="max-w-[450px]"
main={
<>
<div className="flex w-full flex-col items-center gap-2">
<div className="grid w-full items-center gap-2">
<Label htmlFor="dialog-confirm-delete" className="text-left text-sm font-medium">
{localize('com_ui_delete_confirm')} <strong>{deleteRow?.title}</strong>
</Label>
</div>
</div>
</>
}
selection={{
selectHandler: confirmDelete,
selectClasses: `bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white ${
deleteMutation.isLoading ? 'cursor-not-allowed opacity-80' : ''
}`,
selectText: deleteMutation.isLoading ? <Spinner /> : localize('com_ui_delete'),
}}
/>
</OGDialog>
</div>

View file

@ -140,9 +140,9 @@ const AdminSettings = () => {
<OGDialog>
<OGDialogTrigger asChild>
<Button
size={'sm'}
variant={'outline'}
className="h-10 w-fit gap-1 border transition-all dark:bg-transparent"
size='sm'
variant='outline'
className="h-10 w-fit gap-1 border transition-all dark:bg-transparent dark:hover:bg-surface-tertiary"
>
<ShieldEllipsis className="cursor-pointer" />
<span className="hidden sm:flex">{localize('com_ui_admin')}</span>

View file

@ -1,6 +1,6 @@
import { Trash2 } from 'lucide-react';
import { Button, OGDialog, OGDialogTrigger, Label } from '~/components/ui';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { TrashIcon } from '~/components/svg';
import { useLocalize } from '~/hooks';
const DeleteVersion = ({
@ -18,14 +18,15 @@ const DeleteVersion = ({
<OGDialog>
<OGDialogTrigger asChild>
<Button
size={'sm'}
className="h-10 w-10 border border-transparent bg-red-600 text-red-500 transition-all hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-800"
variant="default"
size="sm"
className="h-10 w-10 border border-transparent bg-red-600 transition-all hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-800 p-0.5"
disabled={disabled}
onClick={(e) => {
e.stopPropagation();
}}
>
<TrashIcon className="icon-lg cursor-pointer text-white dark:text-white" />
<Trash2 className="cursor-pointer text-white size-5" />
</Button>
</OGDialogTrigger>
<OGDialogTemplate

View file

@ -256,9 +256,9 @@ const PromptForm = () => {
{hasShareAccess && <SharePrompt group={group} disabled={isLoadingGroup} />}
{editorMode === PromptsEditorMode.ADVANCED && (
<Button
size={'sm'}
className="h-10 border border-transparent bg-green-500 transition-all hover:bg-green-600 dark:bg-green-500 dark:hover:bg-green-600"
variant={'default'}
variant="default"
size="sm"
className="h-10 w-10 border border-transparent bg-green-500 transition-all hover:bg-green-600 dark:bg-green-500 dark:hover:bg-green-600 p-0.5"
onClick={() => {
const { _id: promptVersionId = '', prompt } = selectedPrompt ?? ({} as TPrompt);
makeProductionMutation.mutate(
@ -283,7 +283,7 @@ const PromptForm = () => {
makeProductionMutation.isLoading
}
>
<Rocket className="cursor-pointer text-white" />
<Rocket className="cursor-pointer text-white size-5" />
</Button>
)}
<DeleteConfirm

View file

@ -80,16 +80,18 @@ const SharePrompt = ({ group, disabled }: { group?: TPromptGroup; disabled: bool
<OGDialog>
<OGDialogTrigger asChild>
<Button
variant={'default'}
size={'sm'}
className="h-10 w-10 border border-transparent bg-blue-500/90 transition-all hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-800"
variant="default"
size="sm"
className="h-10 w-10 border border-transparent bg-blue-500/90 p-0.5 transition-all hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-800"
disabled={disabled}
>
<Share2Icon className="cursor-pointer text-white " />
<Share2Icon className="size-5 cursor-pointer text-white" />
</Button>
</OGDialogTrigger>
<OGDialogContent className="border-border-light bg-surface-primary-alt text-text-secondary">
<OGDialogTitle>{localize('com_ui_share_var', `"${group.name}"`)}</OGDialogTitle>
<OGDialogContent className="w-11/12 max-w-[600px]">
<OGDialogTitle className="truncate pr-2" title={group.name}>
{localize('com_ui_share_var', `"${group.name}"`)}
</OGDialogTitle>
<form className="p-2" onSubmit={handleSubmit(onSubmit)}>
<div className="mb-4 flex items-center justify-between gap-2 py-4">
<div className="flex items-center">

View file

@ -0,0 +1,106 @@
import React, { useState } from 'react';
import { Search } from 'lucide-react';
const AnimatedSearchInput = ({ value, onChange, isSearching: searching, placeholder }) => {
const [isFocused, setIsFocused] = useState(false);
const isSearching = searching === true;
return (
<div className="relative w-full">
<div className="relative rounded-lg transition-all duration-500 ease-in-out">
{/* Background gradient effect */}
<div
className={`
absolute inset-0 rounded-lg
bg-gradient-to-r from-blue-500/20 via-purple-500/20 to-blue-500/20
transition-all duration-500 ease-in-out
${isSearching ? 'opacity-100 blur-sm' : 'opacity-0 blur-none'}
`}
/>
<div className="relative">
<div className="absolute left-3 top-1/2 z-10 -translate-y-1/2">
<Search
className={`
h-4 w-4 transition-all duration-500 ease-in-out
${isFocused ? 'text-blue-500' : 'text-gray-400'}
${isSearching ? 'text-blue-400' : ''}
`}
/>
</div>
{/* Input field with background transitions */}
<input
type="text"
value={value}
onChange={onChange}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
placeholder={placeholder}
className={`
w-full rounded-lg px-10 py-2
transition-all duration-500 ease-in-out
placeholder:text-gray-400
focus:outline-none focus:ring-0
${isFocused ? 'bg-white/10' : 'bg-white/5'}
${isSearching ? 'bg-white/15' : ''}
backdrop-blur-sm
`}
/>
{/* Animated loading indicator */}
<div
className={`
absolute right-3 top-1/2 -translate-y-1/2
transition-all duration-500 ease-in-out
${isSearching ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}
`}
>
<div className="relative h-2 w-2">
<div className="absolute inset-0 animate-ping rounded-full bg-blue-500/60" />
<div className="absolute inset-0 rounded-full bg-blue-500" />
</div>
</div>
</div>
</div>
{/* Outer glow effect */}
<div
className={`
absolute -inset-8 -z-10
transition-all duration-700 ease-in-out
${isSearching ? 'scale-105 opacity-100' : 'scale-100 opacity-0'}
`}
>
<div className="absolute inset-0">
<div
className={`
bg-gradient-radial absolute inset-0 from-blue-500/10 to-transparent
transition-opacity duration-700 ease-in-out
${isSearching ? 'animate-pulse-slow opacity-100' : 'opacity-0'}
`}
/>
<div
className={`
absolute inset-0 bg-gradient-to-r from-purple-500/5 via-blue-500/5 to-purple-500/5
blur-xl transition-all duration-700 ease-in-out
${isSearching ? 'animate-gradient-x opacity-100' : 'opacity-0'}
`}
/>
</div>
</div>
{/* Focus state background glow */}
<div
className={`
absolute inset-0 -z-20 bg-gradient-to-r from-blue-500/10
via-purple-500/10 to-blue-500/10 blur-xl
transition-all duration-500 ease-in-out
${isFocused ? 'scale-105 opacity-100' : 'scale-100 opacity-0'}
`}
/>
</div>
);
};
export default AnimatedSearchInput;

View file

@ -15,7 +15,8 @@ const buttonVariants = cva(
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
submit: 'bg-surface-submit text-text-primary hover:bg-surface-submit/90',
// hardcoded text color because of WCAG contrast issues (text-white)
submit: 'bg-surface-submit text-white hover:bg-surface-submit-hover',
},
size: {
default: 'h-10 px-4 py-2',

View file

@ -0,0 +1,459 @@
import React, { useCallback, useEffect, useRef, useState, memo, useMemo } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import {
Row,
ColumnDef,
flexRender,
SortingState,
useReactTable,
getCoreRowModel,
VisibilityState,
getSortedRowModel,
ColumnFiltersState,
getFilteredRowModel,
} from '@tanstack/react-table';
import type { Table as TTable } from '@tanstack/react-table';
import {
Button,
Table,
Checkbox,
TableRow,
TableBody,
TableCell,
TableHead,
TableHeader,
AnimatedSearchInput,
} from './';
import { TrashIcon, Spinner } from '~/components/svg';
import { useLocalize, useMediaQuery } from '~/hooks';
import { cn } from '~/utils';
type TableColumn<TData, TValue> = ColumnDef<TData, TValue> & {
meta?: {
size?: string | number;
mobileSize?: string | number;
minWidth?: string | number;
};
};
const SelectionCheckbox = memo(
({
checked,
onChange,
ariaLabel,
}: {
checked: boolean;
onChange: (value: boolean) => void;
ariaLabel: string;
}) => (
<div
role="button"
tabIndex={0}
onKeyDown={(e) => e.stopPropagation()}
className="flex h-full w-[30px] items-center justify-center"
onClick={(e) => e.stopPropagation()}
>
<Checkbox checked={checked} onCheckedChange={onChange} aria-label={ariaLabel} />
</div>
),
);
SelectionCheckbox.displayName = 'SelectionCheckbox';
interface DataTableProps<TData, TValue> {
columns: TableColumn<TData, TValue>[];
data: TData[];
onDelete?: (selectedRows: TData[]) => Promise<void>;
filterColumn?: string;
defaultSort?: SortingState;
columnVisibilityMap?: Record<string, string>;
className?: string;
pageSize?: number;
isFetchingNextPage?: boolean;
hasNextPage?: boolean;
fetchNextPage?: (options?: unknown) => Promise<unknown>;
enableRowSelection?: boolean;
showCheckboxes?: boolean;
onFilterChange?: (value: string) => void;
filterValue?: string;
}
const TableRowComponent = <TData, TValue>({
row,
isSmallScreen,
onSelectionChange,
index,
isSearching,
}: {
row: Row<TData>;
isSmallScreen: boolean;
onSelectionChange?: (rowId: string, selected: boolean) => void;
index: number;
isSearching: boolean;
}) => {
const handleSelection = useCallback(
(value: boolean) => {
row.toggleSelected(value);
onSelectionChange?.(row.id, value);
},
[row, onSelectionChange],
);
return (
<TableRow
data-state={row.getIsSelected() ? 'selected' : undefined}
className={`
motion-safe:animate-fadeIn border-b
border-border-light transition-all duration-300
ease-out
hover:bg-surface-secondary
${isSearching ? 'opacity-50' : 'opacity-100'}
${isSearching ? 'scale-98' : 'scale-100'}
`}
style={{
animationDelay: `${index * 20}ms`,
transform: `translateY(${isSearching ? '4px' : '0'})`,
}}
>
{row.getVisibleCells().map((cell) => {
if (cell.column.id === 'select') {
return (
<TableCell key={cell.id} className="px-2 py-1 transition-all duration-300">
<SelectionCheckbox
checked={row.getIsSelected()}
onChange={handleSelection}
ariaLabel="Select row"
/>
</TableCell>
);
}
return (
<TableCell
key={cell.id}
className={`
w-0 max-w-0 px-2 py-1 align-middle text-xs
transition-all duration-300 sm:px-4
sm:py-2 sm:text-sm
${isSearching ? 'blur-[0.3px]' : 'blur-0'}
`}
style={getColumnStyle(
cell.column.columnDef as TableColumn<TData, TValue>,
isSmallScreen,
)}
>
<div className="overflow-hidden text-ellipsis">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
</TableCell>
);
})}
</TableRow>
);
};
const MemoizedTableRow = memo(TableRowComponent) as typeof TableRowComponent;
function getColumnStyle<TData, TValue>(
column: TableColumn<TData, TValue>,
isSmallScreen: boolean,
): React.CSSProperties {
return {
width: isSmallScreen ? column.meta?.mobileSize : column.meta?.size,
minWidth: column.meta?.minWidth,
maxWidth: column.meta?.size,
};
}
const DeleteButton = memo(
({
onDelete,
isDeleting,
disabled,
isSmallScreen,
localize,
}: {
onDelete?: () => Promise<void>;
isDeleting: boolean;
disabled: boolean;
isSmallScreen: boolean;
localize: (key: string) => string;
}) => {
if (!onDelete) {
return null;
}
return (
<Button
variant="outline"
onClick={onDelete}
disabled={disabled}
className={cn('min-w-[40px] transition-all duration-200', isSmallScreen && 'px-2 py-1')}
>
{isDeleting ? (
<Spinner className="size-4" />
) : (
<>
<TrashIcon className="size-3.5 text-red-400 sm:size-4" />
{!isSmallScreen && <span className="ml-2">{localize('com_ui_delete')}</span>}
</>
)}
</Button>
);
},
);
export default function DataTable<TData, TValue>({
columns,
data,
onDelete,
filterColumn,
defaultSort = [],
className = '',
isFetchingNextPage = false,
hasNextPage = false,
fetchNextPage,
enableRowSelection = true,
showCheckboxes = true,
onFilterChange,
filterValue,
}: DataTableProps<TData, TValue>) {
const localize = useLocalize();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const tableContainerRef = useRef<HTMLDivElement>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const [sorting, setSorting] = useState<SortingState>(defaultSort);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [searchTerm, setSearchTerm] = useState(filterValue ?? '');
const [isSearching, setIsSearching] = useState(false);
const tableColumns = useMemo(() => {
if (!enableRowSelection || !showCheckboxes) {
return columns;
}
const selectColumn = {
id: 'select',
header: ({ table }: { table: TTable<TData> }) => (
<div className="flex h-full w-[30px] items-center justify-center">
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(Boolean(value))}
aria-label="Select all"
/>
</div>
),
cell: ({ row }: { row: Row<TData> }) => (
<SelectionCheckbox
checked={row.getIsSelected()}
onChange={(value) => row.toggleSelected(value)}
ariaLabel="Select row"
/>
),
meta: { size: '50px' },
};
return [selectColumn, ...columns];
}, [columns, enableRowSelection, showCheckboxes]);
const table = useReactTable({
data,
columns: tableColumns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
enableRowSelection,
enableMultiRowSelection: true,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
},
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
});
const { rows } = table.getRowModel();
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => tableContainerRef.current,
estimateSize: useCallback(() => 48, []),
overscan: 10,
});
const virtualRows = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize();
const paddingTop = virtualRows.length > 0 ? virtualRows[0].start : 0;
const paddingBottom =
virtualRows.length > 0 ? totalSize - virtualRows[virtualRows.length - 1].end : 0;
useEffect(() => {
const scrollElement = tableContainerRef.current;
if (!scrollElement) {
return;
}
const handleScroll = async () => {
if (!hasNextPage || isFetchingNextPage) {
return;
}
const { scrollTop, scrollHeight, clientHeight } = scrollElement;
if (scrollHeight - scrollTop <= clientHeight * 1.5) {
try {
// Safely fetch next page without breaking if lastPage is undefined
await fetchNextPage?.();
} catch (error) {
console.error('Unable to fetch next page:', error);
}
}
};
scrollElement.addEventListener('scroll', handleScroll, { passive: true });
return () => scrollElement.removeEventListener('scroll', handleScroll);
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
useEffect(() => {
setIsSearching(true);
const timeout = setTimeout(() => {
onFilterChange?.(searchTerm);
setIsSearching(false);
}, 300);
return () => clearTimeout(timeout);
}, [searchTerm, onFilterChange]);
const handleDelete = useCallback(async () => {
if (!onDelete) {
return;
}
setIsDeleting(true);
try {
const itemsToDelete = table.getFilteredSelectedRowModel().rows.map((r) => r.original);
await onDelete(itemsToDelete);
setRowSelection({});
// await fetchNextPage?.({ pageParam: lastPage?.nextCursor });
} finally {
setIsDeleting(false);
}
}, [onDelete, table]);
return (
<div className={cn('flex h-full flex-col gap-4', className)}>
{/* Table controls */}
<div className="flex flex-wrap items-center gap-2 py-2 sm:gap-4 sm:py-4">
{enableRowSelection && showCheckboxes && (
<DeleteButton
onDelete={handleDelete}
isDeleting={isDeleting}
disabled={!table.getFilteredSelectedRowModel().rows.length || isDeleting}
isSmallScreen={isSmallScreen}
localize={localize}
/>
)}
{filterColumn !== undefined && table.getColumn(filterColumn) && (
<div className="relative flex-1">
<AnimatedSearchInput
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
isSearching={isSearching}
placeholder={`${localize('com_ui_search')}...`}
/>
</div>
)}
</div>
{/* Virtualized table */}
<div
ref={tableContainerRef}
className={cn(
'relative h-[calc(100vh-20rem)] max-w-full overflow-x-auto overflow-y-auto rounded-md border border-black/10 dark:border-white/10',
'transition-all duration-300 ease-out',
isSearching && 'bg-surface-secondary/50',
className,
)}
>
<Table className="w-full min-w-[300px] table-fixed border-separate border-spacing-0">
<TableHeader className="sticky top-0 z-50 bg-surface-secondary">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="border-b border-border-light">
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className="whitespace-nowrap bg-surface-secondary px-2 py-2 text-left text-sm font-medium text-text-secondary sm:px-4"
style={getColumnStyle(
header.column.columnDef as TableColumn<TData, TValue>,
isSmallScreen,
)}
onClick={
header.column.getCanSort()
? header.column.getToggleSortingHandler()
: undefined
}
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{paddingTop > 0 && (
<tr>
<td style={{ height: `${paddingTop}px` }} />
</tr>
)}
{virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index];
return (
<MemoizedTableRow
key={row.id}
row={row}
isSmallScreen={isSmallScreen}
index={virtualRow.index}
isSearching={isSearching}
/>
);
})}
{!virtualRows.length && (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={columns.length} className="p-4 text-center">
{localize('com_ui_no_data')}
</TableCell>
</TableRow>
)}
{paddingBottom > 0 && (
<tr>
<td style={{ height: `${paddingBottom}px` }} />
</tr>
)}
{/* Loading indicator */}
{(isFetchingNextPage || hasNextPage) && (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={columns.length} className="p-4">
<div className="flex h-full items-center justify-center">
{isFetchingNextPage ? (
<Spinner className="size-4" />
) : (
hasNextPage && <div className="h-6" />
)}
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
);
}

View file

@ -6,7 +6,7 @@ import {
OGDialogHeader,
OGDialogContent,
OGDialogDescription,
} from './';
} from './OriginalDialog';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils/';

View file

@ -33,6 +33,8 @@ export { default as ThemeSelector } from './ThemeSelector';
export { default as SelectDropDown } from './SelectDropDown';
export { default as MultiSelectPop } from './MultiSelectPop';
export { default as ModelParameters } from './ModelParameters';
export { default as OGDialogTemplate } from './OGDialogTemplate';
export { default as InputWithDropdown } from './InputWithDropDown';
export { default as SelectDropDownPop } from './SelectDropDownPop';
export { default as AnimatedSearchInput } from './AnimatedSearchInput';
export { default as MultiSelectDropDown } from './MultiSelectDropDown';

View file

@ -1,26 +1,18 @@
import {
Constants,
InfiniteCollections,
defaultAssistantsVersion,
ConversationListResponse,
} from 'librechat-data-provider';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { dataService, MutationKeys, QueryKeys, defaultOrderQuery } from 'librechat-data-provider';
import type * as t from 'librechat-data-provider';
import type { InfiniteData, UseMutationResult } from '@tanstack/react-query';
import type * as t from 'librechat-data-provider';
import { useConversationTagsQuery, useConversationsInfiniteQuery } from './queries';
import useUpdateTagsInConvo from '~/hooks/Conversations/useUpdateTagsInConvo';
import { updateConversationTag } from '~/utils/conversationTags';
import { normalizeData } from '~/utils/collection';
import {
useConversationTagsQuery,
useConversationsInfiniteQuery,
useSharedLinksInfiniteQuery,
} from './queries';
import {
logger,
/* Shared Links */
addSharedLink,
deleteSharedLink,
/* Conversations */
addConversation,
updateConvoFields,
@ -244,120 +236,126 @@ export const useArchiveConvoMutation = (options?: t.ArchiveConvoOptions) => {
};
export const useCreateSharedLinkMutation = (
options?: t.CreateSharedLinkOptions,
): UseMutationResult<t.TSharedLinkResponse, unknown, t.TSharedLinkRequest, unknown> => {
options?: t.MutationOptions<t.TCreateShareLinkRequest, { conversationId: string }>,
): UseMutationResult<t.TSharedLinkResponse, unknown, { conversationId: string }, unknown> => {
const queryClient = useQueryClient();
const { refetch } = useSharedLinksInfiniteQuery();
const { onSuccess, ..._options } = options || {};
return useMutation((payload: t.TSharedLinkRequest) => dataService.createSharedLink(payload), {
onSuccess: (_data, vars, context) => {
if (!vars.conversationId) {
return;
return useMutation(
({ conversationId }: { conversationId: string }) => {
if (!conversationId) {
throw new Error('Conversation ID is required');
}
const isPublic = vars.isPublic === true;
return dataService.createSharedLink(conversationId);
},
{
onSuccess: (_data: t.TSharedLinkResponse, vars, context) => {
queryClient.setQueryData([QueryKeys.sharedLinks, _data.conversationId], _data);
queryClient.setQueryData<t.SharedLinkListData>([QueryKeys.sharedLinks], (sharedLink) => {
if (!sharedLink) {
return sharedLink;
}
const pageSize = sharedLink.pages[0].pageSize as number;
return normalizeData(
// If the shared link is public, add it to the shared links cache list
isPublic ? addSharedLink(sharedLink, _data) : deleteSharedLink(sharedLink, _data.shareId),
InfiniteCollections.SHARED_LINKS,
pageSize,
);
});
queryClient.setQueryData([QueryKeys.sharedLinks, _data.shareId], _data);
if (!isPublic) {
const current = queryClient.getQueryData<t.ConversationData>([QueryKeys.sharedLinks]);
refetch({
refetchPage: (page, index) => index === ((current?.pages.length ?? 0) || 1) - 1,
});
}
onSuccess?.(_data, vars, context);
},
..._options,
});
},
);
};
export const useUpdateSharedLinkMutation = (
options?: t.UpdateSharedLinkOptions,
): UseMutationResult<t.TSharedLinkResponse, unknown, t.TSharedLinkRequest, unknown> => {
options?: t.MutationOptions<t.TUpdateShareLinkRequest, { shareId: string }>,
): UseMutationResult<t.TSharedLinkResponse, unknown, { shareId: string }, unknown> => {
const queryClient = useQueryClient();
const { refetch } = useSharedLinksInfiniteQuery();
const { onSuccess, ..._options } = options || {};
return useMutation((payload: t.TSharedLinkRequest) => dataService.updateSharedLink(payload), {
onSuccess: (_data, vars, context) => {
if (!vars.conversationId) {
return;
}
const isPublic = vars.isPublic === true;
queryClient.setQueryData<t.SharedLinkListData>([QueryKeys.sharedLinks], (sharedLink) => {
if (!sharedLink) {
return sharedLink;
}
return normalizeData(
// If the shared link is public, add it to the shared links cache list.
isPublic
? // Even if the SharedLink data exists in the database, it is not registered in the cache when isPublic is false.
// Therefore, when isPublic is true, use addSharedLink instead of updateSharedLink.
addSharedLink(sharedLink, _data)
: deleteSharedLink(sharedLink, _data.shareId),
InfiniteCollections.SHARED_LINKS,
sharedLink.pages[0].pageSize as number,
);
});
queryClient.setQueryData([QueryKeys.sharedLinks, _data.shareId], _data);
if (!isPublic) {
const current = queryClient.getQueryData<t.ConversationData>([QueryKeys.sharedLinks]);
refetch({
refetchPage: (page, index) => index === ((current?.pages.length ?? 0) || 1) - 1,
});
return useMutation(
({ shareId }) => {
if (!shareId) {
throw new Error('Share ID is required');
}
return dataService.updateSharedLink(shareId);
},
{
onSuccess: (_data: t.TSharedLinkResponse, vars, context) => {
queryClient.setQueryData([QueryKeys.sharedLinks, _data.conversationId], _data);
onSuccess?.(_data, vars, context);
},
..._options,
});
},
);
};
export const useDeleteSharedLinkMutation = (
options?: t.DeleteSharedLinkOptions,
): UseMutationResult<t.TDeleteSharedLinkResponse, unknown, { shareId: string }, unknown> => {
): UseMutationResult<
t.TDeleteSharedLinkResponse,
unknown,
{ shareId: string },
t.DeleteSharedLinkContext
> => {
const queryClient = useQueryClient();
const { refetch } = useSharedLinksInfiniteQuery();
const { onSuccess, ..._options } = options || {};
return useMutation(({ shareId }) => dataService.deleteSharedLink(shareId), {
onSuccess: (_data, vars, context) => {
if (!vars.shareId) {
return;
const { onSuccess } = options || {};
return useMutation((vars) => dataService.deleteSharedLink(vars.shareId), {
onMutate: async (vars) => {
await queryClient.cancelQueries({
queryKey: [QueryKeys.sharedLinks],
exact: false,
});
const previousQueries = new Map();
const queryKeys = queryClient.getQueryCache().findAll([QueryKeys.sharedLinks]);
queryKeys.forEach((query) => {
const previousData = queryClient.getQueryData(query.queryKey);
previousQueries.set(query.queryKey, previousData);
queryClient.setQueryData<t.SharedLinkQueryData>(query.queryKey, (old) => {
if (!old?.pages) {
return old;
}
queryClient.setQueryData([QueryKeys.sharedMessages, vars.shareId], null);
queryClient.setQueryData<t.SharedLinkListData>([QueryKeys.sharedLinks], (data) => {
if (!data) {
return data;
}
return normalizeData(
deleteSharedLink(data, vars.shareId),
InfiniteCollections.SHARED_LINKS,
data.pages[0].pageSize as number,
);
const updatedPages = old.pages.map((page) => ({
...page,
links: page.links.filter((link) => link.shareId !== vars.shareId),
}));
const nonEmptyPages = updatedPages.filter((page) => page.links.length > 0);
return {
...old,
pages: nonEmptyPages,
};
});
const current = queryClient.getQueryData<t.ConversationData>([QueryKeys.sharedLinks]);
refetch({
refetchPage: (page, index) => index === (current?.pages.length ?? 1) - 1,
});
onSuccess?.(_data, vars, context);
return { previousQueries };
},
onError: (_err, _vars, context) => {
if (context?.previousQueries) {
context.previousQueries.forEach((prevData: unknown, prevQueryKey: unknown) => {
queryClient.setQueryData(prevQueryKey as string[], prevData);
});
}
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: [QueryKeys.sharedLinks],
exact: false,
});
},
onSuccess: (data, variables) => {
if (onSuccess) {
onSuccess(data, variables);
}
queryClient.refetchQueries({
queryKey: [QueryKeys.sharedLinks],
exact: true,
});
},
..._options,
});
};
@ -575,9 +573,7 @@ export const useDuplicateConversationMutation = (
): UseMutationResult<t.TDuplicateConvoResponse, unknown, t.TDuplicateConvoRequest, unknown> => {
const queryClient = useQueryClient();
const { onSuccess, ..._options } = options ?? {};
return useMutation(
(payload: t.TDuplicateConvoRequest) => dataService.duplicateConversation(payload),
{
return useMutation((payload) => dataService.duplicateConversation(payload), {
onSuccess: (data, vars, context) => {
const originalId = vars.conversationId ?? '';
if (originalId.length === 0) {
@ -603,8 +599,7 @@ export const useDuplicateConversationMutation = (
onSuccess?.(data, vars, context);
},
..._options,
},
);
});
};
export const useForkConvoMutation = (

View file

@ -24,7 +24,7 @@ import type {
AssistantDocument,
TEndpointsConfig,
TCheckUserKeyResponse,
SharedLinkListParams,
SharedLinksListParams,
SharedLinksResponse,
} from 'librechat-data-provider';
import { findPageForConversation } from '~/utils';
@ -139,31 +139,29 @@ export const useConversationsInfiniteQuery = (
);
};
export const useSharedLinksInfiniteQuery = (
params?: SharedLinkListParams,
export const useSharedLinksQuery = (
params: SharedLinksListParams,
config?: UseInfiniteQueryOptions<SharedLinksResponse, unknown>,
) => {
return useInfiniteQuery<SharedLinksResponse, unknown>(
[QueryKeys.sharedLinks],
({ pageParam = '' }) =>
const { pageSize, isPublic, search, sortBy, sortDirection } = params;
return useInfiniteQuery<SharedLinksResponse>({
queryKey: [QueryKeys.sharedLinks, { pageSize, isPublic, search, sortBy, sortDirection }],
queryFn: ({ pageParam }) =>
dataService.listSharedLinks({
...params,
pageNumber: pageParam?.toString(),
isPublic: params?.isPublic || true,
cursor: pageParam?.toString(),
pageSize,
isPublic,
search,
sortBy,
sortDirection,
}),
{
getNextPageParam: (lastPage) => {
const currentPageNumber = Number(lastPage.pageNumber);
const totalPages = Number(lastPage.pages); // Convert totalPages to a number
// If the current page number is less than total pages, return the next page number
return currentPageNumber < totalPages ? currentPageNumber + 1 : undefined;
},
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
getNextPageParam: (lastPage) => lastPage?.nextCursor ?? undefined,
keepPreviousData: true,
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 30 * 60 * 1000, // 30 minutes
...config,
},
);
});
};
export const useConversationTagsQuery = (

View file

@ -1,4 +1,4 @@
import { useCallback } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import copy from 'copy-to-clipboard';
import { ContentTypes } from 'librechat-data-provider';
import type { TMessage } from 'librechat-data-provider';
@ -7,8 +7,20 @@ export default function useCopyToClipboard({
text,
content,
}: Partial<Pick<TMessage, 'text' | 'content'>>) {
const copyTimeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
return () => {
if (copyTimeoutRef.current) {
clearTimeout(copyTimeoutRef.current);
}
};
}, []);
const copyToClipboard = useCallback(
(setIsCopied: React.Dispatch<React.SetStateAction<boolean>>) => {
if (copyTimeoutRef.current) {
clearTimeout(copyTimeoutRef.current);
}
setIsCopied(true);
let messageText = text ?? '';
if (content) {
@ -22,7 +34,7 @@ export default function useCopyToClipboard({
}
copy(messageText, { format: 'text/plain' });
setTimeout(() => {
copyTimeoutRef.current = setTimeout(() => {
setIsCopied(false);
}, 3000);
},

View file

@ -52,7 +52,6 @@ export default {
com_ui_chats: 'الدردشات',
com_ui_share: 'مشاركة',
com_ui_copy_link: 'نسخ الرابط',
com_ui_update_link: 'رابط التحديث',
com_ui_create_link: 'إنشاء رابط',
com_ui_share_link_to_chat: 'شارك الرابط في الدردشة',
com_ui_share_error: 'حدث خطأ أثناء مشاركة رابط الدردشة',

View file

@ -293,7 +293,6 @@ export default {
com_ui_share_var: 'Compartilhar {0}',
com_ui_enter_var: 'Inserir {0}',
com_ui_copy_link: 'Copiar link',
com_ui_update_link: 'Atualizar link',
com_ui_create_link: 'Criar link',
com_ui_share_to_all_users: 'Compartilhar com todos os usuários',
com_ui_my_prompts: 'Meus Prompts',

View file

@ -260,7 +260,6 @@ export default {
com_ui_share: 'Teilen',
com_ui_share_var: '{0} teilen',
com_ui_copy_link: 'Link kopieren',
com_ui_update_link: 'Link aktualisieren',
com_ui_create_link: 'Link erstellen',
com_ui_share_to_all_users: 'Mit allen Benutzern teilen',
com_ui_my_prompts: 'Meine Prompts',

View file

@ -53,7 +53,7 @@ export default {
com_download_expired: '(download expired)',
com_download_expires: '(click here to download - expires {0})',
com_click_to_download: '(click here to download)',
com_files_number_selected: '{0} of {1} file(s) selected',
com_files_number_selected: '{0} of {1} items(s) selected',
com_sidepanel_select_assistant: 'Select an Assistant',
com_sidepanel_parameters: 'Parameters',
com_sidepanel_assistant_builder: 'Assistant Builder',
@ -354,7 +354,6 @@ export default {
com_ui_share_var: 'Share {0}',
com_ui_enter_var: 'Enter {0}',
com_ui_copy_link: 'Copy link',
com_ui_update_link: 'Update link',
com_ui_create_link: 'Create link',
com_ui_share_to_all_users: 'Share to all users',
com_ui_my_prompts: 'My Prompts',
@ -379,6 +378,8 @@ export default {
com_ui_share_error: 'There was an error sharing the chat link',
com_ui_share_retrieve_error: 'There was an error retrieving the shared links',
com_ui_share_delete_error: 'There was an error deleting the shared link',
com_ui_bulk_delete_error: 'Failed to delete shared links',
com_ui_bulk_delete_partial_error: 'Failed to delete {0} shared links',
com_ui_share_create_message: 'Your name and any messages you add after sharing stay private.',
com_ui_share_created_message:
'A shared link to your chat has been created. Manage previously shared chats at any time via Settings.',
@ -442,6 +443,14 @@ export default {
com_ui_add_multi_conversation: 'Add multi-conversation',
com_ui_duplicate_agent_confirm: 'Are you sure you want to duplicate this agent?',
com_ui_page: 'Page',
com_ui_refresh_link: 'Refresh link',
com_ui_show_qr: 'Show QR Code',
com_ui_hide_qr: 'Hide QR Code',
com_ui_title: 'Title',
com_ui_view_source: 'View source chat',
com_ui_shared_link_delete_success: 'Successfully deleted shared link',
com_ui_shared_link_bulk_delete_success: 'Successfully deleted shared links',
com_ui_search: 'Search',
com_auth_error_login:
'Unable to login with the information provided. Please check your credentials and try again.',
com_auth_error_login_rl:

View file

@ -142,7 +142,6 @@ export default {
com_ui_create: 'Crear',
com_ui_share: 'Compartir',
com_ui_copy_link: 'Copiar enlace',
com_ui_update_link: 'Actualizar enlace',
com_ui_create_link: 'Crear enlace',
com_ui_share_link_to_chat: 'Compartir enlace en el chat',
com_ui_share_error: 'Hubo un error al compartir el enlace del chat',

View file

@ -243,7 +243,6 @@ export default {
com_ui_share: 'Jaa',
com_ui_share_var: 'Jaa {0}',
com_ui_copy_link: 'Kopioi linkki',
com_ui_update_link: 'Päivitä linkki',
com_ui_create_link: 'Luo linkki',
com_ui_share_to_all_users: 'Jaa kaikille käyttäjille',
com_ui_my_prompts: 'Omat syötteet',

View file

@ -401,7 +401,6 @@ export default {
com_ui_copied: 'Copié !',
com_ui_copy_code: 'Copier le code',
com_ui_copy_link: 'Copier le lien',
com_ui_update_link: 'Mettre à jour le lien',
com_ui_create_link: 'Créer un lien',
com_nav_source_chat: 'Afficher la conversation source',
com_ui_date_today: 'Aujourd\'hui',

View file

@ -96,7 +96,6 @@ export default {
com_ui_create: 'צור',
com_ui_share: 'שתף',
com_ui_copy_link: 'העתק קישור',
com_ui_update_link: 'עדכן קישור',
com_ui_create_link: 'צור קישור',
com_ui_share_link_to_chat: 'שתף קישור בצ\'אט',
com_ui_share_error: 'אירעה שגיאה בעת שיתוף קישור הצ\'אט',

View file

@ -61,7 +61,6 @@ export default {
com_ui_chats: 'chat',
com_ui_share: 'Bagikan',
com_ui_copy_link: 'Salin tautan',
com_ui_update_link: 'Perbarui tautan',
com_ui_create_link: 'Buat tautan',
com_ui_share_link_to_chat: 'Bagikan tautan ke chat',
com_ui_share_error: 'Terjadi kesalahan saat membagikan tautan chat',

View file

@ -192,7 +192,6 @@ export default {
com_ui_create: 'Crea',
com_ui_share: 'Condividi',
com_ui_copy_link: 'Copia link',
com_ui_update_link: 'Aggiorna link',
com_ui_create_link: 'Crea link',
com_ui_share_link_to_chat: 'Condividi link a chat',
com_ui_share_error: 'Si è verificato un errore durante la condivisione del link della chat',

View file

@ -291,7 +291,6 @@ export default {
com_ui_share_var: '{0} を共有',
com_ui_enter_var: '{0} を入力',
com_ui_copy_link: 'リンクをコピー',
com_ui_update_link: 'リンクを更新する',
com_ui_create_link: 'リンクを作成する',
com_ui_share_to_all_users: '全ユーザーと共有',
com_ui_my_prompts: 'マイ プロンプト',

View file

@ -51,7 +51,6 @@ export default {
com_ui_chats: '채팅',
com_ui_share: '공유하기',
com_ui_copy_link: '링크 복사',
com_ui_update_link: '링크 업데이트',
com_ui_create_link: '링크 만들기',
com_ui_share_link_to_chat: '채팅으로 링크 공유하기',
com_ui_share_error: '채팅 링크를 공유하는 동안 오류가 발생했습니다',

View file

@ -55,7 +55,6 @@ export default {
com_ui_chats: 'chats',
com_ui_share: 'Delen',
com_ui_copy_link: 'Link kopiëren',
com_ui_update_link: 'Link bijwerken',
com_ui_create_link: 'Link aanmaken',
com_ui_share_link_to_chat: 'Deel link naar chat',
com_ui_share_error: 'Er is een fout opgetreden bij het delen van de chatlink',

View file

@ -32,7 +32,6 @@ export default {
'Wszystkie rozmowy z AI w jednym miejscu. Płatność za połączenie, a nie za miesiąc',
com_ui_share: 'Udostępnij',
com_ui_copy_link: 'Skopiuj link',
com_ui_update_link: 'Zaktualizuj link',
com_ui_create_link: 'Utwórz link',
com_ui_share_link_to_chat: 'Udostępnij link w czacie',
com_ui_share_error: 'Wystąpił błąd podczas udostępniania linku do czatu',

View file

@ -70,7 +70,6 @@ export default {
com_ui_connect: 'Подключить',
com_ui_share: 'Поделиться',
com_ui_copy_link: 'Скопировать ссылку',
com_ui_update_link: 'Обновить ссылку',
com_ui_create_link: 'Создать ссылку',
com_ui_share_link_to_chat: 'Поделиться ссылкой в чате',
com_ui_share_error: 'Произошла ошибка при попытке поделиться ссылкой на чат',

View file

@ -52,7 +52,6 @@ export default {
com_ui_chats: 'chattar',
com_ui_share: 'Dela',
com_ui_copy_link: 'Kopiera länk',
com_ui_update_link: 'Uppdatera länk',
com_ui_create_link: 'Skapa länk',
com_ui_share_link_to_chat: 'Dela länk till chatt',
com_ui_share_error: 'Ett fel uppstod vid delning av chattlänken',

View file

@ -221,7 +221,6 @@ export default {
com_ui_create: 'Oluştur',
com_ui_share: 'Paylaş',
com_ui_copy_link: 'Bağlantıyı kopyala',
com_ui_update_link: 'Bağlantıyı güncelle',
com_ui_create_link: 'Bağlantı oluştur',
com_ui_share_link_to_chat: 'Sohbete bağlantı paylaş',
com_ui_share_error: 'Sohbet bağlantısını paylaşırken bir hata oluştu',

View file

@ -54,7 +54,6 @@ export default {
com_ui_chats: 'cuộc trò chuyện',
com_ui_share: 'Chia sẻ',
com_ui_copy_link: 'Sao chép liên kết',
com_ui_update_link: 'Cập nhật liên kết',
com_ui_create_link: 'Tạo liên kết',
com_ui_share_link_to_chat: 'Chia sẻ liên kết đến cuộc trò chuyện',
com_ui_share_error: 'Có lỗi xảy ra khi chia sẻ liên kết trò chuyện',

View file

@ -278,7 +278,6 @@ export default {
com_ui_share_var: '共享 {0}',
com_ui_enter_var: '输入 {0}',
com_ui_copy_link: '复制链接',
com_ui_update_link: '更新链接',
com_ui_create_link: '创建链接',
com_ui_share_to_all_users: '共享给所有用户',
com_ui_my_prompts: '我的提示词',

View file

@ -51,7 +51,6 @@ export default {
com_ui_chats: '對話',
com_ui_share: '分享',
com_ui_copy_link: '複製連結',
com_ui_update_link: '更新連結',
com_ui_create_link: '建立連結',
com_ui_share_link_to_chat: '分享連結到聊天',
com_ui_share_error: '分享聊天連結時發生錯誤',

View file

@ -60,7 +60,8 @@ html {
--surface-tertiary: var(--gray-100);
--surface-tertiary-alt: var(--white);
--surface-dialog: var(--white);
--surface-submit: var(--green-500);
--surface-submit: var(--green-700);
--surface-submit-hover: var(--green-800);
--border-light: var(--gray-200);
--border-medium-alt: var(--gray-300);
--border-medium: var(--gray-300);
@ -114,7 +115,8 @@ html {
--surface-tertiary: var(--gray-700);
--surface-tertiary-alt: var(--gray-700);
--surface-dialog: var(--gray-850);
--surface-submit: var(--green-600);
--surface-submit: var(--green-700);
--surface-submit-hover: var(--green-800);
--border-light: var(--gray-700);
--border-medium-alt: var(--gray-600);
--border-medium: var(--gray-600);
@ -2412,3 +2414,42 @@ button.scroll-convo {
height: auto !important;
max-height: none !important;
}
/** AnimatedSearchInput style */
@keyframes gradient-x {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.animate-gradient-x {
background-size: 200% 200%;
animation: gradient-x 15s ease infinite;
}
.animate-pulse-subtle {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.animate-pulse-slow {
animation: pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeIn {
animation: fadeIn 0.5s ease-out forwards;
}
.scale-98 {
transform: scale(0.98);
}

View file

@ -11,7 +11,6 @@ export * from './textarea';
export * from './messages';
export * from './languages';
export * from './endpoints';
export * from './sharedLink';
export * from './localStorage';
export * from './promptGroups';
export { default as cn } from './cn';

View file

@ -1,955 +0,0 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
import type { SharedLinkListData } from 'librechat-data-provider';
const today = new Date();
today.setDate(today.getDate() - 3);
export const sharedLinkData: SharedLinkListData = {
pages: [
{
sharedLinks: [
{
conversationId: '7a327f49-0850-4741-b5da-35373e751256',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-03-04T04:31:04.897Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'b3c2e29b131c464182b483c4',
'6dc217152a134ac1826fc46c',
'483658114d104691b2501fbf',
'cfb8467cfd30438e8268cf92',
],
shareId: '62f850ad-a0d8-48a5-b439-2d1dbaba291c',
title: 'Test Shared Link 1',
updatedAt: '2024-04-11T11:10:42.329Z',
},
{
conversationId: '1777ad5f-5e53-4847-be49-86f66c649ac6',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-04-05T05:59:31.571Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'bc53fda136ba46fb965260b8',
'138b83d659c84250904feb53',
'1c750ffab31546bd85b81360',
'7db87f183e4d489fae0b5161',
'64ee2004479644b7b5ffd2ea',
'4dd2b9a0704c4ae79688292e',
'25394c2bb2ee40feaf67836f',
'838ed537d9054780a3d9f272',
'300728390f8c4021a6c066ca',
'ea30b637cb8f463192523919',
],
shareId: '1f43f69f-0562-4129-b181-3c37df0df43e',
title: 'Test Shared Link 2',
updatedAt: '2024-04-16T17:52:40.250Z',
},
{
conversationId: 'a9682067-a7c9-4375-8efb-6b8fa1c71def',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-03-03T08:23:35.147Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'bb4fe223548b480eae6d64af',
'420ef02293d0470b96980e7b',
'ae0ffbb27e13418fbd63e7c2',
'43df3ea55cfb4219b1630518',
'c4fb3be788404058a4c9780d',
'6ee6a5833b1d4849a95be890',
'0b8a3ecf5ca5449b9bdc0ed8',
'a3daed97f0e5432a8b6031c0',
'6a7d10c55c9a46cfbd08d6d2',
'216d40fa813a44059bd01ab6',
],
shareId: 'e84d2642-9b3a-4e20-b92a-11a37eebe33f',
title: 'Test Shared Link 3',
updatedAt: '2024-02-06T04:21:17.065Z',
},
{
conversationId: 'b61f9a0a-6d5d-4d0e-802b-4c1866428816',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-04-06T19:25:45.708Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: ['00aad718514044dda8e044ec', '8cb3b67fccd64b8c8ac0abbb'],
shareId: '9011e12a-b2fe-4003-9623-bf1b5f80396b',
title: 'Test Shared Link 4',
updatedAt: '2024-03-21T22:37:32.704Z',
},
{
conversationId: '4ac3fd9e-437b-4988-b870-29cacf28abef',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-04-03T15:45:11.220Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'6b05f825ca7747f294f2ac64',
'871ee06fb6a141879ca1cb25',
'47b05821c6134a3b9f21072e',
],
shareId: '51d3ab25-195e-47d0-a5e3-d0694ece776a',
title: 'Test Shared Link 5',
updatedAt: '2024-04-03T23:20:11.213Z',
},
{
conversationId: '6ed26de8-3310-4abb-b561-4bdae9400aac',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-03-22T19:12:14.995Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'ac2929efa82b4cd78aae02d6',
'4266450abc7b41a59887e99d',
'95df3c7c802c40e0b643bb96',
'f21038af46074e51a2c4bd87',
'3f064bc8589c435786a92bcb',
],
shareId: 'c3bc13ed-190a-4ffa-8a05-50f8dad3c83e',
title: 'Test Shared Link 6',
updatedAt: '2024-04-25T19:55:25.785Z',
},
{
conversationId: 'b3c0aaca-ee76-42a2-b53b-5e85baca2f91',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-03-04T00:37:12.929Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'5a44ebd0bf05418e98cc9e5d',
'88b93127aef74bfb94666ac1',
'bf654993c34743c9a5a1b76c',
'2514259bd702491e924da475',
'60dbbf91a6734aa081e082cd',
'11efabaa3a8f4df8bf85410b',
'3f5bbf38abdb42efa65a8740',
'5b9dd8246dde41ae9ebd57c4',
],
shareId: '871d41fe-fb8a-41d4-8460-8bb93fb8aa98',
title: 'Test Shared Link 7',
updatedAt: '2024-03-13T14:34:26.790Z',
},
{
conversationId: '2071122a-57cc-4f16-baa8-1e8af3e23522',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-01-24T03:22:58.012Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'8c94aad21caa45d6acb863c8',
'c10e4e0bfe554a94920093ba',
'2e4c2e2238f24f63b08440bc',
'05bacd00320342298f9f439f',
'c8b7750a7d8a4e2fbdc2630b',
'a84573fea668476a87207979',
'6ab15a1b96c24798b1bddd6f',
'b699d8e42324493eae95ca44',
],
shareId: 'f90f738a-b0ac-4dba-bb39-ad3d77919a21',
title: 'Test Shared Link 8',
updatedAt: '2024-01-22T11:09:51.834Z',
},
{
conversationId: 'ee06374d-4452-4fbe-a1c0-5dbc327638f9',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-03-03T19:24:21.281Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'297d0827c81a4da0a881561a',
'3131ef1b3c484542b0db1f92',
'e8879a50340c49449e970dbc',
'fe598327a93b4b0399055edd',
'acc7a2a24e204325befffbcd',
'6ec3c6450e124cbf808c8839',
'714e3443f62045aaaff17f93',
'014be593aaad41cab54a1c44',
],
shareId: '0fc91bab-083d-449f-add3-1e32146b6c4a',
title: 'Test Shared Link 9',
updatedAt: '2024-03-14T00:52:52.345Z',
},
{
conversationId: '0d2f6880-cacf-4f7b-930e-35881df1cdea',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-02-14T03:18:45.587Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: ['1d045c1cf37742a6a979e21b'],
shareId: 'd87deb62-b993-476c-b520-104b08fd7445',
title: 'Test Shared Link 10',
updatedAt: '2024-03-26T18:38:41.222Z',
},
{
conversationId: '1fe437fd-68f0-4e3e-81a9-ca9a0fa8220a',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-02-16T19:55:23.412Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'a28b5f27c95e4700bcd158dc',
'6e85f0a8b6ae4107a5819317',
'fa5b863c91224a0098aebd64',
'b73811a510e54acebe348371',
'f3f7f7d7b69a485da727f9c2',
'81d82df3098c4e359d29703f',
],
shareId: '704a1a9c-5366-4f55-b69e-670a374f4326',
title: 'Test Shared Link 11',
updatedAt: '2024-04-11T05:00:25.349Z',
},
{
conversationId: '50465c8e-102f-4f94-88c2-9cf607a6c336',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-02-05T21:57:52.289Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'a64886199ab641c29eb6fdaf',
'9c16497010354cf385d4cc1d',
'36cdeb4d1e4f45078edfe28a',
'a11f4ea78fa44f57bfc5bfc6',
'dea42fcfe7a544feb5debc26',
'ece0d630cd89420ca80ffe25',
'719165a5d80644ae8fae9498',
'f27111921a10470982f522b2',
'10b78255f7a24b6192e67693',
],
shareId: 'e47eaf30-c1ed-4cc2-b2b8-8cdec4b1ea2f',
title: 'Test Shared Link 12',
updatedAt: '2024-02-07T15:43:21.110Z',
},
{
conversationId: '1834f551-0a68-4bc7-a66a-21a234462d24',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-04-23T02:58:52.653Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'cb5d19d986194f779a6f47fd',
'72159d6668f347f99398aec9',
'cbe535213d664a6280d9a19e',
'8dccceadcb3a44148962ba47',
],
shareId: '976b55cb-d305-40f8-ae06-ae516f4e49f5',
title: 'Test Shared Link 13',
updatedAt: '2024-05-02T10:21:05.190Z',
},
{
conversationId: 'd8175b2f-f7c0-4f61-850d-f524bf8a84df',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-03-09T09:04:10.576Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'0f3708fc670d46998b1294d5',
'794520b9cee84c23bff01d5a',
'b05d2af2d37c426a970d8326',
'bd4239e379284d01acb9aaf4',
'e6265cfbbd88420781b27248',
'5262193aef7c426cafe2ee85',
'848569e2ca4843beaf64efc4',
'99f3b438241c4454a6784ac2',
'111d346fbeae4806bdf23490',
'fe4bde34e1a143f1a12fa628',
],
shareId: '928eb0a8-e0ea-470d-8b6a-92e0981d61b0',
title: 'Test Shared Link 14',
updatedAt: '2024-04-15T18:00:13.094Z',
},
{
conversationId: '281984c0-fed0-4428-8e50-c7c93cba4ae0',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-03-23T23:26:41.956Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'7e781fd08408426795c243e7',
'3c6d729fd3524a65a7b2a5e3',
'53bdbec6ee6148e78795d6e1',
'46f8170f28684ccc8ee56f33',
'3350d9aa7c814c89af6d3640',
],
shareId: '7a251af6-1ad3-4b24-830c-21b38124f325',
title: 'Test Shared Link 15',
updatedAt: '2024-03-18T16:33:35.498Z',
},
{
conversationId: '09610b11-6087-4d15-b163-e1bc958f2e82',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-02-05T20:00:36.159Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: ['6dce61720af24c70926efe87'],
shareId: '2b389d5e-eb24-4b29-a8e1-c0545cdfa1fc',
title: 'Test Shared Link 16',
updatedAt: '2024-02-23T05:49:50.020Z',
},
{
conversationId: '0c388322-905c-4c57-948c-1ba9614fdc2f',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-04-05T00:03:20.078Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'e3755ff2cf9f403c9d20901f',
'e32733b8da1440ec9d9dc2df',
'e2870d0361634d4f867e1e57',
'2e504afb8675434bb9f58cb5',
'ea38d76735c54f94bf378ed3',
'8712cda1bfc8480eba6c65aa',
'3f43a655706f4032a9e1efb4',
'3f890f8279f4436da2a7d767',
'4ca7616c04404391a7cfc94f',
'd3e176a831ff48e49debabce',
],
shareId: '2866400b-bcb9-43a4-8cbf-6597959f8c55',
title: 'Test Shared Link 17',
updatedAt: '2024-03-16T02:53:06.642Z',
},
{
conversationId: '5ac2b90a-63f8-4388-919b-40a1c1fea874',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-01-21T15:30:37.893Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'734a0427c6224fca87e2a89d',
'6af13387ddf0495d9c6ebad9',
'02a93d5659f343678b12b932',
'8af2f028c5114286a3339075',
'3a8bec13fc574fb9a9f938e2',
'6f4aa482286548b7b42668e6',
'c1d4f94a2eaf4e44b94c5834',
'442d9491b51d49fcab60366d',
'82a115a84b2a4457942ca6cf',
'152d8c2894a0454d9248c9f5',
],
shareId: 'e76f6a90-06f3-4846-8e3d-987d37af27b5',
title: 'Test Shared Link 18',
updatedAt: '2024-01-27T06:25:27.032Z',
},
{
conversationId: '01521fef-aa0b-4670-857d-f19bfc0ce664',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-03-01T21:46:40.674Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'222cf562d8e24b1b954395c2',
'c6f299f588c24905b771e623',
'f023f30fd4d9472c9bf60b84',
'e4929e3f14d748a18656f1be',
'a01f453fcb0a49b5b488a22c',
'4ceee6b365ab4386bacb4d27',
'c2cab81da0be4c6e97f11f92',
'644c32d10f2f4e2086d5e04d',
'5225d1286db14cc6a47fdea5',
'c821ebb220ae495b98f2e17f',
],
shareId: '1b2d8bf5-ff90-478a-bdf6-ea622fb4875a',
title: 'Test Shared Link 19',
updatedAt: '2024-02-25T15:52:56.189Z',
},
{
conversationId: '54f5d332-efc7-4062-9e1d-c70c3dbbc964',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-01-29T15:57:22.808Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'49771038e2dd4de0a28b19f2',
'0debd4ad13de4db9a65fe589',
'a9c8e6e34c34486ca27b7c88',
'd7b0ace0438146789e8b1899',
],
shareId: '4f5eea7d-b3a8-4b72-ad1e-a4d516c582c2',
title: 'Test Shared Link 20',
updatedAt: '2024-03-18T13:12:10.828Z',
},
{
conversationId: '99dabf25-46a5-43bb-8274-715c64e56032',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-03-05T03:35:11.327Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: ['965a55515727404eb70dec8f'],
shareId: '2360b7c1-20d7-46b9-919d-65576a899ab9',
title: 'Test Shared Link 21',
updatedAt: '2024-04-17T11:22:12.800Z',
},
{
conversationId: '1e2ffc1a-3546-460e-819c-689eb88940c6',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-04-22T08:40:32.663Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'131f4b03ad3d4e90803a203d',
'7f55262c554f4d97a8fef0ec',
'341e8fea28e241fc8b5a2398',
],
shareId: 'f3e370ed-420c-4579-a033-e18743b49485',
title: 'Test Shared Link 22',
updatedAt: '2024-04-07T22:06:07.162Z',
},
{
conversationId: '14510a0c-01cc-4bfb-8336-3388573ac4d8',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-03-08T08:20:28.619Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'022f87b1bf0d4e4688970daa',
'42519e8f3603496faae0969c',
'abc29ac88d66485aa11e4b58',
],
shareId: '0f46f1fd-95d3-4a6f-a5aa-ae5338dc5337',
title: 'Test Shared Link 23',
updatedAt: '2024-03-06T12:05:33.679Z',
},
{
conversationId: '2475594e-10dc-4d6a-aa58-5ce727a36271',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-03-04T07:43:46.952Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'5d0cd8bef4c241aba5d822a8',
'a19669a364d84ab5bbafbe0c',
'336686022ea6456b9a63879d',
'3323c9b85acc4ffba35aad04',
'bf15e8860a01474cb4744842',
'5a055eb825ed4173910fffd5',
'36a5e683ad144ec68c2a8ce0',
'8bc1d5590a594fa1afc18ee1',
'f86444b60bea437ba0d0ef8e',
'5be768788d984723aef5c9a0',
],
shareId: 'b742f35c-e6a3-4fa4-b35d-abab4528d7d6',
title: 'Test Shared Link 24',
updatedAt: '2024-03-27T15:31:10.930Z',
},
{
conversationId: 'ddb5a61c-82fe-4cc7-a2b0-c34b8c393b28',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-02-15T02:06:45.901Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'059d7ae5405a42af9c52171d',
'303efd2e676e4fe7aa9fa9d0',
'9f459c2e6a23411ea4a3e153',
'6036a3785adc4b7caa7ea22b',
'65251979d0c64d1f8821b3d9',
'25fdeb5ed99d42cca3041e08',
'61baa25e4e3d42a3aefd6c16',
'91dc4578fee749aeb352b5ea',
'd52daca5afb84e7890d5d9ad',
],
shareId: '13106e5f-1b5f-4ed4-963d-790e61c1f4c8',
title: 'Test Shared Link 25',
updatedAt: '2024-02-05T08:39:45.847Z',
},
{
conversationId: 'df09c89b-0b0d-429c-9c93-b5f4d51ef1ec',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-03-28T07:50:10.724Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'7f007af305ee4f2197c125d3',
'318b26abfe864dbf9f557bf9',
'0c4709b489ac4211b9f22874',
'8940f9ab45f44b56911819d5',
'b47ec3aa0cf7413fa446f19b',
'3857f85f492f4e11aa0ea377',
],
shareId: '31bbafa4-2051-4a20-883b-2f8557c46116',
title: 'Test Shared Link 26',
updatedAt: '2024-02-01T19:52:32.986Z',
},
{
conversationId: '856a4d54-54f7-483f-9b4e-7b798920be25',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-04-14T08:57:03.592Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'b5afc1f3569d44378bc7539d',
'e54804329577443d8685d3b1',
'7b10204ad48c464aac2b752a',
'8e96d562d33b4d6b85f2269e',
'cd844644f15d4dbdb5772a3b',
'91f5159278ca420c8a0097b2',
'5f8cf34736df4cca962635c1',
'96e2169ddcf5408fb793aeb6',
'988d96959afb4ec08cd3cec4',
'173398cdf05d4838aeb5ad9f',
],
shareId: '88c159a0-0273-4798-9d21-f95bd650bd30',
title: 'Test Shared Link 27',
updatedAt: '2024-05-08T20:07:46.345Z',
},
{
conversationId: '41ee3f3f-36a5-4139-993a-1c4d7d055ccb',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-02-26T10:08:29.943Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: ['883cc3286240405ba558f322', '7ca7809f471e481fa9944795'],
shareId: '97dc26aa-c909-4a9c-91be-b605d25b9cf3',
title: 'Test Shared Link 28',
updatedAt: '2024-04-06T17:36:05.767Z',
},
{
conversationId: '79e30f91-9b87-484c-8a12-6e4c6e8973d4',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-05-07T05:28:58.595Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'a8ac347785504b51bdad7ea7',
'ce85321aecf64355b0362f8c',
'21a462895f37474d8d6acdfd',
'095d9104011e4534bda93294',
'503b6e27677c457289366a8d',
'1738d52a60004c9ba6f0c9ec',
'a157fe44a67f4882a507941b',
'40e30dc275394eb4b9921db0',
'f4ed9f2fb08640fcbacaa6a7',
'bbac358328864dc2bfaa39da',
],
shareId: 'aa36fc45-2a73-4fa2-a500-2a9148fca67d',
title: 'Test Shared Link 29',
updatedAt: '2024-01-26T16:45:59.269Z',
},
{
conversationId: 'f5eaa000-3657-43d4-bc55-538108723b83',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-02-22T15:51:31.330Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'a87cfce565844b4ba9230dc5',
'426723bc4c22425e9bdf4b7b',
'73be5795469a444b8f1eca88',
'75a87212574a4cfc80d7d4e3',
'80f982dfc3e94535aed6e7d4',
'86d036c912c142ca8ec0f45a',
'e3435fbbd4d2443eba30e97d',
'e451e124aa964398b596af5d',
'1a13913f55e9442e8b5d7816',
],
shareId: 'fe0f7ea2-74d2-40ba-acb2-437e61fc3bef',
title: 'Test Shared Link 30',
updatedAt: '2024-02-27T13:29:04.060Z',
},
{
conversationId: 'a1ad92b4-6fac-44be-bad6-7648aeeba7af',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-04-10T09:32:22.242Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'5931cd94fbcd4fbcbaa20b91',
'0bc5f38ccc4f4b88afa42aed',
'7b4375d65f3f4524a79cb5f0',
'd2ce098360ce4d19b6961017',
'847f5ee8d2df49a0ba1fd8a7',
'6164a71770c745ea8142a37c',
'e98a0f1e15c846ac9b113608',
'5297d7df09b44d088cf80da5',
'62260b3f62ba423aa5c1962c',
'21fffc89d1d54e0190819384',
],
shareId: 'ee5ae35d-540d-4a01-a938-ee7ee97b15ce',
title: 'Test Shared Link 31',
updatedAt: '2024-02-26T03:37:24.862Z',
},
{
conversationId: '1e502d46-c710-4848-9bf2-674c08e51d9c',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-04-09T08:37:01.082Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'adb4bfb7657d4d7d92e82edf',
'70bdd81466e0408399b415d3',
'ef99511981dc4c3baa18d372',
],
shareId: 'b4fd8b63-7265-4825-89a4-9cebcbaadeee',
title: 'Test Shared Link 32',
updatedAt: '2024-02-27T04:32:40.654Z',
},
{
conversationId: 'd1a43c39-f05e-4c6e-a8c2-0fcca9cb8928',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-04-26T15:03:25.546Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'07c070ab8a7541fea96b131c',
'eb89cc57bcbb47ecb497cd5f',
'651999e46e734837b24c2500',
'608f9fbbbbb645e6b32d7d46',
],
shareId: '5a4cf7d0-0abb-48c1-8e70-9f4ee3220dc4',
title: 'Test Shared Link 33',
updatedAt: '2024-04-06T21:39:51.521Z',
},
{
conversationId: 'e549be2b-2623-42a3-8315-a8e35a7776b3',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-02-23T21:40:32.151Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'aa4e0b65650544589afb5961',
'160841178c0944e88de83956',
'234ac16af26d48a7875ee643',
],
shareId: 'b083f048-2803-407e-b54a-89261db87ade',
title: 'Test Shared Link 34',
updatedAt: '2024-03-14T12:16:32.984Z',
},
{
conversationId: '39f415ea-48f2-4bb2-b6f8-c2cf2d5fe42a',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-04-08T19:02:27.141Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'c77e79bb92b64d36a72d5a4d',
'ea236310a9ba4b27a2217f09',
'b25c46f2d23542f6b9d94de9',
],
shareId: 'a9871169-7012-4206-b35c-7d207309a0f5',
title: 'Test Shared Link 35',
updatedAt: '2024-04-21T04:00:58.151Z',
},
{
conversationId: 'c0d00265-12c4-45d0-a8bd-95d6e1bda769',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-03-14T09:50:55.476Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'63cdf78acd0449cf90237b29',
'b93d82d7612b49fc98f0c211',
'e56afe7e6e1e478d858a96d0',
'09344c8d22e74ce9b1d615cc',
],
shareId: 'aa1262ab-54c9-406a-a97f-e2636266cf3e',
title: 'Test Shared Link 36',
updatedAt: '2024-03-24T15:53:36.021Z',
},
{
conversationId: '5114b13d-8050-4e29-a2fd-85c776aec055',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-01-20T20:39:54.322Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'd397dc5c136a4c7da44e2fb9',
'a3cd29243629450b87852a85',
'9dd1e0e918844a37ba8dc955',
'ec2a73f7efe344fe85709c22',
'4d4702651869476b8ae397fd',
'8447430fd4f34aab82921018',
'8d804ee086734d6192b59995',
'29d6ccba37234bb8bd280977',
'31ec4f8c28cc4c21828ecef8',
'8ea630045b5847ec92651f4a',
],
shareId: '2021fcab-7000-4840-9a8c-f0a1cb1ce8fa',
title: 'Test Shared Link 37',
updatedAt: '2024-04-08T02:09:33.732Z',
},
{
conversationId: 'afa796fe-c8c1-411d-98d1-a8c8c8550412',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-01-16T23:58:11.179Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'8f54ee5871494f1b9f13f314',
'7778849398db40eb950952fb',
'65977e5d9e12445cb1cd9a54',
'8dba76884b09490a91b1aff9',
'2f6cc465171742b8a529daa3',
'1775b24fe2e94cd89dd6164e',
'780d980e59274240837c0bff',
],
shareId: '9bb78460-0a26-4df7-be54-99b904b8084a',
title: 'Test Shared Link 38',
updatedAt: '2024-04-22T00:33:47.525Z',
},
{
conversationId: 'c70fc447-acfc-4b57-84aa-2d8abcc3c5a5',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-04-24T11:39:14.696Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'b659ff86f9284ae1a40bee94',
'35bce7b6b2124db491f116c4',
'cf0bad6c2623413babb33e65',
'26c6ce4d46614c86941d5429',
'fba6517fc3434c188d8e1471',
'3e37398cc2ea4e50920d6271',
'fd8584b1cf8145c88697b89d',
'8e433df0ada34e2280d4bd91',
'fc52f80a6df24df5baccb657',
'95cdf9b05b8f4a81a70a37e9',
],
shareId: '0664b078-8f29-41ff-8c1c-85172c659195',
title: 'Test Shared Link 39',
updatedAt: '2024-03-29T10:22:50.815Z',
},
{
conversationId: '32ccaa36-cc46-4c84-888d-a86bf9b1d79c',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-02-04T03:13:19.399Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'8a0cfa8f5e874cf089f91b2e',
'e9a72907ac9b4e88a8cfa737',
'aa328aaf978944e18727a967',
'8786577a76b24415920d87a0',
'ee05127d35ec415a85554406',
],
shareId: 'a0018d28-52a8-4d31-8884-037cf9037eb7',
title: 'Test Shared Link 40',
updatedAt: '2024-01-30T03:26:15.920Z',
},
{
conversationId: '2d8f1f40-b0e8-4629-937a-dee5036cb0bb',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-03-18T15:32:59.697Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'64475ed4f6234326a1104ca2',
'db0db3ee92e14afaba6db75b',
'1f28a30501a94e3d896c261b',
'de2eb08823db401d8262d3f3',
'254c32efae97476b954d8dc4',
'dda42e4e74144cb69e395392',
'85bfe89de9e643fb8d5fa8ff',
'2f52e060a8b645928d0bf594',
],
shareId: '9740b59b-cd84-461d-9fd7-2e1903b844b2',
title: 'Test Shared Link 41',
updatedAt: '2024-04-23T15:48:54.690Z',
},
{
conversationId: '5180353f-23a9-48af-8ed0-b05983ef87d1',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-02-15T10:45:51.373Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'012b97e2df45475b93ad1e37',
'23d5042117a142f5a12762d5',
'8eb8cbca953d4ec18108f6d8',
'ba506914203442339cd81d25',
'88c3b40cd0ae43d2b670ee41',
'0dd8fe241f5c4ea88730652c',
'80e3d1d7c26c489c9c8741fe',
'317a47a138c6499db73679f0',
'6497260d6a174f799cb56fd5',
],
shareId: 'a6eaf23e-6e99-4e96-8222-82149c48803b',
title: 'Test Shared Link 42',
updatedAt: '2024-02-24T12:08:27.344Z',
},
{
conversationId: 'cf3f2919-1840-4f6a-b350-f73f02ba6e90',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-02-14T06:20:45.439Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'ba3b939f8a3443f99f37b296',
'b2039c988b3841c6b4ccb436',
'89ea6e1d4b3f440bb867d740',
'270210838a724aeb87e9bbe9',
'02dd6b2f185247d9888d5be1',
'6458fe13ee1c470ba33fb931',
],
shareId: '765042c0-144d-4f7b-9953-0553ed438717',
title: 'Test Shared Link 43',
updatedAt: '2024-04-11T05:23:05.750Z',
},
{
conversationId: '8efb71ee-7984-409a-b27c-aeb2650d78ba',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-01-28T16:41:04.100Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'cc60d9e2cbb7494481138833',
'1fb8d220b888475ba6c59fd3',
'5fd97817ab25451bb7ac22f5',
'9e8f7765a1bc4ab495da9081',
'4d5997d3c8744aaeb8c96964',
'd438acb0f7704201857d6916',
'b5106745d89f4a3fada8cd11',
'3b41562ce727411a83f44cdf',
'627f8f77feb843848145fc5f',
'6bee635eb10443ae9eef20ab',
],
shareId: 'ed0fe440-479d-4c79-a494-0f461612c474',
title: 'Test Shared Link 44',
updatedAt: '2024-04-15T12:41:00.324Z',
},
{
conversationId: '7cdd42a6-67bb-48c8-b8c3-bb55cbaa3905',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-04-24T23:13:42.892Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'a944f461ca094d1c80bea677',
'bd4b516b51a84285846343b4',
'442f6b4c27f647199279e49c',
'e672974b3cf74cd3b85537f9',
],
shareId: '9439972e-226c-4386-910a-e629eb7019c3',
title: 'Test Shared Link 45',
updatedAt: '2024-01-17T07:42:21.103Z',
},
{
conversationId: '595bab25-e5c1-4bd0-99c1-a099391adb87',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-04-13T05:58:33.171Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'c39942615fdf435cb22369b5',
'0ec24a7328424a78b7dcecaf',
'335373a769fd43a5833eac16',
'22905090a44f4bf8b6f415f8',
],
shareId: '18501e23-3fc5-436d-a9aa-ccde7c5c9074',
title: 'Test Shared Link 46',
updatedAt: '2024-02-05T04:34:42.323Z',
},
{
conversationId: '822a650b-2971-441a-9cb0-b2ecabf7b3ba',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-03-20T10:29:20.771Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'ed566d1ffd51494e9a069f32',
'3ca9a8bbfb7c43e49e4898d7',
'6534186966784f0fba45c1ab',
'8a9e394dda8542d4a4db1140',
'002d883a1c344de0beb794b3',
'61e9e872aa854288a4ac9694',
'11e465cb875746aaa5894327',
'ead6b00c855f4907ac5070af',
],
shareId: 'aaaf89e4-eb3d-45f8-9e24-f370d777d8f7',
title: 'Test Shared Link 47',
updatedAt: '2024-04-29T03:11:47.109Z',
},
{
conversationId: 'ce68ce26-07fc-4448-9239-f1925cfaaa72',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-03-15T15:04:08.691Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'a1851d231ee748e59ed43494',
'363372c828d8443b81abffd4',
'0b2e97210bd14e229ddb6641',
],
shareId: 'f4de7c43-c058-43f5-bdab-0854d939dfb9',
title: 'Test Shared Link 48',
updatedAt: '2024-03-05T11:43:00.177Z',
},
{
conversationId: '3fafe417-b5f8-4cc8-ac8e-897ebef836bd',
user: '662dbe728ca96f444c6f69f4',
createdAt: '2024-04-20T05:34:57.880Z',
isAnonymous: true,
isPublic: true,
isVisible: true,
messages: [
'876337c495ca40c080b65c1d',
'b5e914ac15ff439a9836a9ea',
'cb6379d0a9ad442291d78c14',
'529424b650a4478ba012cf40',
'99ff1ed49cb2483bbd970730',
'0f0e215e179f4cfba56c7b03',
'210940fbe4c745d183358ed1',
'99246c796c7a44c2ae85a549',
'a2b967556867499eb437674a',
],
shareId: '79ec3716-ea2e-4045-8a82-056d63ebc939',
title: 'Test Shared Link 49',
updatedAt: '2024-03-19T08:01:13.445Z',
},
],
pages: 49,
pageNumber: '1',
pageSize: 25,
},
],
pageParams: [null],
};

View file

@ -1,149 +0,0 @@
import { sharedLinkData } from './sharedLink.fakeData';
import { addSharedLink, updateSharedLink, deleteSharedLink } from './sharedLink';
import type { TSharedLink, SharedLinkListData } from 'librechat-data-provider';
describe('Shared Link Utilities', () => {
describe('addSharedLink', () => {
it('adds a new shared link to the top of the list', () => {
const data = { pages: [{ sharedLinks: [] }] };
const newSharedLink = { shareId: 'new', updatedAt: '2023-04-02T12:00:00Z' };
const newData = addSharedLink(
data as unknown as SharedLinkListData,
newSharedLink as TSharedLink,
);
expect(newData.pages[0].sharedLinks).toHaveLength(1);
expect(newData.pages[0].sharedLinks[0].shareId).toBe('new');
});
it('does not add a shared link but updates it if it already exists', () => {
const data = {
pages: [
{
sharedLinks: [
{ shareId: '1', updatedAt: '2023-04-01T12:00:00Z' },
{ shareId: '2', updatedAt: '2023-04-01T13:00:00Z' },
],
},
],
};
const newSharedLink = { shareId: '2', updatedAt: '2023-04-02T12:00:00Z' };
const newData = addSharedLink(
data as unknown as SharedLinkListData,
newSharedLink as TSharedLink,
);
expect(newData.pages[0].sharedLinks).toHaveLength(2);
expect(newData.pages[0].sharedLinks[0].shareId).toBe('2');
});
});
describe('updateSharedLink', () => {
it('updates an existing shared link and moves it to the top', () => {
const initialData = {
pages: [
{
sharedLinks: [
{ shareId: '1', updatedAt: '2023-04-01T12:00:00Z' },
{ shareId: '2', updatedAt: '2023-04-01T13:00:00Z' },
],
},
],
};
const updatedSharedLink = { shareId: '1', updatedAt: '2023-04-02T12:00:00Z' };
const newData = updateSharedLink(
initialData as unknown as SharedLinkListData,
updatedSharedLink as TSharedLink,
);
expect(newData.pages[0].sharedLinks).toHaveLength(2);
expect(newData.pages[0].sharedLinks[0].shareId).toBe('1');
});
it('does not update a shared link if it does not exist', () => {
const initialData = {
pages: [
{
sharedLinks: [
{ shareId: '1', updatedAt: '2023-04-01T12:00:00Z' },
{ shareId: '2', updatedAt: '2023-04-01T13:00:00Z' },
],
},
],
};
const updatedSharedLink = { shareId: '3', updatedAt: '2023-04-02T12:00:00Z' };
const newData = updateSharedLink(
initialData as unknown as SharedLinkListData,
updatedSharedLink as TSharedLink,
);
expect(newData.pages[0].sharedLinks).toHaveLength(2);
expect(newData.pages[0].sharedLinks[0].shareId).toBe('1');
});
});
describe('deleteSharedLink', () => {
it('removes a shared link by id', () => {
const initialData = {
pages: [
{
sharedLinks: [
{ shareId: '1', updatedAt: '2023-04-01T12:00:00Z' },
{ shareId: '2', updatedAt: '2023-04-01T13:00:00Z' },
],
},
],
};
const newData = deleteSharedLink(initialData as unknown as SharedLinkListData, '1');
expect(newData.pages[0].sharedLinks).toHaveLength(1);
expect(newData.pages[0].sharedLinks[0].shareId).not.toBe('1');
});
it('does not remove a shared link if it does not exist', () => {
const initialData = {
pages: [
{
sharedLinks: [
{ shareId: '1', updatedAt: '2023-04-01T12:00:00Z' },
{ shareId: '2', updatedAt: '2023-04-01T13:00:00Z' },
],
},
],
};
const newData = deleteSharedLink(initialData as unknown as SharedLinkListData, '3');
expect(newData.pages[0].sharedLinks).toHaveLength(2);
});
});
});
describe('Shared Link Utilities with Fake Data', () => {
describe('addSharedLink', () => {
it('adds a new shared link to the existing fake data', () => {
const newSharedLink = {
shareId: 'new',
updatedAt: new Date().toISOString(),
} as TSharedLink;
const initialLength = sharedLinkData.pages[0].sharedLinks.length;
const newData = addSharedLink(sharedLinkData, newSharedLink);
expect(newData.pages[0].sharedLinks.length).toBe(initialLength + 1);
expect(newData.pages[0].sharedLinks[0].shareId).toBe('new');
});
});
describe('updateSharedLink', () => {
it('updates an existing shared link within fake data', () => {
const updatedSharedLink = {
...sharedLinkData.pages[0].sharedLinks[0],
title: 'Updated Title',
};
const newData = updateSharedLink(sharedLinkData, updatedSharedLink);
expect(newData.pages[0].sharedLinks[0].title).toBe('Updated Title');
});
});
describe('deleteSharedLink', () => {
it('removes a shared link by id from fake data', () => {
const shareIdToDelete = sharedLinkData.pages[0].sharedLinks[0].shareId as string;
const newData = deleteSharedLink(sharedLinkData, shareIdToDelete);
const deletedDataExists = newData.pages[0].sharedLinks.some(
(c) => c.shareId === shareIdToDelete,
);
expect(deletedDataExists).toBe(false);
});
});
});

View file

@ -1,36 +0,0 @@
import { InfiniteCollections } from 'librechat-data-provider';
import { SharedLinkListData, SharedLinkListResponse, TSharedLink } from 'librechat-data-provider';
import { addData, deleteData, updateData } from './collection';
import { InfiniteData } from '@tanstack/react-query';
export const addSharedLink = (
data: InfiniteData<SharedLinkListResponse>,
newSharedLink: TSharedLink,
): SharedLinkListData => {
return addData<SharedLinkListResponse, TSharedLink>(
data,
InfiniteCollections.SHARED_LINKS,
newSharedLink,
(page) => page.sharedLinks.findIndex((c) => c.shareId === newSharedLink.shareId),
);
};
export const updateSharedLink = (
data: InfiniteData<SharedLinkListResponse>,
newSharedLink: TSharedLink,
): SharedLinkListData => {
return updateData<SharedLinkListResponse, TSharedLink>(
data,
InfiniteCollections.SHARED_LINKS,
newSharedLink,
(page) => page.sharedLinks.findIndex((c) => c.shareId === newSharedLink.shareId),
);
};
export const deleteSharedLink = (data: SharedLinkListData, shareId: string): SharedLinkListData => {
return deleteData<SharedLinkListResponse, SharedLinkListData>(
data,
InfiniteCollections.SHARED_LINKS,
(page) => page.sharedLinks.findIndex((c) => c.shareId === shareId),
);
};

View file

@ -29,6 +29,7 @@ module.exports = {
},
},
animation: {
'fade-in': 'fadeIn 0.5s ease-out forwards',
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
@ -83,6 +84,7 @@ module.exports = {
'surface-tertiary-alt': 'var(--surface-tertiary-alt)',
'surface-dialog': 'var(--surface-dialog)',
'surface-submit': 'var(--surface-submit)',
'surface-submit-hover': 'var(--surface-submit-hover)',
'border-light': 'var(--border-light)',
'border-medium': 'var(--border-medium)',
'border-medium-alt': 'var(--border-medium-alt)',

9
package-lock.json generated
View file

@ -933,6 +933,7 @@
"lucide-react": "^0.394.0",
"match-sorter": "^6.3.4",
"msedge-tts": "^1.3.4",
"qrcode.react": "^4.2.0",
"rc-input-number": "^7.4.2",
"react": "^18.2.0",
"react-avatar-editor": "^13.0.2",
@ -30206,6 +30207,14 @@
}
]
},
"node_modules/qrcode.react": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/qs": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",

View file

@ -14,10 +14,20 @@ export const messages = (conversationId: string, messageId?: string) =>
const shareRoot = '/api/share';
export const shareMessages = (shareId: string) => `${shareRoot}/${shareId}`;
export const getSharedLinks = (pageNumber: string, isPublic: boolean) =>
`${shareRoot}?pageNumber=${pageNumber}&isPublic=${isPublic}`;
export const createSharedLink = shareRoot;
export const updateSharedLink = shareRoot;
export const getSharedLink = (conversationId: string) => `${shareRoot}/link/${conversationId}`;
export const getSharedLinks = (
pageSize: number,
isPublic: boolean,
sortBy: 'title' | 'createdAt',
sortDirection: 'asc' | 'desc',
search?: string,
cursor?: string,
) =>
`${shareRoot}?pageSize=${pageSize}&isPublic=${isPublic}&sortBy=${sortBy}&sortDirection=${sortDirection}${
search ? `&search=${search}` : ''
}${cursor ? `&cursor=${cursor}` : ''}`;
export const createSharedLink = (conversationId: string) => `${shareRoot}/${conversationId}`;
export const updateSharedLink = (shareId: string) => `${shareRoot}/${shareId}`;
const keysEndpoint = '/api/keys';

View file

@ -41,27 +41,29 @@ export function getSharedMessages(shareId: string): Promise<t.TSharedMessagesRes
return request.get(endpoints.shareMessages(shareId));
}
export const listSharedLinks = (
params?: q.SharedLinkListParams,
export const listSharedLinks = async (
params: q.SharedLinksListParams,
): Promise<q.SharedLinksResponse> => {
const pageNumber = (params?.pageNumber ?? '1') || '1'; // Default to page 1 if not provided
const isPublic = params?.isPublic ?? true; // Default to true if not provided
return request.get(endpoints.getSharedLinks(pageNumber, isPublic));
const { pageSize, isPublic, sortBy, sortDirection, search, cursor } = params;
return request.get(
endpoints.getSharedLinks(pageSize, isPublic, sortBy, sortDirection, search, cursor),
);
};
export function getSharedLink(shareId: string): Promise<t.TSharedLinkResponse> {
return request.get(endpoints.shareMessages(shareId));
export function getSharedLink(conversationId: string): Promise<t.TSharedLinkGetResponse> {
return request.get(endpoints.getSharedLink(conversationId));
}
export function createSharedLink(payload: t.TSharedLinkRequest): Promise<t.TSharedLinkResponse> {
return request.post(endpoints.createSharedLink, payload);
export function createSharedLink(conversationId: string): Promise<t.TSharedLinkResponse> {
return request.post(endpoints.createSharedLink(conversationId));
}
export function updateSharedLink(payload: t.TSharedLinkRequest): Promise<t.TSharedLinkResponse> {
return request.patch(endpoints.updateSharedLink, payload);
export function updateSharedLink(shareId: string): Promise<t.TSharedLinkResponse> {
return request.patch(endpoints.updateSharedLink(shareId));
}
export function deleteSharedLink(shareId: string): Promise<t.TDeleteSharedLinkResponse> {
export function deleteSharedLink(shareId: string): Promise<m.TDeleteSharedLinkResponse> {
return request.delete(endpoints.shareMessages(shareId));
}

View file

@ -75,6 +75,29 @@ export const useGetSharedMessages = (
);
};
export const useGetSharedLinkQuery = (
conversationId: string,
config?: UseQueryOptions<t.TSharedLinkGetResponse>,
): QueryObserverResult<t.TSharedLinkGetResponse> => {
const queryClient = useQueryClient();
return useQuery<t.TSharedLinkGetResponse>(
[QueryKeys.sharedLinks, conversationId],
() => dataService.getSharedLink(conversationId),
{
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
onSuccess: (data) => {
queryClient.setQueryData([QueryKeys.sharedLinks, conversationId], {
conversationId: data.conversationId,
shareId: data.shareId,
});
},
...config,
},
);
};
export const useGetUserBalance = (
config?: UseQueryOptions<string>,
): QueryObserverResult<string> => {
@ -306,7 +329,9 @@ export const useRegisterUserMutation = (
options?: m.RegistrationOptions,
): UseMutationResult<t.TError, unknown, t.TRegisterUser, unknown> => {
const queryClient = useQueryClient();
return useMutation((payload: t.TRegisterUser) => dataService.register(payload), {
return useMutation<t.TRegisterUserResponse, t.TError, t.TRegisterUser>(
(payload: t.TRegisterUser) => dataService.register(payload),
{
...options,
onSuccess: (...args) => {
queryClient.invalidateQueries([QueryKeys.user]);
@ -314,7 +339,8 @@ export const useRegisterUserMutation = (
options.onSuccess(...args);
}
},
});
},
);
};
export const useRefreshTokenMutation = (): UseMutationResult<

View file

@ -719,13 +719,12 @@ export const tSharedLinkSchema = z.object({
conversationId: z.string(),
shareId: z.string(),
messages: z.array(z.string()),
isAnonymous: z.boolean(),
isPublic: z.boolean(),
isVisible: z.boolean(),
title: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
});
export type TSharedLink = z.infer<typeof tSharedLinkSchema>;
export const tConversationTagSchema = z.object({

View file

@ -170,15 +170,17 @@ export type TArchiveConversationResponse = TConversation;
export type TSharedMessagesResponse = Omit<TSharedLink, 'messages'> & {
messages: TMessage[];
};
export type TSharedLinkRequest = Partial<
Omit<TSharedLink, 'messages' | 'createdAt' | 'updatedAt'>
> & {
conversationId: string;
};
export type TSharedLinkResponse = TSharedLink;
export type TSharedLinksResponse = TSharedLink[];
export type TDeleteSharedLinkResponse = TSharedLink;
export type TCreateShareLinkRequest = Pick<TConversation, 'conversationId'>;
export type TUpdateShareLinkRequest = Pick<TSharedLink, 'shareId'>;
export type TSharedLinkResponse = Pick<TSharedLink, 'shareId'> &
Pick<TConversation, 'conversationId'>;
export type TSharedLinkGetResponse = TSharedLinkResponse & {
success: boolean;
};
// type for getting conversation tags
export type TConversationTagsResponse = TConversationTag[];
@ -203,12 +205,10 @@ export type TDuplicateConvoRequest = {
conversationId?: string;
};
export type TDuplicateConvoResponse =
| {
export type TDuplicateConvoResponse = {
conversation: TConversation;
messages: TMessage[];
}
| undefined;
};
export type TForkConvoRequest = {
messageId: string;

View file

@ -24,6 +24,12 @@ export type MutationOptions<
onSuccess?: (data: Response, variables: Request, context?: Context) => void;
onMutate?: (variables: Request) => Snapshot | Promise<Snapshot>;
onError?: (error: Error, variables: Request, context?: Context, snapshot?: Snapshot) => void;
onSettled?: (
data: Response | undefined,
error: Error | null,
variables: Request,
context?: Context,
) => void;
};
export type TGenTitleRequest = {
@ -186,7 +192,12 @@ export type ArchiveConvoOptions = MutationOptions<
types.TArchiveConversationRequest
>;
export type DeleteSharedLinkOptions = MutationOptions<types.TSharedLink, { shareId: string }>;
export type DeleteSharedLinkContext = { previousQueries?: Map<string, TDeleteSharedLinkResponse> };
export type DeleteSharedLinkOptions = MutationOptions<
TDeleteSharedLinkResponse,
{ shareId: string },
DeleteSharedLinkContext
>;
export type TUpdatePromptContext =
| {
@ -298,3 +309,9 @@ export type ToolCallMutationOptions<T extends ToolId> = MutationOptions<
ToolCallResponse,
ToolParams<T>
>;
export type TDeleteSharedLinkResponse = {
success: boolean;
shareId: string;
message: string;
};

View file

@ -41,23 +41,34 @@ export type ConversationUpdater = (
export type SharedMessagesResponse = Omit<s.TSharedLink, 'messages'> & {
messages: s.TMessage[];
};
export type SharedLinkListParams = Omit<ConversationListParams, 'isArchived' | 'conversationId'> & {
isPublic?: boolean;
export interface SharedLinksListParams {
pageSize: number;
isPublic: boolean;
sortBy: 'title' | 'createdAt';
sortDirection: 'asc' | 'desc';
search?: string;
cursor?: string;
}
export type SharedLinkItem = {
shareId: string;
title: string;
isPublic: boolean;
createdAt: Date;
conversationId: string;
};
export type SharedLinksResponse = Omit<ConversationListResponse, 'conversations' | 'messages'> & {
sharedLinks: s.TSharedLink[];
};
export interface SharedLinksResponse {
links: SharedLinkItem[];
nextCursor: string | null;
hasNextPage: boolean;
}
// Type for the response from the conversation list API
export type SharedLinkListResponse = {
sharedLinks: s.TSharedLink[];
pageNumber: string;
pageSize: string | number;
pages: string | number;
};
export type SharedLinkListData = InfiniteData<SharedLinkListResponse>;
export interface SharedLinkQueryData {
pages: SharedLinksResponse[];
pageParams: (string | null)[];
}
export type AllPromptGroupsFilterRequest = {
category: string;