From 41a2f1e5267667be36384bb7a04734ec30e5266f Mon Sep 17 00:00:00 2001 From: Enrico Ros Date: Wed, 9 Oct 2024 01:17:19 -0700 Subject: [PATCH] Actile: improve, multi-provider, label attachments --- .../chat/components/composer/Composer.tsx | 27 ++-- .../composer/actile/ActilePopup.tsx | 131 +++++++++++------- .../composer/actile/ActileProvider.tsx | 24 +++- .../actile/providerAttachmentLabels.tsx | 37 +++++ .../composer/actile/providerCommands.tsx | 47 ++++--- .../actile/providerStarredMessage.tsx | 67 ++++----- .../composer/actile/useActileManager.tsx | 100 ++++++------- 7 files changed, 262 insertions(+), 171 deletions(-) create mode 100644 src/apps/chat/components/composer/actile/providerAttachmentLabels.tsx diff --git a/src/apps/chat/components/composer/Composer.tsx b/src/apps/chat/components/composer/Composer.tsx index e716d8386..b465e18f2 100644 --- a/src/apps/chat/components/composer/Composer.tsx +++ b/src/apps/chat/components/composer/Composer.tsx @@ -49,8 +49,9 @@ import { useUICounter, useUIPreferencesStore } from '~/common/state/store-ui'; import { useUXLabsStore } from '~/common/state/store-ux-labs'; import type { ActileItem } from './actile/ActileProvider'; +import { providerAttachmentLabels } from './actile/providerAttachmentLabels'; import { providerCommands } from './actile/providerCommands'; -import { providerStarredMessage, StarredMessageItem } from './actile/providerStarredMessage'; +import { providerStarredMessages, StarredMessageItem } from './actile/providerStarredMessage'; import { useActileManager } from './actile/useActileManager'; import type { AttachmentDraftId } from '~/common/attachment-drafts/attachment.types'; @@ -457,24 +458,21 @@ export function Composer(props: { // Actiles - const onActileCommandPaste = React.useCallback((item: ActileItem) => { + const onActileCommandPaste = React.useCallback(({ label }: ActileItem, searchPrefix: string) => { if (composerTextAreaRef.current) { const textArea = composerTextAreaRef.current; const currentText = textArea.value; const cursorPos = textArea.selectionStart; // Find the position where the command starts - const commandStart = currentText.lastIndexOf('/', cursorPos); + const commandStart = currentText.lastIndexOf(searchPrefix, cursorPos); // Construct the new text with the autocompleted command - const newText = currentText.substring(0, commandStart) + item.label + ' ' + currentText.substring(cursorPos); + setComposeText((prevText) => prevText.substring(0, commandStart) + label + ' ' + prevText.substring(cursorPos)); - // Update the text area with the new text - setComposeText(newText); - - // Move the cursor to the end of the autocompleted command - const newCursorPos = commandStart + item.label.length + 1; - textArea.setSelectionRange(newCursorPos, newCursorPos); + // Schedule setting the cursor position after the state update + const newCursorPos = commandStart + label.length + 1; + setTimeout(() => composerTextAreaRef.current?.setSelectionRange(newCursorPos, newCursorPos), 0); } }, [composerTextAreaRef, setComposeText]); @@ -493,9 +491,12 @@ export function Composer(props: { } }, [attachAppendEgoFragments]); - const actileProviders = React.useMemo(() => { - return [providerCommands(onActileCommandPaste), providerStarredMessage(onActileEmbedMessage)]; - }, [onActileCommandPaste, onActileEmbedMessage]); + + const actileProviders = React.useMemo(() => [ + providerAttachmentLabels(conversationOverlayStore, onActileCommandPaste), + providerCommands(onActileCommandPaste), + providerStarredMessages(onActileEmbedMessage), + ], [conversationOverlayStore, onActileCommandPaste, onActileEmbedMessage]); const { actileComponent, actileInterceptKeydown, actileInterceptTextChange } = useActileManager(actileProviders, composerTextAreaRef); diff --git a/src/apps/chat/components/composer/actile/ActilePopup.tsx b/src/apps/chat/components/composer/actile/ActilePopup.tsx index 5c904d59a..18b664e76 100644 --- a/src/apps/chat/components/composer/actile/ActilePopup.tsx +++ b/src/apps/chat/components/composer/actile/ActilePopup.tsx @@ -4,38 +4,44 @@ import { Box, ListItem, ListItemButton, ListItemDecorator, Sheet, Typography } f import { CloseableMenu } from '~/common/components/CloseableMenu'; -import type { ActileItem } from './ActileProvider'; - +import type { ActileItem, ActileProvider } from './ActileProvider'; export function ActilePopup(props: { anchorEl: HTMLElement | null, onClose: () => void, - title?: string, - items: ActileItem[], - activeItemIndex: number | undefined, + itemsByProvider: { provider: ActileProvider, items: ActileItem[] }[], + activeItemIndex: number, activePrefixLength: number, onItemClick: (item: ActileItem) => void, - children?: React.ReactNode }) { - const hasAnyIcon = props.items.some(item => !!item.Icon); + // We need to keep track of the overall item index to correctly match with activeItemIndex + const itemIndices = React.useMemo(() => { + const indices: { providerKey: string, itemKey: string, isActive: boolean }[] = []; + let indexCounter = 0; + props.itemsByProvider.forEach(({ provider, items }) => { + items.forEach((item) => { + indices.push({ + providerKey: provider.key, + itemKey: item.key, + isActive: indexCounter === props.activeItemIndex, + }); + indexCounter += 1; + }); + }); + return indices; + }, [props.itemsByProvider, props.activeItemIndex]); return ( - {!!props.title && ( - - - {props.title} - - - )} - - {!props.items.length && ( + {!props.itemsByProvider.length && ( No matching command @@ -43,46 +49,65 @@ export function ActilePopup(props: { )} - {props.items.map((item, idx) => { - const isActive = idx === props.activeItemIndex; - const labelBold = item.label.slice(0, props.activePrefixLength); - const labelNormal = item.label.slice(props.activePrefixLength); - return ( - props.onItemClick(item)} - > - - {hasAnyIcon && ( - - {item.Icon ? : null} - - )} - + {props.itemsByProvider.map(({ provider, items }) => ( + - - - {labelBold}{labelNormal} - - {item.argument && - {item.argument} + {/* Provider Label */} + + + {provider.label} + + + + {/* Items */} + {items.map((item) => { + const index = itemIndices.findIndex(idx => idx.providerKey === provider.key && idx.itemKey === item.key); + const isActive = itemIndices[index]?.isActive; + + const labelBold = item.label.slice(0, props.activePrefixLength); + const labelNormal = item.label.slice(props.activePrefixLength); + + return ( + props.onItemClick(item)} + > + + {item.Icon && ( + + + + )} + + {/* Item*/} + + + {/* Item main text */} + + + {labelBold}{labelNormal} + + {item.argument && + {item.argument} + } + + + {/* Item description */} + {!!item.description && + {item.description} } + - {!!item.description && - {item.description} - } - - - - ); - }, - )} - - {props.children} + + + ); + })} + + ))} ); -} \ No newline at end of file +} diff --git a/src/apps/chat/components/composer/actile/ActileProvider.tsx b/src/apps/chat/components/composer/actile/ActileProvider.tsx index 9b0440c25..e306fcf41 100644 --- a/src/apps/chat/components/composer/actile/ActileProvider.tsx +++ b/src/apps/chat/components/composer/actile/ActileProvider.tsx @@ -1,15 +1,27 @@ import type { FunctionComponent } from 'react'; +export interface ActileProvider { + + // Unique key for the provider + readonly key: 'pcmd' | 'pstrmsg' | 'pattlbl'; + + // Label for display + get label(): string; + + // Interface for the provider + fastCheckTriggerText: (trailingText: string) => boolean; + fetchItems: () => ActileProviderItems; + onItemSelect: (item: ActileItem) => void; + +} + +export type ActileProviderItems = Promise<{ searchPrefix: string, items: TItem[] }>; + export interface ActileItem { key: string; + providerKey: ActileProvider['key']; label: string; argument?: string; description?: string; Icon?: FunctionComponent; } - -export interface ActileProvider { - fastCheckTriggerText: (trailingText: string) => boolean; - fetchItems: () => Promise<{ title: string, searchPrefix: string, items: TItem[] }>; - onItemSelect: (item: ActileItem) => void; -} diff --git a/src/apps/chat/components/composer/actile/providerAttachmentLabels.tsx b/src/apps/chat/components/composer/actile/providerAttachmentLabels.tsx new file mode 100644 index 000000000..61e230994 --- /dev/null +++ b/src/apps/chat/components/composer/actile/providerAttachmentLabels.tsx @@ -0,0 +1,37 @@ +import type { ActileItem, ActileProvider, ActileProviderItems } from './ActileProvider'; + +import type { AttachmentDraftsStoreApi } from '~/common/attachment-drafts/store-perchat-attachment-drafts_slice'; + +export interface AttachmentLabelItem extends ActileItem { + // nothing to do do here, this is really just a label +} + +export const providerAttachmentLabels = ( + attachmentsStoreApi: AttachmentDraftsStoreApi | null, + onLabelSelect: (item: ActileItem, searchPrefix: string) => void, +): ActileProvider => ({ + + key: 'pattlbl', + + get label() { + return 'Attachment Labels'; + }, + + // Uses '@' as the trigger + fastCheckTriggerText: (trailingText: string) => trailingText === '@' || trailingText.endsWith(' @'), + + fetchItems: async (): ActileProviderItems => ({ + searchPrefix: '', + items: attachmentsStoreApi?.getState()?.attachmentDrafts.map(draft => ({ + key: draft.id, + providerKey: 'pattlbl', + label: draft.label, + argument: undefined, + description: 'name', + Icon: undefined, + } as AttachmentLabelItem)) ?? [], + }), + + onItemSelect: item => onLabelSelect(item as AttachmentLabelItem, '@'), + +}); \ No newline at end of file diff --git a/src/apps/chat/components/composer/actile/providerCommands.tsx b/src/apps/chat/components/composer/actile/providerCommands.tsx index 957731472..4eeb6b68b 100644 --- a/src/apps/chat/components/composer/actile/providerCommands.tsx +++ b/src/apps/chat/components/composer/actile/providerCommands.tsx @@ -1,26 +1,35 @@ -import { ActileItem, ActileProvider } from './ActileProvider'; import { findAllChatCommands } from '../../../commands/commands.registry'; +import type { ActileItem, ActileProvider, ActileProviderItems } from './ActileProvider'; -export function providerCommands(onCommandSelect: (item: ActileItem) => void): ActileProvider { - return { +export const providerCommands = ( + onCommandSelect: (item: ActileItem, searchPrefix: string) => void, +): ActileProvider => ({ + + key: 'pcmd', + + get label() { + return 'Chat Commands'; + }, + + fastCheckTriggerText: (trailingText: string) => { // only the literal '/' is a trigger - fastCheckTriggerText: (trailingText: string) => trailingText === '/', + return trailingText === '/'; + }, - // no real need to be async - fetchItems: async () => ({ - title: 'Chat Commands', - searchPrefix: '/', - items: findAllChatCommands().map((cmd) => ({ - key: cmd.primary, - label: cmd.primary, - argument: cmd.arguments?.join(' ') ?? undefined, - description: cmd.description, - Icon: cmd.Icon, - } satisfies ActileItem)), - }), + fetchItems: async (): ActileProviderItems => ({ + searchPrefix: '/', + items: findAllChatCommands().map((cmd) => ({ + key: cmd.primary, + providerKey: 'pcmd', + label: cmd.primary, + argument: cmd.arguments?.join(' ') ?? undefined, + description: cmd.description, + Icon: cmd.Icon, + } satisfies ActileItem)), + }), - onItemSelect: onCommandSelect, - }; -} \ No newline at end of file + onItemSelect: (item) => onCommandSelect(item as ActileItem, '/'), + +}); \ No newline at end of file diff --git a/src/apps/chat/components/composer/actile/providerStarredMessage.tsx b/src/apps/chat/components/composer/actile/providerStarredMessage.tsx index d7525bb43..b45402d29 100644 --- a/src/apps/chat/components/composer/actile/providerStarredMessage.tsx +++ b/src/apps/chat/components/composer/actile/providerStarredMessage.tsx @@ -2,7 +2,7 @@ import { conversationTitle, DConversationId } from '~/common/stores/chat/chat.co import { MESSAGE_FLAG_STARRED, messageFragmentsReduceText, messageHasUserFlag } from '~/common/stores/chat/chat.message'; import { useChatStore } from '~/common/stores/chat/store-chats'; -import { ActileItem, ActileProvider } from './ActileProvider'; +import type { ActileItem, ActileProvider, ActileProviderItems } from './ActileProvider'; export interface StarredMessageItem extends ActileItem { @@ -10,39 +10,44 @@ export interface StarredMessageItem extends ActileItem { messageId: string, } -export function providerStarredMessage(onMessageSeelect: (item: StarredMessageItem) => void): ActileProvider { - return { +export const providerStarredMessages = (onMessageSelect: (item: StarredMessageItem) => void): ActileProvider => ({ - // only the literal '@' at start of chat, or ' @' at end of chat - fastCheckTriggerText: (trailingText: string) => trailingText === '@' || trailingText.endsWith(' @'), + key: 'pstrmsg', - // finds all the starred messages in all the conversations - this could be heavy - fetchItems: async () => { - const { conversations } = useChatStore.getState(); + get label() { + return 'Starred Messages'; + }, - const starredMessages: StarredMessageItem[] = []; - conversations.forEach((conversation) => { - conversation.messages.forEach((message) => { - messageHasUserFlag(message, MESSAGE_FLAG_STARRED) && starredMessages.push({ - // data - conversationId: conversation.id, - messageId: message.id, - // looks - key: message.id, - label: conversationTitle(conversation) + ' - ' + messageFragmentsReduceText(message.fragments).slice(0, 32) + '...', - // description: message.text.slice(32, 100), - Icon: undefined, - } satisfies StarredMessageItem); - }); + // only the literal '@' at start of chat, or ' @' at end of chat + fastCheckTriggerText: (trailingText: string) => trailingText === '@' || trailingText.endsWith(' @'), + + // finds all the starred messages in all the conversations - this could be heavy + fetchItems: async (): ActileProviderItems => { + const { conversations } = useChatStore.getState(); + + const starredMessages: StarredMessageItem[] = []; + conversations.forEach((conversation) => { + conversation.messages.forEach((message) => { + messageHasUserFlag(message, MESSAGE_FLAG_STARRED) && starredMessages.push({ + key: message.id, + providerKey: 'pstrmsg', + // data + conversationId: conversation.id, + messageId: message.id, + // looks + label: conversationTitle(conversation) + ' - ' + messageFragmentsReduceText(message.fragments).slice(0, 32) + '...', + // description: message.text.slice(32, 100), + Icon: undefined, + } satisfies StarredMessageItem); }); + }); - return { - title: 'Starred Messages', - searchPrefix: '', - items: starredMessages, - }; - }, + return { + searchPrefix: '', + items: starredMessages, + }; + }, - onItemSelect: item => onMessageSeelect(item as StarredMessageItem), - }; -} \ No newline at end of file + onItemSelect: item => onMessageSelect(item as StarredMessageItem), + +}); \ No newline at end of file diff --git a/src/apps/chat/components/composer/actile/useActileManager.tsx b/src/apps/chat/components/composer/actile/useActileManager.tsx index 80c98572c..1fc750488 100644 --- a/src/apps/chat/components/composer/actile/useActileManager.tsx +++ b/src/apps/chat/components/composer/actile/useActileManager.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; -import { ActileItem, ActileProvider } from './ActileProvider'; + +import type { ActileItem, ActileProvider } from './ActileProvider'; import { ActilePopup } from './ActilePopup'; @@ -7,71 +8,74 @@ export const useActileManager = (providers: ActileProvider[], anchorRef: React.R // state const [popupOpen, setPopupOpen] = React.useState(false); - const [provider, setProvider] = React.useState(null); - - const [title, setTitle] = React.useState(''); - const [items, setItems] = React.useState([]); + const [itemsByProvider, setItemsByProvider] = React.useState<{ provider: ActileProvider, items: ActileItem[] }[]>([]); const [activeSearchString, setActiveSearchString] = React.useState(''); const [activeItemIndex, setActiveItemIndex] = React.useState(0); - // derived state - const activeItems = React.useMemo(() => { + const activeItemsByProvider = React.useMemo(() => { const search = activeSearchString.trim().toLowerCase(); - return items.filter(item => item.label?.toLowerCase().startsWith(search)); - }, [items, activeSearchString]); - const activeItem = activeItemIndex >= 0 && activeItemIndex < activeItems.length ? activeItems[activeItemIndex] : null; + return itemsByProvider.map(({ provider, items }) => ({ + provider, + items: items.filter(item => item.label?.toLowerCase().startsWith(search)), + })).filter(({ items }) => items.length > 0); + }, [itemsByProvider, activeSearchString]); + const flatActiveItems = React.useMemo(() => { + return activeItemsByProvider.flatMap(({ items }) => items); + }, [activeItemsByProvider]); + const totalItems = flatActiveItems.length; + const activeItem = totalItems > 0 && activeItemIndex >= 0 && activeItemIndex < totalItems ? flatActiveItems[activeItemIndex] : null; const handleClose = React.useCallback(() => { setPopupOpen(false); - setProvider(null); - setTitle(''); - setItems([]); + setItemsByProvider([]); setActiveSearchString(''); setActiveItemIndex(0); }, []); const handlePopupItemClicked = React.useCallback((item: ActileItem) => { + const provider = providers.find(p => p.key === item.providerKey); provider?.onItemSelect(item); handleClose(); - }, [handleClose, provider]); + }, [providers, handleClose]); const handleEnterKey = React.useCallback(() => { - activeItem && handlePopupItemClicked(activeItem); + if (activeItem) + handlePopupItemClicked(activeItem); }, [activeItem, handlePopupItemClicked]); - const actileInterceptTextChange = React.useCallback((trailingText: string) => { - for (const provider of providers) { - if (provider.fastCheckTriggerText(trailingText)) { - provider - .fetchItems() - .then(({ title, searchPrefix, items }) => { - // if there are no items, ignore - if (items.length) { - setPopupOpen(true); - setProvider(provider); - setTitle(title); - setItems(items); - setActiveSearchString(searchPrefix); - } - }) - .catch(error => { - handleClose(); - console.error('Failed to fetch popup items:', error); - }); - return true; - } + // Collect all providers whose trigger matches + const matchingProviders = providers.filter(provider => provider.fastCheckTriggerText(trailingText)); + + if (matchingProviders.length > 0) { + // Fetch items from all matching providers + Promise.all(matchingProviders.map(provider => + provider.fetchItems().then(({ searchPrefix, items }) => ({ + provider, + searchPrefix, + items: items.map(item => ({ ...item, providerKey: provider.key })), + })), + )).then((results) => { + // Filter out empty results + results = results.filter(result => result.items.length > 0); + if (results.length) { + setPopupOpen(true); + setItemsByProvider(results.map(result => ({ provider: result.provider, items: result.items }))); + setActiveSearchString(results[0].searchPrefix); // Assuming all search prefixes are the same + setActiveItemIndex(0); + } + }).catch(error => { + handleClose(); + console.error('Failed to fetch popup items:', error); + }); + return true; } return false; }, [handleClose, providers]); - const actileInterceptKeydown = React.useCallback((_event: React.KeyboardEvent): boolean => { - - // Popup open: Intercept - const { key, currentTarget, ctrlKey, metaKey } = _event; if (popupOpen) { @@ -80,11 +84,11 @@ export const useActileManager = (providers: ActileProvider[], anchorRef: React.R handleClose(); } else if (key === 'ArrowUp') { _event.preventDefault(); - setActiveItemIndex((prevIndex) => (prevIndex > 0 ? prevIndex - 1 : activeItems.length - 1)); + setActiveItemIndex((prevIndex) => (prevIndex > 0 ? prevIndex - 1 : totalItems - 1)); } else if (key === 'ArrowDown') { _event.preventDefault(); - setActiveItemIndex((prevIndex) => (prevIndex < activeItems.length - 1 ? prevIndex + 1 : 0)); - } else if (key === 'Enter' || key === 'ArrowRight' || key === 'Tab' || (key === ' ' && activeItems.length === 1)) { + setActiveItemIndex((prevIndex) => (prevIndex < totalItems - 1 ? prevIndex + 1 : 0)); + } else if (key === 'Enter' || key === 'ArrowRight' || key === 'Tab' || (key === ' ' && totalItems === 1)) { _event.preventDefault(); handleEnterKey(); } else if (key === 'Backspace') { @@ -100,26 +104,24 @@ export const useActileManager = (providers: ActileProvider[], anchorRef: React.R const trailingText = (currentTarget.value || '') + key; return actileInterceptTextChange(trailingText); - }, [actileInterceptTextChange, activeItems.length, handleClose, handleEnterKey, popupOpen]); - + }, [actileInterceptTextChange, handleClose, handleEnterKey, popupOpen, totalItems]); const actileComponent = React.useMemo(() => { return !popupOpen ? null : ( ); - }, [activeItemIndex, activeItems, activeSearchString.length, anchorRef, handleClose, handlePopupItemClicked, popupOpen, title]); + }, [activeItemIndex, activeItemsByProvider, activeSearchString.length, anchorRef, handleClose, handlePopupItemClicked, popupOpen]); return { actileComponent, actileInterceptKeydown, actileInterceptTextChange, }; -}; \ No newline at end of file +};