diff --git a/src/components/Chat/__tests__/ChatScheduledMessages.test.tsx b/src/components/Chat/__tests__/ChatScheduledMessages.test.tsx new file mode 100644 index 0000000000..8b5e3eefd9 --- /dev/null +++ b/src/components/Chat/__tests__/ChatScheduledMessages.test.tsx @@ -0,0 +1,226 @@ +import React, { StrictMode } from 'react'; +import { act, cleanup, render } from '@testing-library/react'; +import { ErrorFromResponse } from 'stream-chat'; +import type { APIErrorResponse, StreamChat } from 'stream-chat'; + +import { Chat } from '..'; +import { + dispatchConnectionChangedEvent, + dispatchConnectionRecoveredEvent, + dispatchScheduledMessageEvent, + generateScheduledMessage, + getTestClient, + getTestClientWithUser, +} from '../../../mock-builders'; + +const renderChat = async (client: StreamChat, { strict = false } = {}) => { + const chat = {null}; + + let result!: ReturnType; + await act(() => { + result = render(strict ? {chat} : chat); + }); + return result; +}; + +const countListeners = (client: StreamChat, type: string) => + client.listeners[type]?.length ?? 0; + +const featureDisabledError = () => + new ErrorFromResponse('failed', { + code: 114, + response: { + data: { + code: 114, + duration: '', + exception_fields: { reason: 'scheduled_messages_disabled' }, + message: 'failed', + more_info: '', + StatusCode: 400, + }, + } as ErrorFromResponse['response'], + status: 400, + }); + +describe('Chat scheduled messages wiring', () => { + afterEach(cleanup); + + it('loads the scheduled messages once the user is connected', async () => { + const client = await getTestClientWithUser({ id: 'user-1' }); + + await renderChat(client); + + expect(client.queryScheduledMessages).toHaveBeenCalledTimes(1); + expect(client.queryScheduledMessages).toHaveBeenCalledWith({ limit: 100 }); + expect(client.scheduledMessages.state.getLatestValue().isLoaded).toBe(true); + }); + + it('does not load before a user is connected', async () => { + const client = getTestClient(); + + await renderChat(client); + + expect(client.queryScheduledMessages).not.toHaveBeenCalled(); + }); + + it('loads when a user connects after the first render, without a re-render', async () => { + const client = getTestClient(); + await renderChat(client); + + await client.connectUser({ id: 'user-1' }, 'token'); + await act(() => { + dispatchConnectionChangedEvent(client, true); + }); + + expect(client.queryScheduledMessages).toHaveBeenCalledTimes(1); + }); + + it('does not load when the connection goes offline', async () => { + const client = getTestClient(); + await renderChat(client); + + await client.connectUser({ id: 'user-1' }, 'token'); + await act(() => { + dispatchConnectionChangedEvent(client, false); + }); + + expect(client.queryScheduledMessages).not.toHaveBeenCalled(); + }); + + it('does not load for an anonymous user', async () => { + const client = await getTestClientWithUser({ id: '!anon' }); + client.anonymous = true; + + await renderChat(client); + + expect(client.queryScheduledMessages).not.toHaveBeenCalled(); + }); + + it('retries a failed load when the connection comes back', async () => { + const client = await getTestClientWithUser({ id: 'user-1' }); + vi.mocked(client.queryScheduledMessages).mockRejectedValueOnce(new Error('offline')); + await renderChat(client); + + await act(() => { + dispatchConnectionChangedEvent(client, true); + }); + + expect(client.queryScheduledMessages).toHaveBeenCalledTimes(2); + expect(client.scheduledMessages.state.getLatestValue().isLoaded).toBe(true); + }); + + it('retries a failed load on connection.recovered', async () => { + const client = await getTestClientWithUser({ id: 'user-1' }); + vi.mocked(client.queryScheduledMessages).mockRejectedValueOnce(new Error('offline')); + await renderChat(client); + + await act(() => { + dispatchConnectionRecoveredEvent(client); + }); + + expect(client.scheduledMessages.state.getLatestValue().isLoaded).toBe(true); + }); + + it('does not retry or warn when the feature is disabled', async () => { + const client = await getTestClientWithUser({ id: 'user-1' }); + vi.mocked(client.queryScheduledMessages).mockRejectedValueOnce( + featureDisabledError(), + ); + const loggerSpy = vi.spyOn(client, 'logger'); + await renderChat(client); + + await act(() => { + dispatchConnectionChangedEvent(client, true); + }); + + expect(client.queryScheduledMessages).toHaveBeenCalledTimes(1); + expect(client.scheduledMessages.state.getLatestValue().isDisabled).toBe(true); + expect(loggerSpy).not.toHaveBeenCalledWith( + 'warn', + 'scheduledMessages: load failed', + expect.anything(), + ); + }); + + it('does not load again when the connection comes back after a successful load', async () => { + const client = await getTestClientWithUser({ id: 'user-1' }); + await renderChat(client); + + await act(() => { + dispatchConnectionChangedEvent(client, true); + }); + + expect(client.queryScheduledMessages).toHaveBeenCalledTimes(1); + }); + + it('does not load again on re-render', async () => { + const client = await getTestClientWithUser({ id: 'user-1' }); + const { rerender } = await renderChat(client); + + await act(() => { + rerender({null}); + }); + + expect(client.queryScheduledMessages).toHaveBeenCalledTimes(1); + }); + + it('queries and subscribes once under StrictMode', async () => { + const client = await getTestClientWithUser({ id: 'user-1' }); + + await renderChat(client, { strict: true }); + + expect(client.queryScheduledMessages).toHaveBeenCalledTimes(1); + expect(countListeners(client, 'scheduled_message.created')).toBe(1); + expect(countListeners(client, 'scheduled_message.sent')).toBe(1); + }); + + it('keeps the store in sync from events while mounted', async () => { + const client = await getTestClientWithUser({ id: 'user-1' }); + const scheduledMessage = generateScheduledMessage(); + await renderChat(client); + + act(() => { + dispatchScheduledMessageEvent({ + client, + scheduledMessage, + type: 'scheduled_message.created', + }); + }); + + expect(client.scheduledMessages.getFromState(scheduledMessage.id)).toEqual( + scheduledMessage, + ); + }); + + it('unsubscribes on unmount', async () => { + const client = await getTestClientWithUser({ id: 'user-1' }); + const connectionChangedListeners = countListeners(client, 'connection.changed'); + const connectionRecoveredListeners = countListeners(client, 'connection.recovered'); + const { unmount } = await renderChat(client); + + unmount(); + + expect(client.scheduledMessages.hasSubscriptions).toBe(false); + expect(countListeners(client, 'scheduled_message.created')).toBe(0); + expect(countListeners(client, 'connection.changed')).toBe(connectionChangedListeners); + expect(countListeners(client, 'connection.recovered')).toBe( + connectionRecoveredListeners, + ); + }); + + it('logs and keeps rendering when the load fails', async () => { + const client = await getTestClientWithUser({ id: 'user-1' }); + const error = new Error('boom'); + vi.mocked(client.queryScheduledMessages).mockRejectedValueOnce(error); + const loggerSpy = vi.spyOn(client, 'logger'); + + await renderChat(client); + + expect(loggerSpy).toHaveBeenCalledWith( + 'warn', + 'scheduledMessages: load failed', + expect.objectContaining({ error }), + ); + expect(client.scheduledMessages.state.getLatestValue().lastQueryError).toBe(error); + }); +}); diff --git a/src/components/Chat/hooks/useChat.ts b/src/components/Chat/hooks/useChat.ts index 0af9603cfd..93ae945b6b 100644 --- a/src/components/Chat/hooks/useChat.ts +++ b/src/components/Chat/hooks/useChat.ts @@ -8,6 +8,7 @@ import { isLanguageSupported, Streami18n, } from '../../../i18n'; +import { useLoadScheduledMessages } from './useLoadScheduledMessages'; import type { AppSettingsAPIResponse, @@ -67,15 +68,19 @@ export const useChat = ({ client.polls.registerSubscriptions(); client.reminders.registerSubscriptions(); client.reminders.initTimers(); + client.scheduledMessages.registerSubscriptions(); return () => { client.threads.unregisterSubscriptions(); client.polls.unregisterSubscriptions(); client.reminders.unregisterSubscriptions(); client.reminders.clearTimers(); + client.scheduledMessages.unregisterSubscriptions(); }; }, [client]); + useLoadScheduledMessages(client); + useEffect(() => { setMutes(clientMutes); diff --git a/src/components/Chat/hooks/useLoadScheduledMessages.ts b/src/components/Chat/hooks/useLoadScheduledMessages.ts new file mode 100644 index 0000000000..52d5cf48dc --- /dev/null +++ b/src/components/Chat/hooks/useLoadScheduledMessages.ts @@ -0,0 +1,44 @@ +import { useEffect } from 'react'; +import { getScheduledMessageErrorReason, ScheduledMessageErrorReason } from 'stream-chat'; +import type { Event, StreamChat } from 'stream-chat'; + +const isFeatureDisabledError = (error: Error) => + getScheduledMessageErrorReason(error) === ScheduledMessageErrorReason.Disabled; + +export const useLoadScheduledMessages = (client: StreamChat) => { + useEffect(() => { + const { scheduledMessages } = client; + + const loadScheduledMessages = () => { + const { isDisabled, isLoaded, isLoading } = + scheduledMessages.state.getLatestValue(); + const hasConnectedUser = !!client.user?.id && !client.anonymous; + + const shouldLoad = hasConnectedUser && !isDisabled && !isLoaded && !isLoading; + + if (!shouldLoad) return; + + scheduledMessages.load().catch((error: Error) => { + if (isFeatureDisabledError(error)) return; + + client.logger('warn', 'scheduledMessages: load failed', { + error, + tags: ['scheduled_messages'], + }); + }); + }; + + const loadWhenOnline = ({ online }: Event) => { + if (online) loadScheduledMessages(); + }; + + loadScheduledMessages(); + + const subscriptions = [ + client.on('connection.changed', loadWhenOnline), + client.on('connection.recovered', loadScheduledMessages), + ]; + + return () => subscriptions.forEach(({ unsubscribe }) => unsubscribe()); + }, [client]); +}; diff --git a/src/components/ScheduledMessages/hooks/__tests__/useCanScheduleMessage.test.tsx b/src/components/ScheduledMessages/hooks/__tests__/useCanScheduleMessage.test.tsx new file mode 100644 index 0000000000..be8571e3b6 --- /dev/null +++ b/src/components/ScheduledMessages/hooks/__tests__/useCanScheduleMessage.test.tsx @@ -0,0 +1,180 @@ +import React from 'react'; +import type { PropsWithChildren } from 'react'; +import { act, renderHook } from '@testing-library/react'; +import { fromPartial } from '@total-typescript/shoehorn'; +import { MessageComposer } from 'stream-chat'; +import type { LocationComposerState, StreamChat } from 'stream-chat'; + +import { + SCHEDULE_MESSAGE_CAPABILITY, + useCanScheduleMessage, +} from '../useCanScheduleMessage'; +import { ChannelStateProvider, ChatProvider } from '../../../../context'; +import { MessageComposerControllerProvider } from '../../../MessageComposer'; +import { + generateMessage, + getTestClientWithUser, + mockChannelStateContext, + mockChatContext, +} from '../../../../mock-builders'; + +type Conditions = { + hasCapability: boolean; + hasCommand: boolean; + hasLocation: boolean; + hasPoll: boolean; + isDisabled: boolean; + isEditingSentMessage: boolean; +}; + +const CONDITION_NAMES: Array = [ + 'hasCapability', + 'hasCommand', + 'hasLocation', + 'hasPoll', + 'isDisabled', + 'isEditingSentMessage', +]; + +const ALL_CONDITIONS: Conditions[] = Array.from( + { length: 2 ** CONDITION_NAMES.length }, + (_, combination) => + CONDITION_NAMES.reduce( + (conditions, name, bit) => ({ + ...conditions, + [name]: !!(combination & (1 << bit)), + }), + {} as Conditions, + ), +); + +const describeConditions = (conditions: Conditions) => + CONDITION_NAMES.map((name) => `${name}=${conditions[name]}`).join(', '); + +const location = fromPartial({ + created_by_device_id: 'device-1', + latitude: 1, + longitude: 2, +}); + +const setup = async ({ hasCapability = true }: { hasCapability?: boolean } = {}) => { + const client = await getTestClientWithUser({ id: 'user-id' }); + const channel = client.channel('messaging', 'general'); + client.configs[channel.cid] = fromPartial({ + commands: [{ args: '[text]', description: 'Post a random gif', name: 'giphy' }], + }); + const composer = new MessageComposer({ client, compositionContext: channel }); + const channelCapabilities = { [SCHEDULE_MESSAGE_CAPABILITY]: hasCapability }; + const renders = { count: 0 }; + + const wrapper = ({ children }: PropsWithChildren) => ( + + + + {children} + + + + ); + + const hook = renderHook( + () => { + renders.count += 1; + return useCanScheduleMessage(); + }, + { wrapper }, + ); + + return { ...hook, client, composer, renders }; +}; + +const applyConditions = ( + client: StreamChat, + composer: MessageComposer, + { hasCommand, hasLocation, hasPoll, isDisabled, isEditingSentMessage }: Conditions, +) => { + client.scheduledMessages.state.partialNext({ isDisabled }); + composer.state.partialNext({ + editedMessage: isEditingSentMessage ? generateMessage() : null, + pollId: hasPoll ? 'poll-1' : null, + }); + composer.textComposer.state.partialNext({ + command: hasCommand ? { name: 'giphy' } : null, + }); + composer.locationComposer.state.partialNext({ + location: hasLocation ? location : null, + }); +}; + +describe('useCanScheduleMessage', () => { + it.each( + ALL_CONDITIONS.map((conditions) => [describeConditions(conditions), conditions]), + )('%s', async (_, conditions) => { + const { client, composer, result } = await setup({ + hasCapability: conditions.hasCapability, + }); + const { hasCapability, hasCommand, hasLocation, hasPoll, isDisabled } = conditions; + const expected = + hasCapability && + !isDisabled && + !conditions.isEditingSentMessage && + !hasCommand && + !hasPoll && + !hasLocation; + + act(() => { + applyConditions(client, composer, conditions); + }); + + expect(result.current).toBe(expected); + }); + + it('is false when the text starts with a command of the channel', async () => { + const { composer, result } = await setup(); + + act(() => { + composer.textComposer.state.partialNext({ text: '/giphy cats' }); + }); + + expect(result.current).toBe(false); + }); + + it('stays true for text that only looks like a command', async () => { + const { composer, result } = await setup(); + + act(() => { + composer.textComposer.state.partialNext({ text: '/not-a-command hello' }); + }); + + expect(result.current).toBe(true); + }); + + it('becomes schedulable again when the blocking condition goes away', async () => { + const { composer, result } = await setup(); + + act(() => { + composer.state.partialNext({ pollId: 'poll-1' }); + }); + expect(result.current).toBe(false); + + act(() => { + composer.state.partialNext({ pollId: null }); + }); + expect(result.current).toBe(true); + }); + + it('does not re-render while typing ordinary text', async () => { + const { composer, renders } = await setup(); + const rendersBefore = renders.count; + + act(() => { + composer.textComposer.state.partialNext({ text: 'h' }); + composer.textComposer.state.partialNext({ text: 'he' }); + composer.textComposer.state.partialNext({ text: 'hello' }); + }); + + expect(renders.count).toBe(rendersBefore); + }); +}); diff --git a/src/components/ScheduledMessages/hooks/__tests__/useScheduledMessageCount.test.tsx b/src/components/ScheduledMessages/hooks/__tests__/useScheduledMessageCount.test.tsx new file mode 100644 index 0000000000..dded2a7400 --- /dev/null +++ b/src/components/ScheduledMessages/hooks/__tests__/useScheduledMessageCount.test.tsx @@ -0,0 +1,128 @@ +import React from 'react'; +import type { PropsWithChildren } from 'react'; +import { act, renderHook } from '@testing-library/react'; +import type { StreamChat } from 'stream-chat'; + +import { useScheduledMessageCount } from '../useScheduledMessageCount'; +import type { UseScheduledMessageCountParams } from '../useScheduledMessageCount'; +import { ChatProvider } from '../../../../context'; +import { + generateScheduledMessage, + getTestClientWithUser, + mockChatContext, +} from '../../../../mock-builders'; + +const cid = 'messaging:general'; +const otherCid = 'messaging:random'; + +const renderUseScheduledMessageCount = ( + client: StreamChat, + params: UseScheduledMessageCountParams, +) => { + const wrapper = ({ children }: PropsWithChildren) => ( + {children} + ); + const renders = { count: 0 }; + + const hook = renderHook( + () => { + renders.count += 1; + return useScheduledMessageCount(params); + }, + { wrapper }, + ); + + return { ...hook, renders }; +}; + +describe('useScheduledMessageCount', () => { + it('counts the channel-level scheduled messages of a channel', async () => { + const client = await getTestClientWithUser({ id: 'user-id' }); + [ + generateScheduledMessage({ cid }), + generateScheduledMessage({ cid }), + generateScheduledMessage({ + cid, + message: { parent_id: 'parent-1', text: 'reply' }, + }), + generateScheduledMessage({ cid: otherCid }), + ].forEach(client.scheduledMessages.upsertToState); + + const { result } = renderUseScheduledMessageCount(client, { cid }); + + expect(result.current).toBe(2); + }); + + it('counts the scheduled replies of a thread', async () => { + const client = await getTestClientWithUser({ id: 'user-id' }); + [ + generateScheduledMessage({ cid }), + generateScheduledMessage({ + cid, + message: { parent_id: 'parent-1', text: 'reply' }, + }), + ].forEach(client.scheduledMessages.upsertToState); + + const { result } = renderUseScheduledMessageCount(client, { + cid, + parentId: 'parent-1', + }); + + expect(result.current).toBe(1); + }); + + it('counts only the thread replies of the given channel', async () => { + const client = await getTestClientWithUser({ id: 'user-id' }); + [ + generateScheduledMessage({ + cid, + message: { parent_id: 'parent-1', text: 'reply' }, + }), + generateScheduledMessage({ + cid: otherCid, + message: { parent_id: 'parent-1', text: 'reply elsewhere' }, + }), + ].forEach(client.scheduledMessages.upsertToState); + + const { result } = renderUseScheduledMessageCount(client, { + cid, + parentId: 'parent-1', + }); + + expect(result.current).toBe(1); + }); + + it('updates when a scheduled message is added or removed', async () => { + const client = await getTestClientWithUser({ id: 'user-id' }); + const scheduledMessage = generateScheduledMessage({ cid }); + const { result } = renderUseScheduledMessageCount(client, { cid }); + + act(() => { + client.scheduledMessages.upsertToState(scheduledMessage); + }); + expect(result.current).toBe(1); + + act(() => { + client.scheduledMessages.removeFromState(scheduledMessage.id); + }); + expect(result.current).toBe(0); + }); + + it('does not re-render when the count stays the same', async () => { + const client = await getTestClientWithUser({ id: 'user-id' }); + const scheduledMessage = generateScheduledMessage({ cid }); + client.scheduledMessages.upsertToState(scheduledMessage); + const { renders } = renderUseScheduledMessageCount(client, { cid }); + const rendersBefore = renders.count; + + act(() => { + client.scheduledMessages.upsertToState({ + ...scheduledMessage, + message: { text: 'edited' }, + }); + client.scheduledMessages.upsertToState(generateScheduledMessage({ cid: otherCid })); + }); + + expect(renders.count).toBe(rendersBefore); + }); +}); diff --git a/src/components/ScheduledMessages/hooks/__tests__/useScheduledMessages.test.tsx b/src/components/ScheduledMessages/hooks/__tests__/useScheduledMessages.test.tsx new file mode 100644 index 0000000000..25664b57a5 --- /dev/null +++ b/src/components/ScheduledMessages/hooks/__tests__/useScheduledMessages.test.tsx @@ -0,0 +1,129 @@ +import React from 'react'; +import type { PropsWithChildren } from 'react'; +import { act, renderHook } from '@testing-library/react'; +import type { StreamChat } from 'stream-chat'; + +import { useScheduledMessages } from '../useScheduledMessages'; +import type { UseScheduledMessagesParams } from '../useScheduledMessages'; +import { ChatProvider } from '../../../../context'; +import { + generateScheduledMessage, + getTestClientWithUser, + mockChatContext, +} from '../../../../mock-builders'; + +const cid = 'messaging:general'; +const otherCid = 'messaging:random'; + +const renderUseScheduledMessages = ( + client: StreamChat, + params?: UseScheduledMessagesParams, +) => { + const wrapper = ({ children }: PropsWithChildren) => ( + {children} + ); + const renders = { count: 0 }; + + const hook = renderHook( + () => { + renders.count += 1; + return useScheduledMessages(params); + }, + { wrapper }, + ); + + return { ...hook, renders }; +}; + +describe('useScheduledMessages', () => { + it('returns the channel-level scheduled messages ordered by scheduled_at', async () => { + const client = await getTestClientWithUser({ id: 'user-id' }); + const later = generateScheduledMessage({ cid, scheduled_at: '2030-01-02T00:00:00Z' }); + const sooner = generateScheduledMessage({ + cid, + scheduled_at: '2030-01-01T00:00:00Z', + }); + const reply = generateScheduledMessage({ + cid, + message: { parent_id: 'parent-1', text: 'reply' }, + }); + const elsewhere = generateScheduledMessage({ cid: otherCid }); + [later, reply, elsewhere, sooner].forEach(client.scheduledMessages.upsertToState); + + const { result } = renderUseScheduledMessages(client, { cid }); + + expect(result.current.scheduledMessages).toEqual([sooner, later]); + }); + + it('returns the replies of a thread when parentId is given', async () => { + const client = await getTestClientWithUser({ id: 'user-id' }); + const reply = generateScheduledMessage({ + cid, + message: { parent_id: 'parent-1', text: 'reply' }, + }); + const channelLevel = generateScheduledMessage({ cid }); + [reply, channelLevel].forEach(client.scheduledMessages.upsertToState); + + const { result } = renderUseScheduledMessages(client, { cid, parentId: 'parent-1' }); + + expect(result.current.scheduledMessages).toEqual([reply]); + }); + + it('returns every scheduled message ordered by scheduled_at without a scope', async () => { + const client = await getTestClientWithUser({ id: 'user-id' }); + const later = generateScheduledMessage({ cid, scheduled_at: '2030-01-02T00:00:00Z' }); + const sooner = generateScheduledMessage({ + cid: otherCid, + scheduled_at: '2030-01-01T00:00:00Z', + }); + [later, sooner].forEach(client.scheduledMessages.upsertToState); + + const { result } = renderUseScheduledMessages(client); + + expect(result.current.scheduledMessages).toEqual([sooner, later]); + }); + + it('exposes the loading flags of the store', async () => { + const client = await getTestClientWithUser({ id: 'user-id' }); + const { result } = renderUseScheduledMessages(client, { cid }); + + act(() => { + client.scheduledMessages.state.partialNext({ + isDisabled: true, + isLoaded: true, + isLoading: false, + }); + }); + + expect(result.current).toEqual( + expect.objectContaining({ isDisabled: true, isLoaded: true, isLoading: false }), + ); + }); + + it('updates when a scheduled message of its scope changes', async () => { + const client = await getTestClientWithUser({ id: 'user-id' }); + const scheduledMessage = generateScheduledMessage({ cid }); + const { result } = renderUseScheduledMessages(client, { cid }); + + act(() => { + client.scheduledMessages.upsertToState(scheduledMessage); + }); + + expect(result.current.scheduledMessages).toEqual([scheduledMessage]); + }); + + it('does not re-render when a scheduled message outside its scope changes', async () => { + const client = await getTestClientWithUser({ id: 'user-id' }); + client.scheduledMessages.upsertToState(generateScheduledMessage({ cid })); + const { renders, result } = renderUseScheduledMessages(client, { cid }); + const rendersBefore = renders.count; + const listBefore = result.current.scheduledMessages; + + act(() => { + client.scheduledMessages.upsertToState(generateScheduledMessage({ cid: otherCid })); + }); + + expect(renders.count).toBe(rendersBefore); + expect(result.current.scheduledMessages).toBe(listBefore); + }); +}); diff --git a/src/components/ScheduledMessages/hooks/index.ts b/src/components/ScheduledMessages/hooks/index.ts new file mode 100644 index 0000000000..b1deb1e29e --- /dev/null +++ b/src/components/ScheduledMessages/hooks/index.ts @@ -0,0 +1,3 @@ +export * from './useCanScheduleMessage'; +export * from './useScheduledMessageCount'; +export * from './useScheduledMessages'; diff --git a/src/components/ScheduledMessages/hooks/useCanScheduleMessage.ts b/src/components/ScheduledMessages/hooks/useCanScheduleMessage.ts new file mode 100644 index 0000000000..ff31502c04 --- /dev/null +++ b/src/components/ScheduledMessages/hooks/useCanScheduleMessage.ts @@ -0,0 +1,80 @@ +import { useMemo } from 'react'; +import type { + Channel, + LocationComposerState, + MessageComposerState, + ScheduledMessageManagerState, + TextComposerState, +} from 'stream-chat'; + +import { useChannelStateContext, useChatContext } from '../../../context'; +import { useStateStore } from '../../../store'; +import { useMessageComposerController } from '../../MessageComposer/hooks/useMessageComposerController'; + +export const SCHEDULE_MESSAGE_CAPABILITY = 'schedule-message'; + +const COMMAND_PATTERN = /^\/(\S+)/; + +const startsWithChannelCommand = (text: string, channel: Channel) => { + const [, commandName] = text.match(COMMAND_PATTERN) ?? []; + + if (!commandName) return false; + + const commands = channel.getConfig()?.commands ?? []; + + const isChannelCommand = commands.some( + ({ name }) => name?.toLowerCase() === commandName.toLowerCase(), + ); + + return isChannelCommand; +}; + +const scheduledMessagesSelector = ({ isDisabled }: ScheduledMessageManagerState) => ({ + isDisabled, +}); + +const messageComposerSelector = ({ editedMessage, pollId }: MessageComposerState) => ({ + hasPoll: !!pollId, + isEditingSentMessage: !!editedMessage, +}); + +const locationComposerSelector = ({ location }: LocationComposerState) => ({ + hasLocation: !!location, +}); + +const createTextComposerSelector = + (channel: Channel) => + ({ command, text }: TextComposerState) => ({ + hasCommand: !!command || startsWithChannelCommand(text, channel), + }); + +export const useCanScheduleMessage = () => { + const { client } = useChatContext('useCanScheduleMessage'); + const { channelCapabilities } = useChannelStateContext('useCanScheduleMessage'); + const messageComposer = useMessageComposerController(); + const { channel, locationComposer, textComposer } = messageComposer; + + const textComposerSelector = useMemo( + () => createTextComposerSelector(channel), + [channel], + ); + + const { isDisabled } = useStateStore( + client.scheduledMessages.state, + scheduledMessagesSelector, + ); + const { hasPoll, isEditingSentMessage } = useStateStore( + messageComposer.state, + messageComposerSelector, + ); + const { hasCommand } = useStateStore(textComposer.state, textComposerSelector); + const { hasLocation } = useStateStore(locationComposer.state, locationComposerSelector); + + const hasCapability = !!channelCapabilities[SCHEDULE_MESSAGE_CAPABILITY]; + const compositionCanBeScheduled = !hasCommand && !hasPoll && !hasLocation; + + const canScheduleMessage = + hasCapability && !isDisabled && !isEditingSentMessage && compositionCanBeScheduled; + + return canScheduleMessage; +}; diff --git a/src/components/ScheduledMessages/hooks/useScheduledMessageCount.ts b/src/components/ScheduledMessages/hooks/useScheduledMessageCount.ts new file mode 100644 index 0000000000..c7b25fe035 --- /dev/null +++ b/src/components/ScheduledMessages/hooks/useScheduledMessageCount.ts @@ -0,0 +1,53 @@ +import { useMemo } from 'react'; +import type { ScheduledMessageManager } from 'stream-chat'; + +import { useChatContext } from '../../../context'; +import { useStateStore } from '../../../store'; + +export type UseScheduledMessageCountParams = { + cid: string; + parentId?: string; +}; + +const countThreadReplies = ( + manager: ScheduledMessageManager, + { cid, parentId }: UseScheduledMessageCountParams, +) => { + const allScheduledMessages = Array.from(manager.scheduledMessages.values()); + + const threadReplyCount = allScheduledMessages.filter( + ({ cid: messageCid, message }) => + messageCid === cid && message.parent_id === parentId, + ).length; + + return threadReplyCount; +}; + +const createScheduledMessageCountSelector = ( + manager: ScheduledMessageManager, + params: UseScheduledMessageCountParams, +) => { + const { cid, parentId } = params; + + const countScheduledMessages = () => + parentId ? countThreadReplies(manager, params) : manager.getCountForChannel(cid); + + return () => ({ scheduledMessageCount: countScheduledMessages() }); +}; + +export const useScheduledMessageCount = ({ + cid, + parentId, +}: UseScheduledMessageCountParams) => { + const { client } = useChatContext('useScheduledMessageCount'); + const { scheduledMessages: manager } = client; + + const selector = useMemo( + () => createScheduledMessageCountSelector(manager, { cid, parentId }), + [cid, manager, parentId], + ); + + const { scheduledMessageCount } = useStateStore(manager.state, selector); + + return scheduledMessageCount; +}; diff --git a/src/components/ScheduledMessages/hooks/useScheduledMessages.ts b/src/components/ScheduledMessages/hooks/useScheduledMessages.ts new file mode 100644 index 0000000000..db61b09541 --- /dev/null +++ b/src/components/ScheduledMessages/hooks/useScheduledMessages.ts @@ -0,0 +1,87 @@ +import { useMemo } from 'react'; +import type { + ScheduledMessageManager, + ScheduledMessageManagerState, + ScheduledMessageResponse, +} from 'stream-chat'; + +import { useChatContext } from '../../../context'; +import { useStateStore } from '../../../store'; + +export type UseScheduledMessagesParams = { + cid?: string; + parentId?: string; +}; + +export type ScheduledMessagesSnapshot = { + isDisabled: boolean; + isLoaded: boolean; + isLoading: boolean; + scheduledMessages: ScheduledMessageResponse[]; +}; + +const byScheduledAt = (a: ScheduledMessageResponse, b: ScheduledMessageResponse) => + Date.parse(a.scheduled_at) - Date.parse(b.scheduled_at) || a.id.localeCompare(b.id); + +const isSameList = ( + current: ScheduledMessageResponse[], + previous: ScheduledMessageResponse[], +) => + current.length === previous.length && + current.every((scheduledMessage, index) => scheduledMessage === previous[index]); + +const getScopedScheduledMessages = ( + manager: ScheduledMessageManager, + { cid, parentId }: UseScheduledMessagesParams, +) => { + if (parentId) return manager.getForThread(parentId); + + if (cid) return manager.getForChannel(cid); + + const allScheduledMessages = Array.from(manager.scheduledMessages.values()); + + return allScheduledMessages.sort(byScheduledAt); +}; + +const createScheduledMessagesSelector = ( + manager: ScheduledMessageManager, + params: UseScheduledMessagesParams, +) => { + let cachedScheduledMessages: ScheduledMessageResponse[] = []; + + return ({ + isDisabled, + isLoaded, + isLoading, + }: ScheduledMessageManagerState): ScheduledMessagesSnapshot => { + const scheduledMessages = getScopedScheduledMessages(manager, params); + + if (!isSameList(scheduledMessages, cachedScheduledMessages)) { + cachedScheduledMessages = scheduledMessages; + } + + return { + isDisabled, + isLoaded, + isLoading, + scheduledMessages: cachedScheduledMessages, + }; + }; +}; + +export const useScheduledMessages = ({ + cid, + parentId, +}: UseScheduledMessagesParams = {}) => { + const { client } = useChatContext('useScheduledMessages'); + const { scheduledMessages: manager } = client; + + const selector = useMemo( + () => createScheduledMessagesSelector(manager, { cid, parentId }), + [cid, manager, parentId], + ); + + const scheduledMessagesSnapshot = useStateStore(manager.state, selector); + + return scheduledMessagesSnapshot; +}; diff --git a/src/components/ScheduledMessages/index.ts b/src/components/ScheduledMessages/index.ts new file mode 100644 index 0000000000..4cc90d02bd --- /dev/null +++ b/src/components/ScheduledMessages/index.ts @@ -0,0 +1 @@ +export * from './hooks'; diff --git a/src/components/index.ts b/src/components/index.ts index f8fedddfd6..6471290ebe 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -38,6 +38,7 @@ export * from './Notifications'; export * from './Poll'; export * from './Reactions'; export * from './SafeAnchor'; +export * from './ScheduledMessages'; export * from './SummarizedMessagePreview'; export * from './TextareaComposer'; export * from './Thread'; diff --git a/src/mock-builders/api/index.ts b/src/mock-builders/api/index.ts index 244a4e375f..f0c9721621 100644 --- a/src/mock-builders/api/index.ts +++ b/src/mock-builders/api/index.ts @@ -23,4 +23,5 @@ export * from './getOrCreateChannel'; export * from './markRead'; export * from './threadReplies'; export * from './sendMessage'; +export * from './scheduledMessages'; export * from './error'; diff --git a/src/mock-builders/api/scheduledMessages.ts b/src/mock-builders/api/scheduledMessages.ts new file mode 100644 index 0000000000..25d524fe14 --- /dev/null +++ b/src/mock-builders/api/scheduledMessages.ts @@ -0,0 +1,58 @@ +import type { + MessageResponse, + ScheduledMessageErrorReason, + ScheduledMessageResponse, +} from 'stream-chat'; + +import { mockedApiResponse } from './utils'; + +type ScheduledMessageRequestMethod = 'delete' | 'patch' | 'post'; + +const SCHEDULED_MESSAGE_ERROR_STATUS: Record = { + 121: 409, + 122: 409, +}; + +export const queryScheduledMessagesApi = ( + scheduledMessages: ScheduledMessageResponse[] = [], + next?: string, +) => mockedApiResponse({ next, scheduled_messages: scheduledMessages }, 'post'); + +export const scheduledMessageApi = ( + scheduledMessage: ScheduledMessageResponse, + method: ScheduledMessageRequestMethod = 'patch', +) => mockedApiResponse({ scheduled_message: scheduledMessage }, method); + +export const sendScheduledMessageApi = ( + scheduledMessage: ScheduledMessageResponse, + message: Partial = {}, +) => + mockedApiResponse( + { + message: { + id: scheduledMessage.id, + text: scheduledMessage.message.text, + ...message, + }, + scheduled_message: scheduledMessage, + }, + 'post', + ); + +export const erroredScheduledMessageApi = ( + code: number, + reason: ScheduledMessageErrorReason, + method: ScheduledMessageRequestMethod = 'post', +) => { + const status = SCHEDULED_MESSAGE_ERROR_STATUS[code] ?? 400; + + const error = { + code, + duration: '0.01', + exception_fields: { reason }, + message: `scheduled message request failed: ${reason}`, + StatusCode: status, + }; + + return mockedApiResponse(error, method, status); +}; diff --git a/src/mock-builders/api/utils.ts b/src/mock-builders/api/utils.ts index 044c9abf13..4789aea2f1 100644 --- a/src/mock-builders/api/utils.ts +++ b/src/mock-builders/api/utils.ts @@ -1,4 +1,4 @@ -type HttpMethod = 'get' | 'post' | 'put' | 'delete'; +type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete'; export interface MockedApiResponse { response: { diff --git a/src/mock-builders/event/index.ts b/src/mock-builders/event/index.ts index f6a118519a..af17890aca 100644 --- a/src/mock-builders/event/index.ts +++ b/src/mock-builders/event/index.ts @@ -17,3 +17,4 @@ export { default as dispatchNotificationMutesUpdated } from './notificationMutes export { default as dispatchNotificationRemovedFromChannel } from './notificationRemovedFromChannel'; export { default as dispatchUserMessagesDeletedEvent } from './userMessagesDeleted'; export { default as dispatchUserUpdatedEvent } from './userUpdated'; +export * from './scheduledMessage'; diff --git a/src/mock-builders/event/scheduledMessage.ts b/src/mock-builders/event/scheduledMessage.ts new file mode 100644 index 0000000000..b50ca6382a --- /dev/null +++ b/src/mock-builders/event/scheduledMessage.ts @@ -0,0 +1,27 @@ +import type { + ScheduledMessageEventType, + ScheduledMessageResponse, + StreamChat, +} from 'stream-chat'; + +export type DispatchScheduledMessageEventParams = { + client: StreamChat; + scheduledMessage: ScheduledMessageResponse; + type: ScheduledMessageEventType; +}; + +export const dispatchScheduledMessageEvent = ({ + client, + scheduledMessage, + type, +}: DispatchScheduledMessageEventParams) => { + const { cid, user } = scheduledMessage; + + client.dispatchEvent({ + cid, + created_at: new Date().toISOString(), + scheduled_message: scheduledMessage, + type, + user, + }); +}; diff --git a/src/mock-builders/generator/index.ts b/src/mock-builders/generator/index.ts index fad35360e9..3abb5aad55 100644 --- a/src/mock-builders/generator/index.ts +++ b/src/mock-builders/generator/index.ts @@ -6,5 +6,6 @@ export * from './messageDraft'; export * from './poll'; export * from './reaction'; export * from './reminder'; +export * from './scheduledMessage'; export * from './sharedLocation'; export * from './user'; diff --git a/src/mock-builders/generator/scheduledMessage.ts b/src/mock-builders/generator/scheduledMessage.ts new file mode 100644 index 0000000000..039d0d8e5a --- /dev/null +++ b/src/mock-builders/generator/scheduledMessage.ts @@ -0,0 +1,26 @@ +import { nanoid } from 'nanoid'; +import type { ScheduledMessageResponse } from 'stream-chat'; + +const ONE_HOUR_MS = 60 * 60 * 1000; + +export const generateScheduledMessage = ( + data: Partial = {}, +): ScheduledMessageResponse => { + const now = new Date(); + const createdAt = now.toISOString(); + const scheduledAt = new Date(now.getTime() + ONE_HOUR_MS).toISOString(); + + const scheduledMessage: ScheduledMessageResponse = { + cid: 'messaging:general', + created_at: createdAt, + id: nanoid(), + message: { text: nanoid() }, + scheduled_at: scheduledAt, + status: 'scheduled', + updated_at: createdAt, + user: { id: 'user-id' }, + ...data, + }; + + return scheduledMessage; +}; diff --git a/src/mock-builders/index.ts b/src/mock-builders/index.ts index 328b84ed83..47167c8f7c 100644 --- a/src/mock-builders/index.ts +++ b/src/mock-builders/index.ts @@ -20,6 +20,7 @@ const connectUser = (client: StreamChat, user: Partial) => interface MockClientOverrides { getAppSettings?: StreamChat['getAppSettings']; queryReactions?: StreamChat['queryReactions']; + queryScheduledMessages?: StreamChat['queryScheduledMessages']; } function mockClient(client: StreamChat, mocks: MockClientOverrides = {}) { @@ -32,6 +33,10 @@ function mockClient(client: StreamChat, mocks: MockClientOverrides = {}) { mocks.queryReactions ?? ((() => Promise.resolve({})) as unknown as StreamChat['queryReactions']), ); + vi.spyOn(client, 'queryScheduledMessages').mockImplementation( + mocks.queryScheduledMessages ?? + (() => Promise.resolve({ duration: '', scheduled_messages: [] })), + ); client.tokenManager = fromPartial({ getToken: vi.fn(() => token), tokenReady: vi.fn(() => true),