diff --git a/examples/vite/src/AppSettings/ActionsMenu/AttachmentPromptDialog.tsx b/examples/vite/src/AppSettings/ActionsMenu/AttachmentPromptDialog.tsx index e213d620e..9821b4444 100644 --- a/examples/vite/src/AppSettings/ActionsMenu/AttachmentPromptDialog.tsx +++ b/examples/vite/src/AppSettings/ActionsMenu/AttachmentPromptDialog.tsx @@ -30,7 +30,6 @@ const defaultUnsupportedObjectAttachment = { uploadState: 'finished', }, debug: true, - metadata: { randomNumber: 7, source: 'vite-preview' }, title: 'custom payload', type: 'custom', }; diff --git a/examples/vite/src/AppSettings/ActionsMenu/WebSocketEventPromptDialog/WebSocketEventPromptDialog.tsx b/examples/vite/src/AppSettings/ActionsMenu/WebSocketEventPromptDialog/WebSocketEventPromptDialog.tsx index 50b05fc17..58df34b49 100644 --- a/examples/vite/src/AppSettings/ActionsMenu/WebSocketEventPromptDialog/WebSocketEventPromptDialog.tsx +++ b/examples/vite/src/AppSettings/ActionsMenu/WebSocketEventPromptDialog/WebSocketEventPromptDialog.tsx @@ -1,5 +1,6 @@ import type { ReactNode, Ref } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { Event } from 'stream-chat'; import { Dropdown, type DropdownTriggerProps, @@ -808,7 +809,7 @@ export const WebSocketEventPromptDialog = ({ throw new Error('Payload must be a JSON object'); } - return parsedPayload as Record; + return parsedPayload as Partial; }, []); const emitConfiguredEvent = useCallback( diff --git a/examples/vite/src/AppSettings/ActionsMenu/WebSocketEventPromptDialog/types.ts b/examples/vite/src/AppSettings/ActionsMenu/WebSocketEventPromptDialog/types.ts index 9fae42590..868829595 100644 --- a/examples/vite/src/AppSettings/ActionsMenu/WebSocketEventPromptDialog/types.ts +++ b/examples/vite/src/AppSettings/ActionsMenu/WebSocketEventPromptDialog/types.ts @@ -1,3 +1,4 @@ +import type { ChannelMemberResponse, UserResponse } from 'stream-chat'; import type { SupportedWebsocketEventType } from './websocketEventTemplates'; export type PayloadMode = 'fixed' | 'fresh'; @@ -63,12 +64,12 @@ export type IntervalEmitter = { export type DialogMode = 'single' | 'pipeline' | 'intervals'; -export type SimulationUser = Record & { - id: string; +export type SimulationUser = UserResponse & { + invisible?: boolean; }; export type SimulationState = { - membersByCid: Record>>; + membersByCid: Record>; messageIdsByCid: Record; nextReactionTypeIndex: number; nextSequence: number; diff --git a/examples/vite/src/AppSettings/ActionsMenu/WebSocketEventPromptDialog/websocketEventAutomation.ts b/examples/vite/src/AppSettings/ActionsMenu/WebSocketEventPromptDialog/websocketEventAutomation.ts index 42036ef29..4c6577fed 100644 --- a/examples/vite/src/AppSettings/ActionsMenu/WebSocketEventPromptDialog/websocketEventAutomation.ts +++ b/examples/vite/src/AppSettings/ActionsMenu/WebSocketEventPromptDialog/websocketEventAutomation.ts @@ -1,14 +1,31 @@ -import type { Channel, StreamChat } from 'stream-chat'; +import type { + Channel, + ChannelMemberResponse, + Event, + MessageResponseBase, + ReactionResponse, + StreamChat, + UserResponse, +} from 'stream-chat'; import { - websocketEventTemplateDefinitions, type SupportedWebsocketEventType, type WebSocketEventTemplateContext, + websocketEventTemplateDefinitions, } from './websocketEventTemplates'; import type { SimulationState, SimulationUser } from './types'; -type JsonObject = Record; -type HandleEventArgument = Parameters[0]; +type UnknownRecord = Record; +type EventPayload = Omit< + Partial, + 'channel' | 'member' | 'message' | 'reaction' | 'user' +> & { + channel?: Partial; + member?: ChannelMemberResponse; + message?: Partial; + reaction?: ReactionResponse; + user?: UserResponse; +}; const messageTextFragments = [ 'debug event payload', @@ -20,12 +37,12 @@ const messageTextFragments = [ const reactionTypes = ['love', 'haha', 'wow', 'like', 'sad'] as const; -const asJsonObject = (value: unknown): JsonObject | undefined => { +const asJsonObject = (value: unknown): UnknownRecord | undefined => { if (!value || typeof value !== 'object' || Array.isArray(value)) { return undefined; } - return value as JsonObject; + return value as UnknownRecord; }; const getId = (value: unknown) => { @@ -45,10 +62,33 @@ const getMessageUser = (message: unknown) => asJsonObject(asJsonObject(message)? const getMessageMember = (message: unknown) => asJsonObject(asJsonObject(message)?.member); +const asUserResponse = (value: unknown): UserResponse | undefined => { + const user = asJsonObject(value); + + return typeof user?.id === 'string' ? (user as unknown as UserResponse) : undefined; +}; + +const asChannelMemberResponse = (value: unknown): ChannelMemberResponse | undefined => { + const member = asJsonObject(value); + + if (!member) return undefined; + + const userId = getId(member.user_id) ?? getUserId(member.user); + + return userId ? (member as unknown as ChannelMemberResponse) : undefined; +}; + const buildRandomMessageText = (sequence: number) => `${messageTextFragments[sequence % messageTextFragments.length]} #${sequence}`; -const buildReactionState = ({ reaction }: { reaction: JsonObject }): JsonObject => { +const buildReactionState = ({ + reaction, +}: { + reaction: ReactionResponse; +}): Pick< + MessageResponseBase, + 'latest_reactions' | 'reaction_counts' | 'reaction_groups' | 'reaction_scores' +> => { const reactionType = getId(reaction.type) ?? 'love'; const reactionScore = typeof reaction.score === 'number' && Number.isFinite(reaction.score) @@ -100,7 +140,7 @@ const getChannelMembersForCid = ( cid: string, simulationState: SimulationState, templateContext: WebSocketEventTemplateContext, -) => { +): ChannelMemberResponse[] => { const knownMembers = Object.values(simulationState.membersByCid[cid] ?? {}); if (knownMembers.length > 0) { @@ -122,7 +162,7 @@ const buildFreshContext = ( templateContext, ); const memberCount = channelMembers.length || templateContext.memberCount; - const baseChannel = asJsonObject(templateContext.channel) ?? {}; + const baseChannel = templateContext.channel; return { ...templateContext, @@ -212,9 +252,9 @@ const registerUserAndMember = ({ user, }: { cid: string; - member?: JsonObject; + member?: ChannelMemberResponse; simulationState: SimulationState; - user?: JsonObject; + user?: UserResponse; }) => { if (user) { const userId = getUserId(user); @@ -266,9 +306,9 @@ export const createInitialSimulationState = ({ registerUserAndMember({ cid, - member: templateContext.actorMember, + member: templateContext.actorMember as ChannelMemberResponse, simulationState: state, - user: templateContext.actor, + user: templateContext.actor as UserResponse, }); registerUserAndMember({ cid, @@ -284,9 +324,9 @@ export const createInitialSimulationState = ({ registerUserAndMember({ cid, - member: memberObject, + member: asChannelMemberResponse(memberObject), simulationState: state, - user: userObject, + user: asUserResponse(userObject), }); }); @@ -305,9 +345,9 @@ export const createInitialSimulationState = ({ registerUserAndMember({ cid, - member: getMessageMember(messageObject), + member: asChannelMemberResponse(getMessageMember(messageObject)), simulationState: state, - user: getMessageUser(messageObject), + user: asUserResponse(getMessageUser(messageObject)), }); }); @@ -322,11 +362,11 @@ export const buildFreshWebSocketEventPayload = ({ eventType: SupportedWebsocketEventType; simulationState: SimulationState; templateContext: WebSocketEventTemplateContext; -}): JsonObject => { +}): EventPayload => { const freshContext = buildFreshContext(templateContext, simulationState); const basePayload = websocketEventTemplateDefinitions[eventType].buildDefault( freshContext, - ) as JsonObject; + ) as EventPayload; switch (eventType) { case 'message.new': @@ -454,7 +494,7 @@ export const trackSimulationStateFromPayload = ({ simulationState, templateContext, }: { - payload: JsonObject; + payload: Event; simulationState: SimulationState; templateContext: WebSocketEventTemplateContext; }) => { @@ -471,15 +511,15 @@ export const trackSimulationStateFromPayload = ({ registerUserAndMember({ cid, - member: asJsonObject(payload.member), + member: asChannelMemberResponse(payload.member), simulationState, - user: asJsonObject(payload.user), + user: asUserResponse(payload.user), }); registerUserAndMember({ cid, - member: getMessageMember(message), + member: asChannelMemberResponse(getMessageMember(message)), simulationState, - user: getMessageUser(message), + user: asUserResponse(getMessageUser(message)), }); const channelObject = asJsonObject(payload.channel); @@ -494,9 +534,9 @@ export const trackSimulationStateFromPayload = ({ registerUserAndMember({ cid, - member, + member: asChannelMemberResponse(member), simulationState, - user: asJsonObject(member.user), + user: asUserResponse(member.user), }); }); } @@ -511,18 +551,16 @@ export const emitWebSocketEventPayload = ({ }: { client: StreamChat; eventType: SupportedWebsocketEventType; - payload: JsonObject; + payload: EventPayload; simulationState: SimulationState; templateContext: WebSocketEventTemplateContext; }) => { const emittedPayload = { ...payload, type: eventType, - }; + } as Event; - client.handleEvent({ - data: JSON.stringify(emittedPayload), - } as HandleEventArgument); + client.dispatchEvent(emittedPayload); trackSimulationStateFromPayload({ payload: emittedPayload, diff --git a/examples/vite/src/AppSettings/ActionsMenu/WebSocketEventPromptDialog/websocketEventTemplates.ts b/examples/vite/src/AppSettings/ActionsMenu/WebSocketEventPromptDialog/websocketEventTemplates.ts index 6b54283b9..c642fc7b9 100644 --- a/examples/vite/src/AppSettings/ActionsMenu/WebSocketEventPromptDialog/websocketEventTemplates.ts +++ b/examples/vite/src/AppSettings/ActionsMenu/WebSocketEventPromptDialog/websocketEventTemplates.ts @@ -1,6 +1,14 @@ -import type { Channel, StreamChat } from 'stream-chat'; +import type { + Channel, + ChannelMemberResponse, + ChannelResponse, + StreamChat, + UserResponse, +} from 'stream-chat'; type JsonObject = Record; +type DebugUserResponse = UserResponse & { invisible?: boolean }; +type DebugChannelResponse = ChannelResponse & { name?: string }; export const supportedWebsocketEventTypes = [ 'ai_indicator.clear', @@ -84,11 +92,11 @@ export type WebSocketEventPresetOption = { }; export type WebSocketEventTemplateContext = { - actor: JsonObject; - actorMember: JsonObject; + actor: DebugUserResponse; + actorMember: ChannelMemberResponse; actorId: string; - channel: JsonObject; - channelMembers: JsonObject[]; + channel: DebugChannelResponse; + channelMembers: ChannelMemberResponse[]; channelId: string; channelName: string; channelType: string; @@ -97,8 +105,8 @@ export type WebSocketEventTemplateContext = { lastReadAt: string; memberCount: number; messageId: string; - otherMember: JsonObject; - otherUser: JsonObject; + otherMember: ChannelMemberResponse; + otherUser: DebugUserResponse; parentMessageId: string; pollId: string; reactionType: string; @@ -108,7 +116,11 @@ export type WebSocketEventTemplateContext = { watcherCount: number; }; -const createFallbackUser = (id: string, createdAt: string): JsonObject => ({ +type BuildChannelSeedContext = Omit & { + channel: Partial; +}; + +const createFallbackUser = (id: string, createdAt: string): DebugUserResponse => ({ banned: false, blocked_user_ids: [], created_at: createdAt, @@ -124,10 +136,10 @@ const createFallbackUser = (id: string, createdAt: string): JsonObject => ({ updated_at: createdAt, }); -const getUserId = (user: JsonObject) => +const getUserId = (user: DebugUserResponse) => typeof user.id === 'string' ? user.id : 'debug-user'; -const createMember = (user: JsonObject): JsonObject => { +const createMember = (user: DebugUserResponse): ChannelMemberResponse => { const createdAt = typeof user.created_at === 'string' ? user.created_at : new Date().toISOString(); @@ -159,9 +171,9 @@ const getMemberUserId = (member: { user?: unknown; user_id?: unknown }) => { }; const buildChannel = ( - context: WebSocketEventTemplateContext, + context: BuildChannelSeedContext, overrides: JsonObject = {}, -): JsonObject => { +): DebugChannelResponse => { const createdAt = context.createdAt; return { @@ -274,7 +286,7 @@ const buildNotificationChannelMutesUpdatedPayload = ( const buildMuteEntry = ( context: WebSocketEventTemplateContext, - target: JsonObject = context.otherUser, + target: DebugUserResponse = context.otherUser, overrides: JsonObject = {}, ): JsonObject => ({ created_at: context.createdAt, @@ -749,7 +761,7 @@ export const createWebSocketEventTemplateContext = ({ const createdAt = new Date().toISOString(); const actorUser = client.user && typeof client.user === 'object' - ? ({ ...client.user } as JsonObject) + ? ({ ...client.user } as DebugUserResponse) : createFallbackUser('debug-user', createdAt); const actorId = typeof actorUser.id === 'string' ? actorUser.id : 'debug-user'; @@ -758,7 +770,7 @@ export const createWebSocketEventTemplateContext = ({ (member) => getMemberUserId(member) === actorId, ); const actorMember = actorMemberFromChannel - ? ({ ...actorMemberFromChannel } as JsonObject) + ? ({ ...actorMemberFromChannel } as ChannelMemberResponse) : createMember(actorUser); const otherMemberFromChannel = members.find( @@ -766,16 +778,16 @@ export const createWebSocketEventTemplateContext = ({ ); const otherUser = otherMemberFromChannel?.user && typeof otherMemberFromChannel.user === 'object' - ? ({ ...otherMemberFromChannel.user } as JsonObject) + ? ({ ...otherMemberFromChannel.user } as DebugUserResponse) : createFallbackUser('debug-other-user', createdAt); const otherMember = otherMemberFromChannel - ? ({ ...otherMemberFromChannel } as JsonObject) + ? ({ ...otherMemberFromChannel } as ChannelMemberResponse) : createMember(otherUser); const channelMembers = ( members.length - ? members.map((member) => ({ ...member }) as JsonObject) + ? members.map((member) => ({ ...member }) as ChannelMemberResponse) : [actorMember, otherMember] - ) as JsonObject[]; + ) as ChannelMemberResponse[]; const channelData = channel?.data as | { @@ -916,7 +928,7 @@ export const websocketEventTemplateDefinitions = { ), 'channel.updated': { buildDefault: (context) => buildChannelUpdatedRenamedPayload(context), - description: 'Update the active channel metadata.', + description: 'Update the active channel details.', }, 'channel.visible': { buildDefault: (context) => @@ -1367,7 +1379,7 @@ export const websocketEventTemplateDefinitions = { }, user: context.actor, }), - description: 'Update thread metadata for the active channel.', + description: 'Update thread details for the active channel.', }, 'typing.start': { buildDefault: (context) => diff --git a/package.json b/package.json index ed57ba272..e3d510362 100644 --- a/package.json +++ b/package.json @@ -104,7 +104,7 @@ "emoji-mart": "^5.4.0", "react": "^19.0.0 || ^18.0.0 || ^17.0.0", "react-dom": "^19.0.0 || ^18.0.0 || ^17.0.0", - "stream-chat": "^9.41.0" + "stream-chat": "^9.43.0" }, "peerDependenciesMeta": { "@breezystack/lamejs": { @@ -171,7 +171,7 @@ "react-dom": "^19.0.0", "sass": "^1.97.2", "semantic-release": "^25.0.3", - "stream-chat": "^9.41.1", + "stream-chat": "^9.43.0", "typescript": "^5.4.5", "typescript-eslint": "^8.17.0", "vite": "^7.3.1", diff --git a/src/components/Dialog/styling/ContextMenu.scss b/src/components/Dialog/styling/ContextMenu.scss index 9645ee42b..f42bbdfb2 100644 --- a/src/components/Dialog/styling/ContextMenu.scss +++ b/src/components/Dialog/styling/ContextMenu.scss @@ -199,6 +199,13 @@ &:disabled { background-color: transparent; + color: var(--str-chat__text-disabled); + cursor: default; + + .str-chat__context-menu__button__details, + .str-chat__icon { + color: inherit; + } } .str-chat__icon { @@ -211,7 +218,6 @@ @include utils.ellipsis-text; flex: auto; text-align: start; - color: var(--str-chat__text-primary); white-space: nowrap; min-width: 0; } diff --git a/src/components/MessageComposer/AttachmentSelector/AttachmentSelector.tsx b/src/components/MessageComposer/AttachmentSelector/AttachmentSelector.tsx index 8838df6cc..56ee5f43d 100644 --- a/src/components/MessageComposer/AttachmentSelector/AttachmentSelector.tsx +++ b/src/components/MessageComposer/AttachmentSelector/AttachmentSelector.tsx @@ -7,7 +7,11 @@ import React, { useRef, useState, } from 'react'; -import { useAttachmentManagerState, useMessageComposerController } from '../hooks'; +import { + useAttachmentManagerState, + useMessageComposerCommands, + useMessageComposerController, +} from '../hooks'; import { CHANNEL_CONTAINER_ID } from '../../Channel/constants'; import { ContextMenu, @@ -146,10 +150,14 @@ export const DefaultAttachmentSelectorComponents = { Command({ submenuHeader, submenuItems }: AttachmentSelectorActionProps) { const { t } = useTranslationContext(); const { openSubmenu } = useContextMenuContext(); + const commands = useMessageComposerCommands(); + const hasEnabledCommands = commands.some(({ enabled }) => enabled); const hasSubmenu = !!submenuItems; + return ( { diff --git a/src/components/MessageComposer/AttachmentSelector/CommandsMenu.tsx b/src/components/MessageComposer/AttachmentSelector/CommandsMenu.tsx index 5658eaeaf..2e37f2052 100644 --- a/src/components/MessageComposer/AttachmentSelector/CommandsMenu.tsx +++ b/src/components/MessageComposer/AttachmentSelector/CommandsMenu.tsx @@ -1,7 +1,7 @@ import React, { type ComponentProps, type ComponentType, useMemo } from 'react'; import type { CommandResponse } from 'stream-chat'; import { useMessageComposerContext, useTranslationContext } from '../../../context'; -import { useMessageComposerController } from '../hooks'; +import { useMessageComposerCommands, useMessageComposerController } from '../hooks'; import { ContextMenuBackButton, ContextMenuButton, @@ -56,25 +56,24 @@ export const CommandsMenu = () => { const { closeMenu } = useContextMenuContext(); const messageComposer = useMessageComposerController(); const { textareaRef } = useMessageComposerContext(); - const channelConfig = messageComposer.channel.getConfig(); - const commands = useMemo<(CommandResponse & { name: string })[]>( + const commands = useMessageComposerCommands(); + const sortedCommands = useMemo( () => - (channelConfig?.commands ?? []) - .filter( - (command): command is CommandResponse & { name: string } => !!command.name, - ) - .sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')), - [channelConfig], + [...commands].sort((a, b) => + (a.command.name ?? '').localeCompare(b.command.name ?? ''), + ), + [commands], ); return ( <> - {commands.map((command) => ( + {sortedCommands.map(({ command, enabled }) => ( { - if (!command.name) return; + if (!command.name || !enabled) return; messageComposer.textComposer.setCommand(command); closeMenu(); // Defer the focus to the next frame so it wins over FocusScope's restore-to-attachment-selector-button behavior. @@ -122,20 +121,26 @@ export const useCommandTranslation = (command: CommandResponse) => { export const CommandContextMenuItem = ({ className, command, + enabled = true, ...props }: ComponentProps<'button'> & { command: CommandResponse & { name: string }; + enabled?: boolean; }) => { const { args, description } = useCommandTranslation(command); // todo: retrieve the command trigger char from textComposer - needed adjustment in LLC - const details = useMemo(() => `/${command.name} ${args}`, [args, command.name]); + const details = useMemo( + () => (args ? `/${command.name} ${args}` : `/${command.name}`), + [args, command.name], + ); return ( { expect(menu).toHaveTextContent('Location'); }); + it('keeps Commands visible and disables it when all commands are unavailable', async () => { + const disabledCommand = fromPartial({ + args: 'ban-command-args', + description: 'ban-command-description', + name: 'ban', + set: 'moderation_set', + }); + const { + channels: [customChannel], + client: customClient, + } = await initClientWithChannels({ + channelsData: [ + { + channel: { + ...defaultChannelData, + cid: 'type:id', + config: { + commands: [disabledCommand], + polls: false, + shared_locations: false, + uploads: false, + }, + id: 'id', + type: 'type', + }, + }, + ], + }); + + customChannel.messageComposer.initState({ + composition: generateMessage({ text: 'editing' }), + }); + + await renderComponent({ + channelStateContext: { channelCapabilities: {} }, + customChannel, + customClient, + }); + + await invokeMenu(); + + const menu = screen.getByTestId(ATTACHMENT_SELECTOR__ACTIONS_MENU_TEST_ID); + const commandsButton = menu.querySelector(`.${COMMANDS_BUTTON_CLASS}`); + + expect(commandsButton).toBeDisabled(); + }); + it('renders with poll only if only polls are enabled', async () => { const { channels: [customChannel], diff --git a/src/components/MessageComposer/__tests__/MessageInput.test.tsx b/src/components/MessageComposer/__tests__/MessageInput.test.tsx index 516202730..4139736de 100644 --- a/src/components/MessageComposer/__tests__/MessageInput.test.tsx +++ b/src/components/MessageComposer/__tests__/MessageInput.test.tsx @@ -39,6 +39,7 @@ import { QuotedMessagePreview } from '../QuotedMessagePreview'; import type { Attachment, Channel as ChannelType, + CommandResponse, CooldownTimerState, LinkPreviewsManagerState, LocalAttachment, @@ -1377,6 +1378,28 @@ describe(`MessageInputFlat`, () => { }); quotedMessagePreviewIsNotDisplayed(mainListMessage); }); + + it('clears active command when quoting makes it unavailable', async () => { + const { channel } = await renderComponent(); + const command = fromPartial({ + args: 'ban-command-args', + description: 'ban-command-description', + name: 'ban', + set: 'moderation_set', + }); + + await act(() => { + channel.messageComposer.textComposer.setCommand(command); + }); + + expect(screen.getByText('ban')).toBeInTheDocument(); + + await initQuotedMessagePreview(mainListMessage); + + await waitFor(() => { + expect(screen.queryByText('ban')).not.toBeInTheDocument(); + }); + }); }); describe('send button', () => { @@ -1554,5 +1577,73 @@ describe(`MessageInputFlat`, () => { expect(textarea).toHaveValue(''); }); }); + + it('should clear active command when entering edit mode', async () => { + const { channel } = await renderComponent(); + const command = fromPartial({ + args: 'giphy-command-args', + description: 'giphy-command-description', + name: 'giphy', + }); + + await act(() => { + channel.messageComposer.textComposer.setCommand(command); + }); + + expect(screen.getByText('giphy')).toBeInTheDocument(); + + await enterEditMode(); + + await waitFor(() => { + expect(screen.queryByText('giphy')).not.toBeInTheDocument(); + }); + }); + + it('should not render command suggestions when all commands are disabled', async () => { + const scrollIntoView = Element.prototype.scrollIntoView; + // eslint-disable-next-line vitest/prefer-spy-on + Element.prototype.scrollIntoView = vi.fn(); + + await renderComponent(); + const textarea = await screen.findByPlaceholderText(inputPlaceholder); + + await act(async () => { + await fireEvent.change(textarea, { + target: { + selectionEnd: 1, + selectionStart: 1, + value: '/', + }, + }); + }); + + await waitFor(() => { + expect(screen.getByText('giphy')).toBeInTheDocument(); + }); + + await enterEditMode(); + + await waitFor(() => { + expect(screen.queryByText('giphy')).not.toBeInTheDocument(); + }); + + await act(async () => { + await fireEvent.change(textarea, { + target: { + selectionEnd: 1, + selectionStart: 1, + value: '/', + }, + }); + }); + + await waitFor(() => { + expect( + document.querySelector('.str-chat__suggestion-list'), + ).not.toBeInTheDocument(); + }); + + Element.prototype.scrollIntoView = scrollIntoView; + }); }); }); diff --git a/src/components/MessageComposer/hooks/__tests__/useMessageComposerCommands.test.tsx b/src/components/MessageComposer/hooks/__tests__/useMessageComposerCommands.test.tsx new file mode 100644 index 000000000..d39cc518f --- /dev/null +++ b/src/components/MessageComposer/hooks/__tests__/useMessageComposerCommands.test.tsx @@ -0,0 +1,119 @@ +import { act, renderHook } from '@testing-library/react'; +import { fromPartial } from '@total-typescript/shoehorn'; +import type { CommandResponse, MessageComposerState } from 'stream-chat'; +import { StateStore } from 'stream-chat'; + +import { useMessageComposerCommands } from '../useMessageComposerCommands'; + +const mockedUseMessageComposerController = vi.hoisted(() => vi.fn()); + +let commands: CommandResponse[]; +let messageComposer: { + channel: { getConfig: ReturnType }; + getCommandDisabledReason: ReturnType; + isCommandDisabled: ReturnType; + state: StateStore; +}; +let state: StateStore; + +vi.mock('../useMessageComposerController', () => ({ + useMessageComposerController: mockedUseMessageComposerController, +})); + +describe('useMessageComposerCommands', () => { + beforeEach(() => { + state = new StateStore( + fromPartial({ + editedMessage: null, + quotedMessage: null, + }) as MessageComposerState, + ); + commands = [ + fromPartial({ + args: 'giphy-command-args', + description: 'giphy-command-description', + name: 'giphy', + }), + fromPartial({ + args: 'ban-command-args', + description: 'ban-command-description', + name: 'ban', + set: 'moderation_set', + }), + fromPartial({ + description: 'missing-name', + }), + ]; + messageComposer = { + channel: { + getConfig: vi.fn(() => ({ + commands, + })), + }, + getCommandDisabledReason: vi.fn((command: CommandResponse) => { + const latestState = state.getLatestValue(); + + if (latestState.editedMessage) { + return 'editing'; + } + + if ( + latestState.quotedMessage && + (command.set === 'moderation_set' || command.name === 'moderation_set') + ) { + return 'quoted_message'; + } + + return undefined; + }), + isCommandDisabled: vi.fn( + (command: CommandResponse) => !!messageComposer.getCommandDisabledReason(command), + ), + state, + }; + mockedUseMessageComposerController.mockReturnValue(messageComposer); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('returns named commands with enabled state', () => { + const { result } = renderHook(() => useMessageComposerCommands()); + + expect(result.current).toEqual([ + { command: expect.objectContaining({ name: 'giphy' }), enabled: true }, + { command: expect.objectContaining({ name: 'ban' }), enabled: true }, + ]); + }); + + it('updates when entering edit mode and disables all commands', () => { + const { result } = renderHook(() => useMessageComposerCommands()); + + act(() => { + state.partialNext({ + editedMessage: fromPartial({ id: 'edited-message-id' }), + }); + }); + + expect(result.current).toEqual([ + { command: expect.objectContaining({ name: 'giphy' }), enabled: false }, + { command: expect.objectContaining({ name: 'ban' }), enabled: false }, + ]); + }); + + it('marks quoted-message-disabled commands as disabled while keeping allowed ones enabled', () => { + const { result } = renderHook(() => useMessageComposerCommands()); + + act(() => { + state.partialNext({ + quotedMessage: fromPartial({ id: 'quoted-message-id' }), + }); + }); + + expect(result.current).toEqual([ + { command: expect.objectContaining({ name: 'giphy' }), enabled: true }, + { command: expect.objectContaining({ name: 'ban' }), enabled: false }, + ]); + }); +}); diff --git a/src/components/MessageComposer/hooks/index.ts b/src/components/MessageComposer/hooks/index.ts index a09746846..14fb3d5a1 100644 --- a/src/components/MessageComposer/hooks/index.ts +++ b/src/components/MessageComposer/hooks/index.ts @@ -3,6 +3,7 @@ export * from './useAttachmentsForPreview'; export * from './useCanCreatePoll'; export * from './useCooldownRemaining'; export * from './useMessageComposerBindings'; +export * from './useMessageComposerCommands'; export * from './useMessageComposerController'; export * from './useMessageComposerHasSendableData'; export * from './useMessageContentIsEmpty'; diff --git a/src/components/MessageComposer/hooks/useMessageComposerCommands.ts b/src/components/MessageComposer/hooks/useMessageComposerCommands.ts new file mode 100644 index 000000000..94e7b41fe --- /dev/null +++ b/src/components/MessageComposer/hooks/useMessageComposerCommands.ts @@ -0,0 +1,42 @@ +import { useMemo } from 'react'; +import type { CommandResponse, MessageComposerState } from 'stream-chat'; + +import { useStateStore } from '../../../store'; +import { useMessageComposerController } from './useMessageComposerController'; + +const messageComposerStateSelector = ({ + editedMessage, + quotedMessage, +}: MessageComposerState) => ({ + editedMessage, + quotedMessage, +}); + +export type MessageComposerCommand = { + command: CommandResponse & { name: string }; + enabled: boolean; +}; + +export const useMessageComposerCommands = () => { + const messageComposer = useMessageComposerController(); + const channelConfig = messageComposer.channel.getConfig(); + const { editedMessage, quotedMessage } = useStateStore( + messageComposer.state, + messageComposerStateSelector, + ); + + return useMemo( + () => + (channelConfig?.commands ?? []) + .filter( + (command): command is CommandResponse & { name: string } => !!command.name, + ) + .map((command) => ({ + command, + enabled: !messageComposer.isCommandDisabled(command), + })), + // editedMessage and quotedMessage are necessary in deps for reactivity + // eslint-disable-next-line react-hooks/exhaustive-deps + [channelConfig, editedMessage, messageComposer, quotedMessage], + ); +}; diff --git a/src/components/TextareaComposer/SuggestionList/CommandItem.tsx b/src/components/TextareaComposer/SuggestionList/CommandItem.tsx index d7599e97f..5a64e5e9f 100644 --- a/src/components/TextareaComposer/SuggestionList/CommandItem.tsx +++ b/src/components/TextareaComposer/SuggestionList/CommandItem.tsx @@ -1,23 +1,41 @@ import type { ComponentProps, PropsWithChildren } from 'react'; import React from 'react'; -import type { CommandResponse } from 'stream-chat'; +import type { CommandResponse, MessageComposerState } from 'stream-chat'; import { CommandContextMenuItem } from '../../MessageComposer/AttachmentSelector/CommandsMenu'; +import { useStateStore } from '../../../store'; +import { useMessageComposerController } from '../../MessageComposer/hooks'; export type CommandItemProps = { entity: CommandResponse; + enabled?: boolean; focused?: boolean; } & ComponentProps<'button'>; +const messageComposerStateSelector = ({ + editedMessage, + quotedMessage, +}: MessageComposerState) => ({ + editedMessage, + quotedMessage, +}); + export const CommandItem = (props: PropsWithChildren) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { entity, focused: _, ...buttonProps } = props; + const { enabled, entity, focused: _, ...buttonProps } = props; + const messageComposer = useMessageComposerController(); + useStateStore(messageComposer.state, messageComposerStateSelector); if (!entity.name) return null; + const resolvedEnabled = + enabled ?? + !messageComposer.isCommandDisabled(entity as CommandResponse & { name: string }); + return ( ); }; diff --git a/src/components/TextareaComposer/SuggestionList/SuggestionList.tsx b/src/components/TextareaComposer/SuggestionList/SuggestionList.tsx index a49bd8a60..645815548 100644 --- a/src/components/TextareaComposer/SuggestionList/SuggestionList.tsx +++ b/src/components/TextareaComposer/SuggestionList/SuggestionList.tsx @@ -24,7 +24,10 @@ import type { ContextMenuItemComponent, ContextMenuItemProps } from '../../Dialo import { ContextMenu } from '../../Dialog'; import { usePopoverPosition } from '../../Dialog/hooks/usePopoverPosition'; import { InfiniteScrollPaginator } from '../../InfiniteScrollPaginator/InfiniteScrollPaginator'; -import { useMessageComposerController } from '../../MessageComposer/hooks/useMessageComposerController'; +import { + useMessageComposerCommands, + useMessageComposerController, +} from '../../MessageComposer/hooks'; import { useTranslationContext } from '../../../context'; import type { SearchSourceState, @@ -91,6 +94,7 @@ export const SuggestionList = ({ } = useComponentContext(); const { textareaRef } = useMessageComposerContext(); const messageComposer = useMessageComposerController(); + const commands = useMessageComposerCommands(); const { textComposer } = messageComposer; const { selection, suggestions } = useStateStore( textComposer.state, @@ -98,6 +102,12 @@ export const SuggestionList = ({ ); const { items } = useStateStore(suggestions?.searchSource.state, searchSourceStateSelector) ?? {}; + const hasEnabledCommandSuggestions = useMemo( + () => + suggestions?.searchSource.type !== 'commands' || + commands.some(({ enabled }) => enabled), + [commands, suggestions?.searchSource.type], + ); const [container, setContainer] = useState(null); const caretRectRef = useRef(null); @@ -207,7 +217,8 @@ export const SuggestionList = ({ virtualCaretReference, ]); - if (!suggestions || !items?.length || !component) return null; + if (!suggestions || !items?.length || !component || !hasEnabledCommandSuggestions) + return null; const suggestionMenuLabel = suggestions.searchSource.type === 'commands' diff --git a/src/components/TextareaComposer/__tests__/CommandItem.test.tsx b/src/components/TextareaComposer/__tests__/CommandItem.test.tsx index df8e30e88..0b8d2c0a1 100644 --- a/src/components/TextareaComposer/__tests__/CommandItem.test.tsx +++ b/src/components/TextareaComposer/__tests__/CommandItem.test.tsx @@ -6,9 +6,34 @@ import type { CommandResponse } from 'stream-chat'; import { CommandItem } from '../SuggestionList'; +const isCommandDisabledMock = vi.fn(); + +vi.mock('../../MessageComposer/hooks', () => ({ + useMessageComposerController: () => ({ + isCommandDisabled: isCommandDisabledMock, + state: { + getLatestValue: () => ({ + editedMessage: null, + quotedMessage: null, + }), + subscribeWithSelector: () => () => undefined, + }, + }), +})); + +vi.mock('../../../context', () => ({ + useTranslationContext: () => ({ + t: (key: string) => key, + }), +})); + afterEach(cleanup); describe('commandItem', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('should render nothing with empty entity (no name)', () => { const { container } = render(); // CommandItem returns null when entity.name is falsy @@ -41,4 +66,24 @@ describe('commandItem', () => { container.querySelector('.str-chat__context-menu__button__details'), ).toHaveTextContent(`/${entity.name} ${entity.args}`); }); + + it('renders disabled state for unavailable commands', () => { + const entity = fromPartial({ + args: 'args', + description: 'description', + name: 'name', + }); + + isCommandDisabledMock.mockReturnValue(true); + + const { container } = render(); + const button = container.querySelector( + 'button.str-chat__context-menu__button--command', + ); + + expect(button).toBeDisabled(); + expect( + container.querySelector('.str-chat__context-menu__button__details'), + ).toHaveTextContent(`/${entity.name} ${entity.args}`); + }); }); diff --git a/src/i18n/TranslationBuilder/notifications/translators.ts b/src/i18n/TranslationBuilder/notifications/translators.ts index 60b8c5372..1dc278147 100644 --- a/src/i18n/TranslationBuilder/notifications/translators.ts +++ b/src/i18n/TranslationBuilder/notifications/translators.ts @@ -71,3 +71,20 @@ export const translateBrowserAudioPlaybackError: Translator< NotificationTranslatorOptions > = ({ options: { notification }, t }) => notification?.message ? t(notification.message) : t('Error reproducing the recording'); + +export const translateCommandDisabled: Translator = ({ + options: { notification }, + t, +}) => { + const reason = normalizeReason(notification); + + if (reason === 'editing') { + return t('Command not available while editing'); + } + + if (reason === 'quoted_message') { + return t('Command not available while replying'); + } + + return t(notification?.message || 'Command not available'); +}; diff --git a/src/i18n/TranslationBuilder/notifications/translatorsByNotificationType.ts b/src/i18n/TranslationBuilder/notifications/translatorsByNotificationType.ts index e01633e71..4707a0da3 100644 --- a/src/i18n/TranslationBuilder/notifications/translatorsByNotificationType.ts +++ b/src/i18n/TranslationBuilder/notifications/translatorsByNotificationType.ts @@ -3,6 +3,7 @@ import { translateAttachmentUploadBlocked, translateAttachmentUploadFailed, translateBrowserAudioPlaybackError, + translateCommandDisabled, translatePollCreateFailed, translatePollEndFailed, } from './translators'; @@ -30,6 +31,7 @@ export const translatorsByNotificationType: Record< 'validation:attachment:upload:blocked': translateAttachmentUploadBlocked, 'validation:attachment:upload:in-progress': ({ t }) => t('Wait until all attachments have uploaded'), + 'validation:command:disabled': translateCommandDisabled, 'validation:poll:castVote:limit': ({ t }) => t('Reached the vote limit. Remove an existing vote first.'), }; diff --git a/src/i18n/de.json b/src/i18n/de.json index d1dbb69c3..dcdbb0c57 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -166,6 +166,8 @@ "Close dialog": "Dialog schließen", "Close emoji picker": "Emoji-Auswahl schließen", "Close prompt: {{ title }}": "Eingabeaufforderung schließen: {{ title }}", + "Command not available while editing": "Befehl beim Bearbeiten nicht verfügbar", + "Command not available while replying": "Befehl beim Antworten nicht verfügbar", "Commands": "Befehle", "Commands matching": "Übereinstimmende Befehle", "Connection failure, reconnecting now...": "Verbindungsfehler, Wiederherstellung der Verbindung...", diff --git a/src/i18n/en.json b/src/i18n/en.json index 1a13197f5..a633d9d57 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -166,6 +166,8 @@ "Close dialog": "Close dialog", "Close emoji picker": "Close emoji picker", "Close prompt: {{ title }}": "Close prompt: {{ title }}", + "Command not available while editing": "Command not available while editing", + "Command not available while replying": "Command not available while replying", "Commands": "Commands", "Commands matching": "Commands matching", "Connection failure, reconnecting now...": "Connection failure, reconnecting now...", diff --git a/src/i18n/es.json b/src/i18n/es.json index 0796c9d4e..c13c395f1 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -174,6 +174,8 @@ "Close dialog": "Cerrar diálogo", "Close emoji picker": "Cerrar el selector de emojis", "Close prompt: {{ title }}": "Cerrar diálogo: {{ title }}", + "Command not available while editing": "Comando no disponible durante la edición", + "Command not available while replying": "Comando no disponible mientras se responde", "Commands": "Comandos", "Commands matching": "Coincidencia de comandos", "Connection failure, reconnecting now...": "Fallo de conexión, reconectando ahora...", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 3c79ee0ab..8a72663fa 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -174,6 +174,8 @@ "Close dialog": "Fermer la boîte de dialogue", "Close emoji picker": "Fermer le sélecteur d'émojis", "Close prompt: {{ title }}": "Fermer l'invite : {{ title }}", + "Command not available while editing": "Commande non disponible pendant la modification", + "Command not available while replying": "Commande non disponible pendant la réponse", "Commands": "Commandes", "Commands matching": "Correspondance des commandes", "Connection failure, reconnecting now...": "Échec de la connexion, reconnexion en cours...", diff --git a/src/i18n/hi.json b/src/i18n/hi.json index 563e4a86a..155411a0d 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -166,6 +166,8 @@ "Close dialog": "डायलॉग बंद करें", "Close emoji picker": "इमोजी पिकर बंद करें", "Close prompt: {{ title }}": "प्रॉम्प्ट बंद करें: {{ title }}", + "Command not available while editing": "संपादन के दौरान कमांड उपलब्ध नहीं है", + "Command not available while replying": "उत्तर देते समय कमांड उपलब्ध नहीं है", "Commands": "कमांड", "Commands matching": "मेल खाती है", "Connection failure, reconnecting now...": "कनेक्शन विफल रहा, अब पुनः कनेक्ट हो रहा है ...", diff --git a/src/i18n/it.json b/src/i18n/it.json index d3f920244..95df4f192 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -174,6 +174,8 @@ "Close dialog": "Chiudi finestra di dialogo", "Close emoji picker": "Chiudi il selettore di emoji", "Close prompt: {{ title }}": "Chiudi prompt: {{ title }}", + "Command not available while editing": "Comando non disponibile durante la modifica", + "Command not available while replying": "Comando non disponibile durante la risposta", "Commands": "Comandi", "Commands matching": "Comandi corrispondenti", "Connection failure, reconnecting now...": "Errore di connessione, riconnessione in corso...", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index d7cf0f78d..ab25b1ad1 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -165,6 +165,8 @@ "Close dialog": "ダイアログを閉じる", "Close emoji picker": "絵文字ピッカーを閉める", "Close prompt: {{ title }}": "プロンプトを閉じる: {{ title }}", + "Command not available while editing": "編集中はコマンドを使用できません", + "Command not available while replying": "返信中はコマンドを使用できません", "Commands": "コマンド", "Commands matching": "一致するコマンド", "Connection failure, reconnecting now...": "接続が失敗しました。再接続中...", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index 81f0dbbe7..86ad2c373 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -165,6 +165,8 @@ "Close dialog": "대화 상자 닫기", "Close emoji picker": "이모티콘 선택기 닫기", "Close prompt: {{ title }}": "프롬프트 닫기: {{ title }}", + "Command not available while editing": "편집 중에는 명령을 사용할 수 없습니다", + "Command not available while replying": "답장 중에는 명령을 사용할 수 없습니다", "Commands": "명령어", "Commands matching": "일치하는 명령", "Connection failure, reconnecting now...": "연결 실패, 지금 다시 연결 중...", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index 63f0584f4..d3469e9a6 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -166,6 +166,8 @@ "Close dialog": "Dialoog sluiten", "Close emoji picker": "Sluit de emoji-kiezer", "Close prompt: {{ title }}": "Prompt sluiten: {{ title }}", + "Command not available while editing": "Opdracht niet beschikbaar tijdens bewerken", + "Command not available while replying": "Opdracht niet beschikbaar tijdens beantwoorden", "Commands": "Commando's", "Commands matching": "Bijpassende opdrachten", "Connection failure, reconnecting now...": "Verbindingsfout, opnieuw verbinden...", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index fed1f1543..baedc52a6 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -174,6 +174,8 @@ "Close dialog": "Fechar diálogo", "Close emoji picker": "Fechar seletor de emoji", "Close prompt: {{ title }}": "Fechar prompt: {{ title }}", + "Command not available while editing": "Comando não disponível durante a edição", + "Command not available while replying": "Comando não disponível durante a resposta", "Commands": "Comandos", "Commands matching": "Comandos correspondentes", "Connection failure, reconnecting now...": "Falha de conexão, reconectando agora...", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 8f737766f..48c7a842f 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -183,6 +183,8 @@ "Close dialog": "Закрыть диалог", "Close emoji picker": "Закрыть окно выбора смайлов", "Close prompt: {{ title }}": "Закрыть запрос: {{ title }}", + "Command not available while editing": "Команда недоступна при редактировании", + "Command not available while replying": "Команда недоступна при ответе", "Commands": "Команды", "Commands matching": "Соответствие команд", "Connection failure, reconnecting now...": "Ошибка соединения, переподключение...", diff --git a/src/i18n/tr.json b/src/i18n/tr.json index 54c7c9f01..913a12500 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -166,6 +166,8 @@ "Close dialog": "İletişim kutusunu kapat", "Close emoji picker": "Emoji seçiciyi kapat", "Close prompt: {{ title }}": "İstemi kapat: {{ title }}", + "Command not available while editing": "Düzenleme sırasında komut kullanılamaz", + "Command not available while replying": "Yanıtlama sırasında komut kullanılamaz", "Commands": "Komutlar", "Commands matching": "Eşleşen komutlar", "Connection failure, reconnecting now...": "Bağlantı hatası, tekrar bağlanılıyor...", diff --git a/yarn.lock b/yarn.lock index cc38c573b..6a182b1ca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7745,10 +7745,10 @@ stdin-discarder@^0.2.2: resolved "https://registry.yarnpkg.com/stdin-discarder/-/stdin-discarder-0.2.2.tgz#390037f44c4ae1a1ae535c5fe38dc3aba8d997be" integrity sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ== -stream-chat@^9.41.1: - version "9.42.0" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.42.0.tgz#ee0dafa01e9a306d5328edcd6c3e42c52f4c1a68" - integrity sha512-EwqCwo2VtZpX+6gx5vKqzKZ2L5VKovj8SSPdZXPejuR+gPhSnbzrgZCK5mChDvDEBqzPILInqlzyoevcs7JLYw== +stream-chat@^9.43.0: + version "9.43.1" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.43.1.tgz#5b2cccdd95ce92cc44c6691c527eeee271ce37bd" + integrity sha512-lP1B3ulv2B20tqbn0xWUaVuKgBPAtgiKRGTBgmZsAIcOKDziR0xbYmZuC8zo9+L6yPh3euSdbF5w+CQ/Rn1FiQ== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14"