| import { useState, useId, useCallback, useMemo, useRef } from 'react'; |
| import { useRecoilValue } from 'recoil'; |
| import * as Ariakit from '@ariakit/react'; |
| import { BookmarkPlusIcon } from 'lucide-react'; |
| import { useQueryClient } from '@tanstack/react-query'; |
| import { Constants, QueryKeys } from 'librechat-data-provider'; |
| import { BookmarkFilledIcon, BookmarkIcon } from '@radix-ui/react-icons'; |
| import { DropdownPopup, TooltipAnchor, Spinner, useToastContext } from '@librechat/client'; |
| import type { TConversationTag } from 'librechat-data-provider'; |
| import type { FC } from 'react'; |
| import type * as t from '~/common'; |
| import { useConversationTagsQuery, useTagConversationMutation } from '~/data-provider'; |
| import { BookmarkContext } from '~/Providers/BookmarkContext'; |
| import { BookmarkEditDialog } from '~/components/Bookmarks'; |
| import { useBookmarkSuccess, useLocalize } from '~/hooks'; |
| import { NotificationSeverity } from '~/common'; |
| import { cn, logger } from '~/utils'; |
| import store from '~/store'; |
|
|
| const BookmarkMenu: FC = () => { |
| const localize = useLocalize(); |
| const queryClient = useQueryClient(); |
| const { showToast } = useToastContext(); |
|
|
| const conversation = useRecoilValue(store.conversationByIndex(0)) || undefined; |
| const conversationId = conversation?.conversationId ?? ''; |
| const updateConvoTags = useBookmarkSuccess(conversationId); |
| const tags = conversation?.tags; |
| const isTemporary = conversation?.expiredAt != null; |
|
|
| const menuId = useId(); |
| const [isMenuOpen, setIsMenuOpen] = useState(false); |
| const [isDialogOpen, setIsDialogOpen] = useState(false); |
|
|
| const mutation = useTagConversationMutation(conversationId, { |
| onSuccess: (newTags: string[], vars) => { |
| updateConvoTags(newTags); |
| const tagElement = document.getElementById(vars.tag); |
| console.log('tagElement', tagElement); |
| if (tagElement) { |
| setTimeout(() => tagElement.focus(), 2); |
| } |
| }, |
| onError: () => { |
| showToast({ |
| message: 'Error adding bookmark', |
| severity: NotificationSeverity.ERROR, |
| }); |
| }, |
| onMutate: (vars) => { |
| const tagElement = document.getElementById(vars.tag); |
| console.log('tagElement', tagElement); |
| if (tagElement) { |
| setTimeout(() => tagElement.focus(), 2); |
| } |
| }, |
| }); |
|
|
| const { data } = useConversationTagsQuery(); |
|
|
| const isActiveConvo = Boolean( |
| conversation && |
| conversationId && |
| conversationId !== Constants.NEW_CONVO && |
| conversationId !== 'search', |
| ); |
|
|
| const handleSubmit = useCallback( |
| (tag?: string) => { |
| if (tag === undefined || tag === '' || !conversationId) { |
| showToast({ |
| message: 'Invalid tag or conversationId', |
| severity: NotificationSeverity.ERROR, |
| }); |
| return; |
| } |
|
|
| logger.log('tag_mutation', 'BookmarkMenu - handleSubmit: tags before setting', tags); |
|
|
| const allTags = |
| queryClient.getQueryData<TConversationTag[]>([QueryKeys.conversationTags]) ?? []; |
| const existingTags = allTags.map((t) => t.tag); |
| const filteredTags = tags?.filter((t) => existingTags.includes(t)); |
|
|
| logger.log('tag_mutation', 'BookmarkMenu - handleSubmit: tags after filtering', filteredTags); |
| const newTags = |
| filteredTags?.includes(tag) === true |
| ? filteredTags.filter((t) => t !== tag) |
| : [...(filteredTags ?? []), tag]; |
|
|
| logger.log('tag_mutation', 'BookmarkMenu - handleSubmit: tags after', newTags); |
| mutation.mutate({ |
| tags: newTags, |
| tag, |
| }); |
| }, |
| [tags, conversationId, mutation, queryClient, showToast], |
| ); |
|
|
| const newBookmarkRef = useRef<HTMLButtonElement>(null); |
|
|
| const dropdownItems: t.MenuItemProps[] = useMemo(() => { |
| const items: t.MenuItemProps[] = [ |
| { |
| id: '%___new___bookmark___%', |
| label: localize('com_ui_bookmarks_new'), |
| icon: <BookmarkPlusIcon className="size-4" />, |
| hideOnClick: false, |
| ref: newBookmarkRef, |
| render: (props) => <button {...props} />, |
| onClick: () => setIsDialogOpen(true), |
| }, |
| ]; |
|
|
| if (data) { |
| for (const tag of data) { |
| const isSelected = tags?.includes(tag.tag); |
| items.push({ |
| id: tag.tag, |
| label: tag.tag, |
| hideOnClick: false, |
| icon: |
| isSelected === true ? ( |
| <BookmarkFilledIcon className="size-4" /> |
| ) : ( |
| <BookmarkIcon className="size-4" /> |
| ), |
| onClick: () => handleSubmit(tag.tag), |
| disabled: mutation.isLoading, |
| }); |
| } |
| } |
|
|
| return items; |
| }, [tags, data, handleSubmit, mutation.isLoading, localize]); |
|
|
| if (!isActiveConvo) { |
| return null; |
| } |
|
|
| if (isTemporary) { |
| return null; |
| } |
|
|
| const renderButtonContent = () => { |
| if (mutation.isLoading) { |
| return <Spinner aria-label="Spinner" />; |
| } |
| if ((tags?.length ?? 0) > 0) { |
| return <BookmarkFilledIcon className="icon-sm" aria-label="Filled Bookmark" />; |
| } |
| return <BookmarkIcon className="icon-sm" aria-label="Bookmark" />; |
| }; |
|
|
| return ( |
| <BookmarkContext.Provider value={{ bookmarks: data || [] }}> |
| <DropdownPopup |
| portal={true} |
| menuId={menuId} |
| focusLoop={true} |
| isOpen={isMenuOpen} |
| unmountOnHide={true} |
| setIsOpen={setIsMenuOpen} |
| keyPrefix={`${conversationId}-bookmark-`} |
| trigger={ |
| <TooltipAnchor |
| description={localize('com_ui_bookmarks_add')} |
| render={ |
| <Ariakit.MenuButton |
| id="bookmark-menu-button" |
| aria-label={localize('com_ui_bookmarks_add')} |
| className={cn( |
| 'mt-text-sm flex size-10 flex-shrink-0 items-center justify-center gap-2 rounded-xl border border-border-light text-sm transition-colors duration-200 hover:bg-surface-hover', |
| isMenuOpen ? 'bg-surface-hover' : '', |
| )} |
| data-testid="bookmark-menu" |
| > |
| {renderButtonContent()} |
| </Ariakit.MenuButton> |
| } |
| /> |
| } |
| items={dropdownItems} |
| /> |
| <BookmarkEditDialog |
| tags={tags} |
| open={isDialogOpen} |
| setTags={updateConvoTags} |
| setOpen={setIsDialogOpen} |
| triggerRef={newBookmarkRef} |
| conversationId={conversationId} |
| context="BookmarkMenu - BookmarkEditDialog" |
| /> |
| </BookmarkContext.Provider> |
| ); |
| }; |
|
|
| export default BookmarkMenu; |
|
|