Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🚀 feat: Shared Links #2772

Merged
merged 47 commits into from
May 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
d034a9d
✨ feat(types): add necessary types for shared link feature
ohneda May 9, 2024
6163e9a
✨ feat: add shared links functions to data service
ohneda May 9, 2024
043cd14
✨ feat: Add useGetSharedMessages hook to fetch shared messages by sha…
ohneda May 9, 2024
737610d
✨ feat: Add share schema and data access functions to API models
ohneda May 9, 2024
17efd23
✨ feat: Add share endpoint to API
ohneda May 9, 2024
bfd83af
♻️ refactor(utils): generalize react-query cache manipulation functions
ohneda May 9, 2024
c8773db
✨ feat(shared-link): add functions to manipulate shared link cache list
ohneda May 9, 2024
a60ef1d
✨ feat: Add mutations and queries for shared links
ohneda May 9, 2024
a3b863f
✨ feat(shared-link): add `Share` button to conversation list
ohneda May 10, 2024
735072c
♻️ refactor(hooks): generalize useNavScrolling for broader use
ohneda May 10, 2024
43b35d1
✨ feat(settings): add shared links listing table with delete function…
ohneda May 10, 2024
929fd6d
♻️ refactor(components): separate `EndpointIcon` from `Icon` componen…
ohneda May 10, 2024
c1d820e
♻️ refactor: update useGetSharedMessages to return TSharedLink
ohneda May 10, 2024
8ab12e3
✨ feat(shared link): add UI for displaying shared conversations witho…
ohneda May 10, 2024
1899aee
🔧 chore: Add translations
ohneda May 10, 2024
fc9243c
Merge branch 'main' of github.com:danny-avila/LibreChat into feature/…
ohneda May 10, 2024
d6ae157
Merge branch 'main' of github.com:danny-avila/LibreChat into feature/…
ohneda May 10, 2024
49e3c2f
♻️ refactor: add icon and tooltip props to EditMenuButton component
ohneda May 14, 2024
000541a
♻️irefactor: added DropdownMenu for Export and Share
ohneda May 14, 2024
81003aa
Merge branch 'main' of github.com:danny-avila/LibreChat into feature/…
ohneda May 14, 2024
a021a62
♻️ refactor: renamed component names more intuitive
ohneda May 14, 2024
3fa7089
🌍 chore: updated translations
ohneda May 14, 2024
6cd5655
Merge branch 'main' of github.com:danny-avila/LibreChat into feature/…
ohneda May 14, 2024
d77244e
Merge branch 'main' into feature/shared-link
ohneda May 14, 2024
1f65222
Merge branch 'main' into feature/shared-link
ohneda May 16, 2024
decc903
Merge branch 'main' into feature/shared-link
ohneda May 16, 2024
7af234e
🐞 Fix: OpenID Profile Image Download (#2757)
bsu3338 May 17, 2024
280a16b
🚑 fix(export): Issue exporting Conversation with Assistants (#2769)
danny-avila May 17, 2024
07d2322
📤style: export button icon (#2752)
fuegovic May 17, 2024
dca336b
refactor(ShareDialog): logic and styling
danny-avila May 17, 2024
7b43f08
refactor(ExportAndShareMenu): imports order and icon update
danny-avila May 17, 2024
6106d14
chore: imports
danny-avila May 17, 2024
a89b70b
chore: imports/render logic
danny-avila May 17, 2024
a25004d
feat: message branching
danny-avila May 17, 2024
c5b0958
refactor: add optional config to useGetStartupConfig
danny-avila May 17, 2024
bc9703a
refactor: disable endpoints query
danny-avila May 17, 2024
8b31019
chore: fix search view styling gradient in light mode
danny-avila May 17, 2024
731f5ac
style: ShareView gradient styling
danny-avila May 17, 2024
0bb6365
refactor(Share): use select queries
danny-avila May 17, 2024
70ab33c
style: shared link table buttons
danny-avila May 17, 2024
da8e826
Merge branch 'main' into ohneda-feature/shared-link
danny-avila May 17, 2024
77c80b2
localization and dark text styling
danny-avila May 17, 2024
94d35b5
style: fix clipboard button layout shift app-wide and add localizatio…
danny-avila May 17, 2024
6df649b
support assistants message content in shared links, add useCopyToClip…
danny-avila May 17, 2024
eb38eb1
add localizations
danny-avila May 17, 2024
0bc38e1
comparisons
danny-avila May 17, 2024
3b93713
Merge branch 'ohneda-feature/shared-link' of https://github.com/danny…
danny-avila May 17, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions api/models/Share.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
const crypto = require('crypto');
const { getMessages } = require('./Message');
const SharedLink = require('./schema/shareSchema');
const logger = require('~/config/winston');

module.exports = {
SharedLink,
getSharedMessages: async (shareId) => {
try {
const share = await SharedLink.findOne({ shareId })
.populate({
path: 'messages',
select: '-_id -__v -user',
})
.select('-_id -__v -user')
.lean();

if (!share || !share.conversationId || !share.isPublic) {
return null;
}

return share;
} catch (error) {
logger.error('[getShare] Error getting share link', error);
return { message: 'Error getting share link' };
}
},

getSharedLinks: async (user, pageNumber = 1, pageSize = 25, isPublic = true) => {
const query = { user, isPublic };
try {
const totalConvos = (await SharedLink.countDocuments(query)) || 1;
const totalPages = Math.ceil(totalConvos / pageSize);
const shares = await SharedLink.find(query)
.sort({ updatedAt: -1 })
.skip((pageNumber - 1) * pageSize)
.limit(pageSize)
.select('-_id -__v -user')
.lean();

return { sharedLinks: shares, pages: totalPages, pageNumber, pageSize };
} catch (error) {
logger.error('[getShareByPage] Error getting shares', error);
return { message: 'Error getting shares' };
}
},

createSharedLink: async (user, { conversationId, ...shareData }) => {
const share = await SharedLink.findOne({ conversationId }).select('-_id -__v -user').lean();
if (share) {
return share;
}

try {
const shareId = crypto.randomUUID();
const messages = await getMessages({ conversationId });
const update = { ...shareData, shareId, messages, user };
return await SharedLink.findOneAndUpdate({ conversationId: conversationId, user }, update, {
new: true,
upsert: true,
});
} catch (error) {
logger.error('[saveShareMessage] Error saving conversation', error);
return { message: 'Error saving conversation' };
}
},

updateSharedLink: async (user, { conversationId, ...shareData }) => {
const share = await SharedLink.findOne({ conversationId }).select('-_id -__v -user').lean();
if (!share) {
return { message: 'Share not found' };
}
// update messages to the latest
const messages = await getMessages({ conversationId });
const update = { ...shareData, messages, user };
return await SharedLink.findOneAndUpdate({ conversationId: conversationId, user }, update, {
new: true,
upsert: false,
});
},

deleteSharedLink: async (user, { shareId }) => {
const share = await SharedLink.findOne({ shareId, user });
if (!share) {
return { message: 'Share not found' };
}
return await SharedLink.findOneAndDelete({ shareId, user });
},
};
38 changes: 38 additions & 0 deletions api/models/schema/shareSchema.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
const mongoose = require('mongoose');

const shareSchema = mongoose.Schema(
{
conversationId: {
type: String,
required: true,
},
title: {
type: String,
index: true,
},
user: {
type: String,
index: true,
},
messages: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Message' }],
shareId: {
type: String,
index: true,
},
isPublic: {
type: Boolean,
default: false,
},
isVisible: {
type: Boolean,
default: false,
},
isAnonymous: {
type: Boolean,
default: true,
},
},
{ timestamps: true },
);

module.exports = mongoose.model('SharedLink', shareSchema);
1 change: 1 addition & 0 deletions api/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ const startServer = async () => {
app.use('/api/assistants', routes.assistants);
app.use('/api/files', await routes.files.initialize());
app.use('/images/', validateImageRequest, routes.staticRoute);
app.use('/api/share', routes.share);

app.use((req, res) => {
res.status(404).sendFile(path.join(app.locals.paths.dist, 'index.html'));
Expand Down
2 changes: 2 additions & 0 deletions api/server/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const config = require('./config');
const assistants = require('./assistants');
const files = require('./files');
const staticRoute = require('./static');
const share = require('./share');

module.exports = {
search,
Expand All @@ -40,4 +41,5 @@ module.exports = {
assistants,
files,
staticRoute,
share,
};
75 changes: 75 additions & 0 deletions api/server/routes/share.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
const express = require('express');

const {
getSharedMessages,
createSharedLink,
updateSharedLink,
getSharedLinks,
deleteSharedLink,
} = require('~/models/Share');
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
const router = express.Router();

/**
* Shared messages
* this route does not require authentication
*/
router.get('/:shareId', async (req, res) => {
const share = await getSharedMessages(req.params.shareId);

if (share) {
res.status(200).json(share);
} else {
res.status(404).end();
}
});

/**
* Shared links
*/
router.get('/', requireJwtAuth, async (req, res) => {
let pageNumber = req.query.pageNumber || 1;
pageNumber = parseInt(pageNumber, 10);

if (isNaN(pageNumber) || pageNumber < 1) {
return res.status(400).json({ error: 'Invalid page number' });
}

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));
});

router.post('/', requireJwtAuth, async (req, res) => {
const created = await createSharedLink(req.user.id, req.body);
if (created) {
res.status(200).json(created);
} else {
res.status(404).end();
}
});

router.patch('/', requireJwtAuth, async (req, res) => {
const updated = await updateSharedLink(req.user.id, req.body);
if (updated) {
res.status(200).json(updated);
} else {
res.status(404).end();
}
});

router.delete('/:shareId', requireJwtAuth, async (req, res) => {
const deleted = await deleteSharedLink(req.user.id, { shareId: req.params.shareId });
if (deleted) {
res.status(200).json(deleted);
} else {
res.status(404).end();
}
});

module.exports = router;
5 changes: 5 additions & 0 deletions client/src/Providers/ShareContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createContext, useContext } from 'react';
type TShareContext = { isSharedConvo?: boolean };

export const ShareContext = createContext<TShareContext>({} as TShareContext);
export const useShareContext = () => useContext(ShareContext);
1 change: 1 addition & 0 deletions client/src/Providers/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export { default as ToastProvider } from './ToastContext';
export { default as AssistantsProvider } from './AssistantsContext';
export * from './ChatContext';
export * from './ShareContext';
export * from './ToastContext';
export * from './SearchContext';
export * from './FileMapContext';
Expand Down
63 changes: 63 additions & 0 deletions client/src/components/Chat/ExportAndShareMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { useState } from 'react';
import { Upload } from 'lucide-react';
import { useRecoilValue } from 'recoil';
import { useLocation } from 'react-router-dom';
import type { TConversation } from 'librechat-data-provider';
import DropDownMenu from '../Conversations/DropDownMenu';
import ShareButton from '../Conversations/ShareButton';
import HoverToggle from '../Conversations/HoverToggle';
import ExportButton from './ExportButton';
import store from '~/store';

export default function ExportAndShareMenu() {
const location = useLocation();

const activeConvo = useRecoilValue(store.conversationByIndex(0));
const globalConvo = useRecoilValue(store.conversation) ?? ({} as TConversation);
const [isPopoverActive, setIsPopoverActive] = useState(false);
let conversation: TConversation | null | undefined;
if (location.state?.from?.pathname.includes('/chat')) {
conversation = globalConvo;
} else {
conversation = activeConvo;
}

const exportable =
conversation &&
conversation.conversationId &&
conversation.conversationId !== 'new' &&
conversation.conversationId !== 'search';

if (!exportable) {
return <></>;
}

const isActiveConvo = exportable;

return (
<HoverToggle
isActiveConvo={!!isActiveConvo}
isPopoverActive={isPopoverActive}
setIsPopoverActive={setIsPopoverActive}
>
<DropDownMenu
icon={<Upload />}
tooltip="Export/Share"
className="pointer-cursor relative z-50 flex h-[40px] min-w-4 flex-none flex-col items-center justify-center rounded-md border border-gray-100 bg-white px-3 text-left hover:bg-gray-50 focus:outline-none focus:ring-0 focus:ring-offset-0 radix-state-open:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-700 dark:radix-state-open:bg-gray-700 sm:text-sm"
>
{conversation && conversation.conversationId && (
<>
<ExportButton conversation={conversation} setPopoverActive={setIsPopoverActive} />
<ShareButton
conversationId={conversation.conversationId}
title={conversation.title ?? ''}
appendLabel={true}
className="mb-[3.5px]"
setPopoverActive={setIsPopoverActive}
/>
</>
)}
</DropDownMenu>
</HoverToggle>
);
}
65 changes: 19 additions & 46 deletions client/src/components/Chat/ExportButton.tsx
Original file line number Diff line number Diff line change
@@ -1,68 +1,41 @@
import React from 'react';

import { useState } from 'react';
import { useLocation } from 'react-router-dom';
import type { TConversation } from 'librechat-data-provider';
import { Upload } from 'lucide-react';
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '~/components/ui';
import { useLocalize } from '~/hooks';
import { ExportModal } from '../Nav';
import { useRecoilValue } from 'recoil';
import store from '~/store';

function ExportButton() {
function ExportButton({
conversation,
setPopoverActive,
}: {
conversation: TConversation;
setPopoverActive: (value: boolean) => void;
}) {
const localize = useLocalize();
const location = useLocation();

const [showExports, setShowExports] = useState(false);

const activeConvo = useRecoilValue(store.conversationByIndex(0));
const globalConvo = useRecoilValue(store.conversation) ?? ({} as TConversation);

let conversation: TConversation | null | undefined;
if (location.state?.from?.pathname.includes('/chat')) {
conversation = globalConvo;
} else {
conversation = activeConvo;
}

const clickHandler = () => {
if (exportable) {
setShowExports(true);
}
setShowExports(true);
};

const exportable =
conversation &&
conversation.conversationId &&
conversation.conversationId !== 'new' &&
conversation.conversationId !== 'search';
const onOpenChange = (value: boolean) => {
setShowExports(value);
setPopoverActive(value);
};

return (
<>
{exportable && (
<div className="flex gap-1 gap-2 pr-1">
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger asChild>
<button
className="btn btn-neutral btn-small relative flex h-9 w-9 items-center justify-center whitespace-nowrap rounded-lg"
onClick={clickHandler}
>
<div className="flex w-full items-center justify-center gap-2">
<Upload size={16} />
</div>
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={5}>
{localize('com_nav_export_conversation')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
<button
onClick={clickHandler}
className="group m-1.5 flex w-full cursor-pointer items-center gap-2 rounded p-2.5 text-sm hover:bg-gray-200 focus-visible:bg-gray-200 focus-visible:outline-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-gray-600 dark:focus-visible:bg-gray-600"
>
<Upload size={16} /> {localize('com_nav_export')}
</button>
{showExports && (
<ExportModal open={showExports} onOpenChange={setShowExports} conversation={conversation} />
<ExportModal open={showExports} onOpenChange={onOpenChange} conversation={conversation} />
)}
</>
);
Expand Down
Loading
Loading