diff --git a/client/src/components/Chat/AddMultiConvo.tsx b/client/src/components/Chat/AddMultiConvo.tsx index 101dbadd19..a807dfc527 100644 --- a/client/src/components/Chat/AddMultiConvo.tsx +++ b/client/src/components/Chat/AddMultiConvo.tsx @@ -1,38 +1,13 @@ -import { useCallback } from 'react'; -import { useSetRecoilState, useRecoilValue } from 'recoil'; import { PlusCircle } from 'lucide-react'; import { TooltipAnchor } from '@librechat/client'; -import { isAssistantsEndpoint } from 'librechat-data-provider'; -import type { TConversation } from 'librechat-data-provider'; -import { useGetConversation, useLocalize } from '~/hooks'; -import { mainTextareaId } from '~/common'; -import store from '~/store'; +import useMultiConvo from '~/hooks/Chat/useMultiConvo'; +import { useLocalize } from '~/hooks'; function AddMultiConvo() { const localize = useLocalize(); - const getConversation = useGetConversation(0); - const endpoint = useRecoilValue(store.conversationEndpointByIndex(0)); - const setAddedConvo = useSetRecoilState(store.conversationByIndex(1)); + const { show, addConversation } = useMultiConvo(); - const clickHandler = useCallback(() => { - const conversation = getConversation(); - const { title: _t, ...convo } = conversation ?? ({} as TConversation); - setAddedConvo({ - ...convo, - title: '', - } as TConversation); - - const textarea = document.getElementById(mainTextareaId); - if (textarea) { - textarea.focus(); - } - }, [getConversation, setAddedConvo]); - - if (!endpoint) { - return null; - } - - if (isAssistantsEndpoint(endpoint)) { + if (!show) { return null; } @@ -42,7 +17,7 @@ function AddMultiConvo() { role="button" tabIndex={0} aria-label={localize('com_ui_add_multi_conversation')} - onClick={clickHandler} + onClick={addConversation} data-testid="add-multi-convo-button" className="inline-flex size-9 flex-shrink-0 items-center justify-center rounded-xl border border-border-light bg-presentation text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary" > diff --git a/client/src/components/Chat/ExportAndShareMenu.tsx b/client/src/components/Chat/ExportAndShareMenu.tsx index 8632cb3840..ffebe1d93e 100644 --- a/client/src/components/Chat/ExportAndShareMenu.tsx +++ b/client/src/components/Chat/ExportAndShareMenu.tsx @@ -1,15 +1,9 @@ -import { useState, useId, useRef } from 'react'; -import { useRecoilValue } from 'recoil'; +import { useState, useId } from 'react'; +import { Share2 } from 'lucide-react'; import * as Ariakit from '@ariakit/react'; -import { Upload, Share2 } from 'lucide-react'; -import { PermissionTypes, Permissions } from 'librechat-data-provider'; -import { useGetSharedLinkQuery } from 'librechat-data-provider/react-query'; import { DropdownPopup, TooltipAnchor, useMediaQuery } from '@librechat/client'; -import type * as t from '~/common'; -import ExportModal from '~/components/Nav/ExportConversation/ExportModal'; -import { ShareButton } from '~/components/Conversations/ConvoOptions'; -import { useHasAccess, useLocalize } from '~/hooks'; -import store from '~/store'; +import useExportShare from '~/hooks/Chat/useExportShare'; +import { useLocalize } from '~/hooks'; export default function ExportAndShareMenu({ isSharedButtonEnabled, @@ -17,63 +11,18 @@ export default function ExportAndShareMenu({ isSharedButtonEnabled: boolean; }) { const localize = useLocalize(); - const [showExports, setShowExports] = useState(false); - const [isPopoverActive, setIsPopoverActive] = useState(false); - const [showShareDialog, setShowShareDialog] = useState(false); - const menuId = useId(); - const shareButtonRef = useRef(null); - const exportButtonRef = useRef(null); - const canCreateSharedLinks = useHasAccess({ - permissionType: PermissionTypes.SHARED_LINKS, - permission: Permissions.CREATE, - }); + const [isPopoverActive, setIsPopoverActive] = useState(false); const isSmallScreen = useMediaQuery('(max-width: 768px)'); - const conversation = useRecoilValue(store.conversationByIndex(0)); + const { show, items, hasSharedLink, dialogs } = useExportShare({ isSharedButtonEnabled }); - const exportable = - conversation != null && - conversation.conversationId != null && - conversation.conversationId !== 'new' && - conversation.conversationId !== 'search'; - const { data: share } = useGetSharedLinkQuery(conversation?.conversationId ?? '', { - enabled: exportable && isSharedButtonEnabled, - }); - const hasSharedLink = Boolean(share?.shareId); - - if (exportable === false) { + if (!show) { return null; } - const shareHandler = () => { - setShowShareDialog(true); - }; - - const exportHandler = () => { - setShowExports(true); - }; - - const dropdownItems: t.MenuItemProps[] = [ - { - label: localize('com_ui_share'), - onClick: shareHandler, - icon: , - show: isSharedButtonEnabled && canCreateSharedLinks, - /** NOTE: THE FOLLOWING PROPS ARE REQUIRED FOR MENU ITEMS THAT OPEN DIALOGS */ - hideOnClick: false, - ref: shareButtonRef, - render: (props) => + } + /> + ); +} diff --git a/client/src/components/Chat/Menus/__tests__/HeaderMenu.spec.tsx b/client/src/components/Chat/Menus/__tests__/HeaderMenu.spec.tsx new file mode 100644 index 0000000000..d4893b316a --- /dev/null +++ b/client/src/components/Chat/Menus/__tests__/HeaderMenu.spec.tsx @@ -0,0 +1,202 @@ +import { render, screen } from '@testing-library/react'; +import type { MenuItemProps } from '~/common'; + +const mockAccess: Record = {}; +const mockBookmarkArgs: { enabled?: boolean }[] = []; +const mockHookState = { + multiConvo: { show: true, addConversation: jest.fn() }, + temporary: { show: true, isTemporary: false, toggle: jest.fn() }, + bookmarks: { + show: true, + items: [{ id: 'tag-1', label: 'work' }] as MenuItemProps[], + bookmarks: [], + hasBookmarks: false, + isLoading: false, + triggerAriaLabel: 'bookmarks', + dialog:
, + }, + exportShare: { + show: true, + items: [{ label: 'share' }, { label: 'export' }] as MenuItemProps[], + hasSharedLink: false, + dialogs:
, + }, +}; + +jest.mock('~/hooks', () => ({ + useLocalize: () => (key: string) => key, + useHasAccess: ({ permissionType }: { permissionType: string }) => + mockAccess[permissionType] ?? true, +})); + +jest.mock('~/hooks/Chat/useMultiConvo', () => ({ + __esModule: true, + default: () => mockHookState.multiConvo, +})); +jest.mock('~/hooks/Chat/useTemporaryChat', () => ({ + __esModule: true, + default: () => mockHookState.temporary, +})); +jest.mock('~/hooks/Chat/useBookmarkItems', () => ({ + __esModule: true, + default: (args: { enabled?: boolean } = {}) => { + mockBookmarkArgs.push(args); + return mockHookState.bookmarks; + }, +})); +jest.mock('~/hooks/Chat/useExportShare', () => ({ + __esModule: true, + default: () => mockHookState.exportShare, +})); + +/** The real MenuButton needs the MenuProvider that DropdownPopup supplies, which is mocked out below. */ +jest.mock('@ariakit/react', () => ({ + MenuButton: ({ + children, + render: _render, + ...props + }: React.ComponentProps<'button'> & { render?: React.ReactNode }) => ( + + ), +})); + +/** + * Mirrors DropdownPopup's real contract: `separate` entries render as a divider + * *instead of* an item, and `show: false` entries are dropped. A looser mock + * hides the bug where a flag on an actionable item silently deletes it. + */ +jest.mock('@librechat/client', () => ({ + Button: ({ children, ...props }: React.ComponentProps<'button'>) => ( + + ), + TooltipAnchor: ({ render: node }: { render: React.ReactNode }) => node, + DropdownPopup: ({ trigger, items }: { trigger: React.ReactNode; items: MenuItemProps[] }) => ( +
+ {trigger} +
    + {items + .filter((item) => item.show !== false) + .map((item, index) => + item.separate === true ? ( +
  • + ) : ( +
  • + {item.label} +
  • + ), + )} +
+
+ ), +})); + +import HeaderMenu from '../HeaderMenu'; + +const rows = () => Array.from(screen.getByTestId('menu-items').children); +const labels = () => + rows() + .filter((node) => node.getAttribute('data-kind') === 'item') + .map((node) => node.textContent); + +describe('HeaderMenu', () => { + beforeEach(() => { + mockBookmarkArgs.length = 0; + for (const key of Object.keys(mockAccess)) { + delete mockAccess[key]; + } + mockHookState.multiConvo.show = true; + mockHookState.temporary.show = true; + mockHookState.bookmarks.show = true; + mockHookState.exportShare.show = true; + mockHookState.exportShare.hasSharedLink = false; + mockHookState.temporary.isTemporary = false; + }); + + it('collapses every secondary action behind one trigger', () => { + render(); + + expect(screen.getByTestId('header-overflow-menu')).toBeInTheDocument(); + expect(labels()).toEqual([ + 'com_ui_bookmarks', + 'com_ui_add_multi_conversation', + 'share', + 'export', + 'com_ui_temporary', + ]); + }); + + it('keeps every action reachable when groups are divided', () => { + render(); + + /** A divider is its own entry; flagging an action as one deletes it. */ + expect(labels()).toContain('share'); + expect(labels()).toContain('com_ui_temporary'); + expect(rows().filter((node) => node.getAttribute('data-kind') === 'separator')).toHaveLength(2); + }); + + it('nests bookmarks rather than flattening every tag into the top level', () => { + render(); + + expect(rows()[0]).toHaveAttribute('data-sub', 'true'); + }); + + it('renders nothing when no action survives its gate', () => { + mockHookState.multiConvo.show = false; + mockHookState.temporary.show = false; + mockHookState.bookmarks.show = false; + mockHookState.exportShare.show = false; + + render(); + + expect(screen.queryByTestId('header-overflow-menu')).not.toBeInTheDocument(); + }); + + it('drops actions the user lacks permission for', () => { + mockAccess.BOOKMARKS = false; + mockAccess.MULTI_CONVO = false; + + render(); + + expect(labels()).toEqual(['share', 'export', 'com_ui_temporary']); + }); + + it('never opens with a leading divider when earlier groups are gated out', () => { + mockAccess.BOOKMARKS = false; + mockAccess.MULTI_CONVO = false; + + render(); + + expect(rows()[0]).toHaveAttribute('data-kind', 'item'); + }); + + it('does not query bookmark tags without the bookmark permission', () => { + mockAccess.BOOKMARKS = false; + + render(); + + expect(mockBookmarkArgs.every((args) => args.enabled === false)).toBe(true); + }); + + it('keeps surfacing an active shared link on the collapsed trigger', () => { + mockHookState.exportShare.hasSharedLink = true; + + render(); + + /** Distinct from the desktop menu's indicator; both are mounted at once. */ + expect(screen.getByTestId('header-menu-shared-link-indicator')).toBeInTheDocument(); + expect(screen.getByTestId('header-overflow-menu')).toHaveAttribute( + 'aria-label', + 'com_ui_export_share_link_active', + ); + }); + + it('shows temporary chat as active to sighted users, not just assistive tech', () => { + mockHookState.temporary.isTemporary = true; + + render(); + + const temporaryRow = rows().find((node) => node.textContent === 'com_ui_temporary'); + expect(temporaryRow).toBeDefined(); + expect(labels()).toContain('com_ui_temporary'); + }); +}); diff --git a/client/src/components/Chat/Menus/index.ts b/client/src/components/Chat/Menus/index.ts index b55dfd846a..ba12ccf196 100644 --- a/client/src/components/Chat/Menus/index.ts +++ b/client/src/components/Chat/Menus/index.ts @@ -1,2 +1,4 @@ export { default as PresetsMenu } from './PresetsMenu'; export { default as OpenSidebar } from './OpenSidebar'; +export { default as HeaderMenu } from './HeaderMenu'; +export { default as NewChat } from './NewChat'; diff --git a/client/src/components/Chat/TemporaryChat.tsx b/client/src/components/Chat/TemporaryChat.tsx index fe4b87262a..b8d1513b54 100644 --- a/client/src/components/Chat/TemporaryChat.tsx +++ b/client/src/components/Chat/TemporaryChat.tsx @@ -1,37 +1,17 @@ -import React from 'react'; -import { useRecoilValue } from 'recoil'; import { TooltipAnchor } from '@librechat/client'; import { MessageCircleDashed } from 'lucide-react'; -import { Constants } from 'librechat-data-provider'; -import { useRecoilState, useRecoilCallback } from 'recoil'; import { useShortcutAriaKey, useShortcutHint } from '~/hooks/useKeyboardShortcuts'; +import useTemporaryChat from '~/hooks/Chat/useTemporaryChat'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; -import store from '~/store'; export function TemporaryChat() { const localize = useLocalize(); - const [isTemporary, setIsTemporary] = useRecoilState(store.isTemporary); - const conversation = useRecoilValue(store.conversationByIndex(0)); - const isSubmitting = useRecoilValue(store.isSubmittingFamily(0)); + const { show, isTemporary, toggle } = useTemporaryChat(); const tooltipDescription = useShortcutHint('toggleTemporaryChat', localize('com_ui_temporary')); const ariaKey = useShortcutAriaKey('toggleTemporaryChat'); - const handleBadgeToggle = useRecoilCallback( - () => () => { - setIsTemporary(!isTemporary); - }, - [isTemporary], - ); - - const conversationId = conversation?.conversationId; - const hasStarted = conversationId != null && conversationId !== Constants.NEW_CONVO; - - if ( - hasStarted || - (Array.isArray(conversation?.messages) && conversation.messages.length >= 1) || - isSubmitting - ) { + if (!show) { return null; } @@ -41,7 +21,7 @@ export function TemporaryChat() { description={tooltipDescription} render={ - } - /> - ); -} diff --git a/client/src/components/Nav/index.ts b/client/src/components/Nav/index.ts index 8a18285181..e56b67d0a5 100644 --- a/client/src/components/Nav/index.ts +++ b/client/src/components/Nav/index.ts @@ -1,6 +1,5 @@ export * from './ExportConversation'; export * from './SettingsTabs/'; export { default as NavLink } from './NavLink'; -export { default as NewChat } from './NewChat'; export { default as SearchBar } from './SearchBar'; export { default as Settings } from './Settings'; diff --git a/client/src/components/UnifiedSidebar/ExpandedPanel.tsx b/client/src/components/UnifiedSidebar/ExpandedPanel.tsx index 8400310e83..437320712a 100644 --- a/client/src/components/UnifiedSidebar/ExpandedPanel.tsx +++ b/client/src/components/UnifiedSidebar/ExpandedPanel.tsx @@ -1,15 +1,14 @@ import { memo, useCallback, lazy, Suspense } from 'react'; import { useRecoilValue } from 'recoil'; import { SquarePen } from 'lucide-react'; -import { QueryKeys } from 'librechat-data-provider'; -import { useQueryClient } from '@tanstack/react-query'; import { Skeleton, Sidebar, Button, TooltipAnchor } from '@librechat/client'; import type { NavLink } from '~/common'; import { useShortcutAriaKey, useShortcutHint } from '~/hooks/useKeyboardShortcuts'; import { useActivePanel, resolveActivePanel, DEFAULT_PANEL } from '~/Providers'; import { CLOSE_SIDEBAR_ID } from '~/components/Chat/Menus/OpenSidebar'; -import { useLocalize, useNewConvo } from '~/hooks'; -import { clearMessagesCache, cn } from '~/utils'; +import useNewChat from '~/hooks/Chat/useNewChat'; +import { useLocalize } from '~/hooks'; +import { cn } from '~/utils'; import store from '~/store'; const AccountSettings = lazy(() => import('~/components/Nav/AccountSettings')); @@ -20,27 +19,17 @@ const NewChatButton = memo(function NewChatButton({ setActive: (id: string) => void; }) { const localize = useLocalize(); - const queryClient = useQueryClient(); - const { newConversation } = useNewConvo(); - const conversationId = useRecoilValue(store.conversationIdByIndex(0)); const switchToHistory = useRecoilValue(store.newChatSwitchToHistory); const tooltipDescription = useShortcutHint('newChat', localize('com_ui_new_chat')); const ariaKey = useShortcutAriaKey('newChat'); - const handleClick = useCallback( - (e: React.MouseEvent) => { - if (e.button === 0 && !e.ctrlKey && !e.metaKey) { - e.preventDefault(); - clearMessagesCache(queryClient, conversationId); - queryClient.invalidateQueries([QueryKeys.messages]); - newConversation(); - if (switchToHistory) { - setActive(DEFAULT_PANEL); - } - } - }, - [queryClient, conversationId, newConversation, switchToHistory, setActive], - ); + const handlePanelSwitch = useCallback(() => { + if (switchToHistory) { + setActive(DEFAULT_PANEL); + } + }, [switchToHistory, setActive]); + + const { handleNewChatClick } = useNewChat({ onNewChat: handlePanelSwitch }); return ( diff --git a/client/src/components/UnifiedSidebar/__tests__/ExpandedPanel.spec.tsx b/client/src/components/UnifiedSidebar/__tests__/ExpandedPanel.spec.tsx index 9ee7d83d63..aad4bc297e 100644 --- a/client/src/components/UnifiedSidebar/__tests__/ExpandedPanel.spec.tsx +++ b/client/src/components/UnifiedSidebar/__tests__/ExpandedPanel.spec.tsx @@ -39,6 +39,31 @@ jest.mock('~/hooks', () => ({ useNewConvo: () => ({ newConversation: mockNewConversation }), })); +/** + * Stands in for the real hook, which reaches `useNewConvo` by deep path and so + * escapes the `~/hooks` mock above. Mirrors its contract closely enough that + * the panel-switch assertions still exercise the `onNewChat` wiring. + */ +jest.mock('~/hooks/Chat/useNewChat', () => ({ + __esModule: true, + default: ({ onNewChat }: { onNewChat?: () => void } = {}) => ({ + newConversation: mockNewConversation, + startNewChat: () => { + mockNewConversation(); + onNewChat?.(); + }, + handleNewChatClick: (event: React.MouseEvent) => { + if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey) { + return; + } + event.preventDefault(); + mockClearMessagesCache(); + mockNewConversation(); + onNewChat?.(); + }, + }), +})); + jest.mock('~/utils', () => ({ clearMessagesCache: (...args: unknown[]) => mockClearMessagesCache(...args), cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '), diff --git a/client/src/hooks/Chat/__tests__/useNewChat.spec.ts b/client/src/hooks/Chat/__tests__/useNewChat.spec.ts new file mode 100644 index 0000000000..0b74a8cf3e --- /dev/null +++ b/client/src/hooks/Chat/__tests__/useNewChat.spec.ts @@ -0,0 +1,105 @@ +import { renderHook, act } from '@testing-library/react'; + +import type { MouseEvent } from 'react'; + +const mockNewConversation = jest.fn(); +const mockClearMessagesCache = jest.fn(); +const mockInvalidateQueries = jest.fn(); + +jest.mock('@tanstack/react-query', () => ({ + useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }), +})); + +jest.mock('recoil', () => ({ + useRecoilValue: () => 'convo-1', +})); + +jest.mock('~/hooks/useNewConvo', () => ({ + __esModule: true, + default: () => ({ newConversation: mockNewConversation }), +})); + +jest.mock('~/utils', () => ({ + clearMessagesCache: (...args: unknown[]) => mockClearMessagesCache(...args), +})); + +jest.mock('~/store', () => ({ + __esModule: true, + default: { conversationIdByIndex: (index: number) => `conversationIdByIndex-${index}` }, +})); + +jest.mock('librechat-data-provider', () => ({ + QueryKeys: { messages: 'messages' }, +})); + +import useNewChat from '../useNewChat'; + +const clickEvent = (overrides: Partial> = {}) => + ({ + button: 0, + ctrlKey: false, + metaKey: false, + shiftKey: false, + preventDefault: jest.fn(), + ...overrides, + }) as unknown as MouseEvent; + +describe('useNewChat', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('clears the outgoing conversation before resetting', () => { + const { result } = renderHook(() => useNewChat()); + + act(() => result.current.startNewChat()); + + expect(mockClearMessagesCache).toHaveBeenCalledWith(expect.anything(), 'convo-1'); + expect(mockInvalidateQueries).toHaveBeenCalledWith(['messages']); + expect(mockNewConversation).toHaveBeenCalledTimes(1); + }); + + it('runs the optional callback after the reset', () => { + const onNewChat = jest.fn(); + const { result } = renderHook(() => useNewChat({ onNewChat })); + + act(() => result.current.startNewChat()); + + expect(onNewChat).toHaveBeenCalledTimes(1); + expect(mockNewConversation.mock.invocationCallOrder[0]).toBeLessThan( + onNewChat.mock.invocationCallOrder[0], + ); + }); + + it('works without a callback', () => { + const { result } = renderHook(() => useNewChat()); + + expect(() => act(() => result.current.startNewChat())).not.toThrow(); + expect(mockNewConversation).toHaveBeenCalledTimes(1); + }); + + it('takes over a plain left click', () => { + const { result } = renderHook(() => useNewChat()); + const event = clickEvent(); + + act(() => result.current.handleNewChatClick(event)); + + expect(event.preventDefault).toHaveBeenCalledTimes(1); + expect(mockNewConversation).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['ctrl', { ctrlKey: true }], + ['meta', { metaKey: true }], + ['shift', { shiftKey: true }], + ['middle', { button: 1 }], + ])('lets a %s click fall through to the browser', (_label, overrides) => { + const { result } = renderHook(() => useNewChat()); + const event = clickEvent(overrides); + + act(() => result.current.handleNewChatClick(event)); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(mockNewConversation).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/hooks/Chat/index.ts b/client/src/hooks/Chat/index.ts index 3dc2a8fd65..b3d8972f3a 100644 --- a/client/src/hooks/Chat/index.ts +++ b/client/src/hooks/Chat/index.ts @@ -1,3 +1,6 @@ +export { default as useNewChat } from './useNewChat'; +export { default as useMultiConvo } from './useMultiConvo'; +export { default as useTemporaryChat } from './useTemporaryChat'; export { default as useChatHelpers } from './useChatHelpers'; export { default as useTokenLimits } from './useTokenLimits'; export { default as useTokenUsage } from './useTokenUsage'; diff --git a/client/src/hooks/Chat/useBookmarkItems.tsx b/client/src/hooks/Chat/useBookmarkItems.tsx new file mode 100644 index 0000000000..be4ed7c0c7 --- /dev/null +++ b/client/src/hooks/Chat/useBookmarkItems.tsx @@ -0,0 +1,178 @@ +import { useState, useCallback, useMemo, useRef } from 'react'; +import { useRecoilValue } from 'recoil'; +import { BookmarkPlusIcon } from 'lucide-react'; +import { useToastContext } from '@librechat/client'; +import { useQueryClient } from '@tanstack/react-query'; +import { Constants, QueryKeys } from 'librechat-data-provider'; +import { BookmarkFilledIcon, BookmarkIcon } from '@radix-ui/react-icons'; + +import type { TConversationTag } from 'librechat-data-provider'; +import type { ReactNode } from 'react'; +import type * as t from '~/common'; + +import { useConversationTagsQuery, useTagConversationMutation } from '~/data-provider'; +import { BookmarkEditDialog } from '~/components/Bookmarks'; +import { useBookmarkSuccess, useLocalize } from '~/hooks'; +import { isTemporaryConversation, logger } from '~/utils'; +import { NotificationSeverity } from '~/common'; +import store from '~/store'; + +export type UseBookmarkItemsResult = { + /** Bookmarks only apply to a saved, non-temporary conversation. */ + show: boolean; + items: t.MenuItemProps[]; + bookmarks: TConversationTag[]; + hasBookmarks: boolean; + isLoading: boolean; + triggerAriaLabel: string; + /** Rendered by whichever surface owns the menu; both need the same instance. */ + dialog: ReactNode; +}; + +/** + * Bookmark tagging as menu items, so the desktop icon menu and the mobile + * overflow menu share one set of items, one mutation, and one edit dialog. + */ +export default function useBookmarkItems({ + enabled = true, +}: { enabled?: boolean } = {}): UseBookmarkItemsResult { + 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 = isTemporaryConversation(conversation); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const newBookmarkRef = useRef(null); + + const focusTag = useCallback((tag: string) => { + const tagElement = document.getElementById(tag); + if (tagElement) { + setTimeout(() => tagElement.focus(), 2); + } + }, []); + + const mutation = useTagConversationMutation(conversationId, { + onSuccess: (newTags: string[], vars) => { + updateConvoTags(newTags); + focusTag(vars.tag); + }, + onError: () => { + showToast({ + message: 'Error adding bookmark', + severity: NotificationSeverity.ERROR, + }); + }, + onMutate: (vars) => { + focusTag(vars.tag); + }, + }); + + /** The tags endpoint is behind the bookmark permission, so an ungated query 403s. */ + const { data } = useConversationTagsQuery({ enabled }); + + 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([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 tagsCount = tags?.length ?? 0; + + const triggerAriaLabel = useMemo(() => { + if (tagsCount > 0) { + return localize('com_ui_bookmarks_count_selected', { count: tagsCount }); + } + return localize('com_ui_bookmarks_add'); + }, [tagsCount, localize]); + + const items: t.MenuItemProps[] = useMemo(() => { + const next: t.MenuItemProps[] = [ + { + id: '%___new___bookmark___%', + label: localize('com_ui_bookmarks_new'), + icon: , + hideOnClick: false, + ref: newBookmarkRef, + render: (props) =>