From 770bb1122ba16d58a107fa006ea8b34b39d63fec Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj <31964049+isekovanic@users.noreply.github.com> Date: Tue, 3 Dec 2024 21:45:58 +0100 Subject: [PATCH] feat: ai-bot integration poc (#2819) * feat: add StreamingMessageView to kick off ai feature * fix: issues with message view * feat: add AITypingIndicatorView * feat: make send message button react to ai state * fix: improve typewriter animation * fix: improvements in ui and typewriter * chore: add customizations to StreamingMessageView * fix: hook deps * chore: extract logic in hook * fix: custom events * fix: revert the type change in favor of changes in the LLC * feat: codeblock scrollable view * feat: table initial reimpl * feat: finish table impl * fix: horizontal scroll list performance issues * feat: add markdown parsing fixes, optimistic code capture and various improvements * fix: theme prop and theming in general * fix: remove edited lalbel for ai messages * fix: bug with stop streaming button and types * fix: colors in md rendering * fix: rename custom scrollview * chore: translations * fix: remove TODO * chore: extract indicator styles in theme * fix: safeguard if channel does not exist * fix: get rid of enum and introduce proper type * fix: allow only message overrides * chore: update event names as per the changes * fix: bump stream-chat-js version to v8.46.0 * fix: use channel method for sending events * fix: cover background mode case * fix: use type from LLC * chore: add jsdocs * fix: move check to checker fn * fix: add overrides for StreamingMessageView * chore: add override for stop streaming button --- .../SampleApp/src/hooks/useStreamChatTheme.ts | 1 + .../SampleApp/src/screens/ChannelScreen.tsx | 2 + examples/SampleApp/yarn.lock | 15 ++ package/package.json | 2 +- .../AITypingIndicatorView.tsx | 50 +++++ .../AITypingIndicatorView/hooks/useAIState.ts | 68 ++++++ .../components/AITypingIndicatorView/index.ts | 2 + package/src/components/Channel/Channel.tsx | 28 ++- .../useCreateInputMessageInputContext.ts | 2 + .../Channel/hooks/useCreateMessagesContext.ts | 2 + package/src/components/Message/Message.tsx | 5 +- .../Message/MessageSimple/MessageContent.tsx | 16 +- .../Message/MessageSimple/MessageFooter.tsx | 10 +- .../Message/MessageSimple/MessageSimple.tsx | 4 +- .../MessageSimple/StreamingMessageView.tsx | 34 +++ .../utils/generateMarkdownText.ts | 10 +- .../MessageSimple/utils/renderText.tsx | 208 +++++++++++++++++- .../Message/hooks/useStreamingMessage.ts | 54 +++++ .../components/MessageInput/MessageInput.tsx | 22 +- .../StopMessageStreamingButton.tsx | 34 +++ package/src/components/index.ts | 5 + .../MessageInputContext.tsx | 6 +- .../hooks/useCreateMessageInputContext.ts | 2 + .../messagesContext/MessagesContext.tsx | 7 +- .../src/contexts/themeContext/utils/theme.ts | 15 +- package/src/i18n/en.json | 2 + package/src/i18n/es.json | 2 + package/src/i18n/fr.json | 2 + package/src/i18n/he.json | 2 + package/src/i18n/hi.json | 2 + package/src/i18n/it.json | 2 + package/src/i18n/ja.json | 2 + package/src/i18n/ko.json | 2 + package/src/i18n/nl.json | 2 + package/src/i18n/pt-br.json | 2 + package/src/i18n/ru.json | 2 + package/src/i18n/tr.json | 2 + package/src/utils/utils.ts | 2 +- 38 files changed, 608 insertions(+), 22 deletions(-) create mode 100644 package/src/components/AITypingIndicatorView/AITypingIndicatorView.tsx create mode 100644 package/src/components/AITypingIndicatorView/hooks/useAIState.ts create mode 100644 package/src/components/AITypingIndicatorView/index.ts create mode 100644 package/src/components/Message/MessageSimple/StreamingMessageView.tsx create mode 100644 package/src/components/Message/hooks/useStreamingMessage.ts create mode 100644 package/src/components/MessageInput/StopMessageStreamingButton.tsx diff --git a/examples/SampleApp/src/hooks/useStreamChatTheme.ts b/examples/SampleApp/src/hooks/useStreamChatTheme.ts index e241ccd2b5..ff12a49518 100644 --- a/examples/SampleApp/src/hooks/useStreamChatTheme.ts +++ b/examples/SampleApp/src/hooks/useStreamChatTheme.ts @@ -17,6 +17,7 @@ const getChatStyle = (colorScheme: ColorSchemeName): DeepPartial => ({ border: '#141924', button_background: '#FFFFFF', button_text: '#005FFF', + code_block: '#222222', grey: '#7A7A7A', grey_gainsboro: '#2D2F2F', grey_whisper: '#1C1E22', diff --git a/examples/SampleApp/src/screens/ChannelScreen.tsx b/examples/SampleApp/src/screens/ChannelScreen.tsx index 2daaea56d9..db3d768ca3 100644 --- a/examples/SampleApp/src/screens/ChannelScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelScreen.tsx @@ -12,6 +12,7 @@ import { useChatContext, useTheme, useTypingString, + AITypingIndicatorView, } from 'stream-chat-react-native'; import { Platform, StyleSheet, View } from 'react-native'; import type { StackNavigationProp } from '@react-navigation/stack'; @@ -168,6 +169,7 @@ export const ChannelScreen: React.FC = ({ }); }} /> + diff --git a/examples/SampleApp/yarn.lock b/examples/SampleApp/yarn.lock index 48fa68d83d..e809ba4e48 100644 --- a/examples/SampleApp/yarn.lock +++ b/examples/SampleApp/yarn.lock @@ -6878,6 +6878,21 @@ stream-chat@8.45.1: jsonwebtoken "~9.0.0" ws "^7.5.10" +stream-chat@8.46.0: + version "8.46.0" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-8.46.0.tgz#416b325e05b144d0937a3527d1e622463113d605" + integrity sha512-HQVCRVldrfQFAvsBOHiHR0TKYf+wpsg/cAzRojeZY+buy1vG6eoqk09h6Fl4k2eG3zFLoA0G9W6o7o45jyFE1g== + dependencies: + "@babel/runtime" "^7.16.3" + "@types/jsonwebtoken" "~9.0.0" + "@types/ws" "^7.4.0" + axios "^1.6.0" + base64-js "^1.5.1" + form-data "^4.0.0" + isomorphic-ws "^4.0.1" + jsonwebtoken "~9.0.0" + ws "^7.5.10" + strict-uri-encode@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546" diff --git a/package/package.json b/package/package.json index 0c49bae870..97d78e81d6 100644 --- a/package/package.json +++ b/package/package.json @@ -78,7 +78,7 @@ "path": "0.12.7", "react-native-markdown-package": "1.8.2", "react-native-url-polyfill": "^1.3.0", - "stream-chat": "8.45.1" + "stream-chat": "8.46.0" }, "peerDependencies": { "react-native-quick-sqlite": ">=5.1.0", diff --git a/package/src/components/AITypingIndicatorView/AITypingIndicatorView.tsx b/package/src/components/AITypingIndicatorView/AITypingIndicatorView.tsx new file mode 100644 index 0000000000..1709b8fcad --- /dev/null +++ b/package/src/components/AITypingIndicatorView/AITypingIndicatorView.tsx @@ -0,0 +1,50 @@ +import React from 'react'; + +import { StyleSheet, Text, View } from 'react-native'; + +import { Channel } from 'stream-chat'; + +import { AIStates, useAIState } from './hooks/useAIState'; + +import { useChannelContext, useTheme, useTranslationContext } from '../../contexts'; +import type { DefaultStreamChatGenerics } from '../../types/types'; + +export type AITypingIndicatorViewProps< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +> = { + channel?: Channel; +}; + +export const AITypingIndicatorView = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +>({ + channel: channelFromProps, +}: AITypingIndicatorViewProps) => { + const { t } = useTranslationContext(); + const { channel: channelFromContext } = useChannelContext(); + const channel = channelFromProps || channelFromContext; + const { aiState } = useAIState(channel); + const allowedStates = { + [AIStates.Thinking]: t('Thinking...'), + [AIStates.Generating]: t('Generating...'), + }; + + const { + theme: { + aiTypingIndicatorView: { container, text }, + colors: { black, grey_gainsboro }, + }, + } = useTheme(); + + return aiState in allowedStates ? ( + + {allowedStates[aiState]} + + ) : null; +}; + +AITypingIndicatorView.displayName = 'AITypingIndicatorView{messageSimple{content}}'; + +const styles = StyleSheet.create({ + container: { paddingHorizontal: 16, paddingVertical: 18 }, +}); diff --git a/package/src/components/AITypingIndicatorView/hooks/useAIState.ts b/package/src/components/AITypingIndicatorView/hooks/useAIState.ts new file mode 100644 index 0000000000..3936f5811d --- /dev/null +++ b/package/src/components/AITypingIndicatorView/hooks/useAIState.ts @@ -0,0 +1,68 @@ +import { useEffect, useState } from 'react'; + +import { AIState, Channel, Event } from 'stream-chat'; + +import { useChatContext } from '../../../contexts'; +import type { DefaultStreamChatGenerics } from '../../../types/types'; +import { useIsOnline } from '../../Chat/hooks/useIsOnline'; + +export const AIStates = { + Error: 'AI_STATE_ERROR', + ExternalSources: 'AI_STATE_EXTERNAL_SOURCES', + Generating: 'AI_STATE_GENERATING', + Idle: 'AI_STATE_IDLE', + Thinking: 'AI_STATE_THINKING', +}; + +/** + * A hook that returns the current state of the AI. + * @param {Channel} channel - The channel for which we want to know the AI state. + * @returns {{ aiState: AIState }} The current AI state for the given channel. + */ +export const useAIState = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +>( + channel?: Channel, +): { aiState: AIState } => { + const { client } = useChatContext(); + const { isOnline } = useIsOnline(client); + + const [aiState, setAiState] = useState(AIStates.Idle); + + useEffect(() => { + if (!isOnline) { + setAiState(AIStates.Idle); + } + }, [isOnline]); + + useEffect(() => { + if (!channel) { + return; + } + + const indicatorChangedListener = channel.on( + 'ai_indicator.update', + (event: Event) => { + const { cid } = event; + const state = event.ai_state as AIState; + if (channel.cid === cid) { + setAiState(state); + } + }, + ); + + const indicatorClearedListener = channel.on('ai_indicator.clear', (event) => { + const { cid } = event; + if (channel.cid === cid) { + setAiState(AIStates.Idle); + } + }); + + return () => { + indicatorChangedListener.unsubscribe(); + indicatorClearedListener.unsubscribe(); + }; + }, [channel]); + + return { aiState }; +}; diff --git a/package/src/components/AITypingIndicatorView/index.ts b/package/src/components/AITypingIndicatorView/index.ts new file mode 100644 index 0000000000..a7c895128b --- /dev/null +++ b/package/src/components/AITypingIndicatorView/index.ts @@ -0,0 +1,2 @@ +export * from './AITypingIndicatorView'; +export * from './hooks/useAIState'; diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 8c92071512..dffd98eff9 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -135,6 +135,7 @@ import { MessageSimple as MessageSimpleDefault } from '../Message/MessageSimple/ import { MessageStatus as MessageStatusDefault } from '../Message/MessageSimple/MessageStatus'; import { MessageTimestamp as MessageTimestampDefault } from '../Message/MessageSimple/MessageTimestamp'; import { ReactionList as ReactionListDefault } from '../Message/MessageSimple/ReactionList'; +import { StreamingMessageView as DefaultStreamingMessageView } from '../Message/MessageSimple/StreamingMessageView'; import { AttachButton as AttachButtonDefault } from '../MessageInput/AttachButton'; import { CommandsButton as CommandsButtonDefault } from '../MessageInput/CommandsButton'; import { AudioRecorder as AudioRecorderDefault } from '../MessageInput/components/AudioRecorder/AudioRecorder'; @@ -154,6 +155,7 @@ import { MoreOptionsButton as MoreOptionsButtonDefault } from '../MessageInput/M import { SendButton as SendButtonDefault } from '../MessageInput/SendButton'; import { SendMessageDisallowedIndicator as SendMessageDisallowedIndicatorDefault } from '../MessageInput/SendMessageDisallowedIndicator'; import { ShowThreadMessageInChannelButton as ShowThreadMessageInChannelButtonDefault } from '../MessageInput/ShowThreadMessageInChannelButton'; +import { StopMessageStreamingButton as DefaultStopMessageStreamingButton } from '../MessageInput/StopMessageStreamingButton'; import { UploadProgressIndicator as UploadProgressIndicatorDefault } from '../MessageInput/UploadProgressIndicator'; import { DateHeader as DateHeaderDefault } from '../MessageList/DateHeader'; import type { MessageType } from '../MessageList/hooks/useMessageList'; @@ -333,6 +335,7 @@ export type ChannelPropsWithContext< | 'VideoThumbnail' | 'PollContent' | 'hasCreatePoll' + | 'StreamingMessageView' > > & Partial, 'allowThreadMessagesInChannel'>> & { @@ -420,7 +423,12 @@ export type ChannelPropsWithContext< * Tells if channel is rendering a thread list */ threadList?: boolean; - } & Partial>; + } & Partial< + Pick< + InputMessageInputContextValue, + 'openPollCreationDialog' | 'CreatePollContent' | 'StopMessageStreamingButton' + > + >; const ChannelWithContext = < StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, @@ -544,7 +552,15 @@ const ChannelWithContext = < MessageAvatar = MessageAvatarDefault, MessageBounce = MessageBounceDefault, MessageContent = MessageContentDefault, - messageContentOrder = ['quoted_reply', 'gallery', 'files', 'poll', 'text', 'attachments'], + messageContentOrder = [ + 'quoted_reply', + 'gallery', + 'files', + 'poll', + 'ai_text', + 'text', + 'attachments', + ], MessageDeleted = MessageDeletedDefault, MessageEditedTimestamp = MessageEditedTimestampDefault, MessageError = MessageErrorDefault, @@ -596,6 +612,8 @@ const ChannelWithContext = < StartAudioRecordingButton = AudioRecordingButtonDefault, stateUpdateThrottleInterval = defaultThrottleInterval, StickyHeader = StickyHeaderDefault, + StopMessageStreamingButton: StopMessageStreamingButtonOverride, + StreamingMessageView = DefaultStreamingMessageView, supportedReactions = reactionData, t, thread: threadFromProps, @@ -612,6 +630,10 @@ const ChannelWithContext = < } = props; const { thread: threadProps, threadInstance } = threadFromProps; + const StopMessageStreamingButton = + StopMessageStreamingButtonOverride === undefined + ? DefaultStopMessageStreamingButton + : StopMessageStreamingButtonOverride; const { theme: { @@ -2338,6 +2360,7 @@ const ChannelWithContext = < setQuotedMessageState, ShowThreadMessageInChannelButton, StartAudioRecordingButton, + StopMessageStreamingButton, UploadProgressIndicator, }); @@ -2439,6 +2462,7 @@ const ChannelWithContext = < setEditingState, setQuotedMessageState, shouldShowUnreadUnderlay, + StreamingMessageView, supportedReactions, targetedMessage, TypingIndicator, diff --git a/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts b/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts index 5ef1125d9d..70abe0888b 100644 --- a/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts +++ b/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts @@ -64,6 +64,7 @@ export const useCreateInputMessageInputContext = < showPollCreationDialog, ShowThreadMessageInChannelButton, StartAudioRecordingButton, + StopMessageStreamingButton, UploadProgressIndicator, }: InputMessageInputContextValue & { /** @@ -137,6 +138,7 @@ export const useCreateInputMessageInputContext = < showPollCreationDialog, ShowThreadMessageInChannelButton, StartAudioRecordingButton, + StopMessageStreamingButton, UploadProgressIndicator, }), // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/package/src/components/Channel/hooks/useCreateMessagesContext.ts b/package/src/components/Channel/hooks/useCreateMessagesContext.ts index 3948abaad8..9587c9c1bd 100644 --- a/package/src/components/Channel/hooks/useCreateMessagesContext.ts +++ b/package/src/components/Channel/hooks/useCreateMessagesContext.ts @@ -88,6 +88,7 @@ export const useCreateMessagesContext = < setEditingState, setQuotedMessageState, shouldShowUnreadUnderlay, + StreamingMessageView, supportedReactions, targetedMessage, TypingIndicator, @@ -189,6 +190,7 @@ export const useCreateMessagesContext = < setEditingState, setQuotedMessageState, shouldShowUnreadUnderlay, + StreamingMessageView, supportedReactions, targetedMessage, TypingIndicator, diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index ce5b4f700d..bc5d1cadea 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -456,6 +456,8 @@ const MessageWithContext = < return !!attachments.images.length || !!attachments.videos.length; case 'poll': return !!message.poll_id; + case 'ai_text': + return !!message.ai_generated; case 'text': default: return !!message.text; @@ -863,7 +865,8 @@ const areEqual = & Pick & { setMessageContentWidth: React.Dispatch>; @@ -142,6 +143,7 @@ const MessageContentWithContext = < Reply, setMessageContentWidth, showMessageStatus, + StreamingMessageView, threadList, } = props; const { client } = useChatContext(); @@ -393,9 +395,16 @@ const MessageContentWithContext = < /> ) : null; } + case 'ai_text': + return message.ai_generated ? ( + + ) : null; case 'text': default: - return otherAttachments.length && otherAttachments[0].actions ? null : ( + return (otherAttachments.length && otherAttachments[0].actions) || + message.ai_generated ? null : ( key={`message_text_container_${messageContentOrderIndex}`} /> @@ -484,7 +493,8 @@ const areEqual = (); const { t } = useTranslationContext(); @@ -635,6 +646,7 @@ export const MessageContent = < preventPress, Reply, showMessageStatus, + StreamingMessageView, t, threadList, }} diff --git a/package/src/components/Message/MessageSimple/MessageFooter.tsx b/package/src/components/Message/MessageSimple/MessageFooter.tsx index b16e2dcf6f..0cf33cbb31 100644 --- a/package/src/components/Message/MessageSimple/MessageFooter.tsx +++ b/package/src/components/Message/MessageSimple/MessageFooter.tsx @@ -129,6 +129,8 @@ const MessageFooterWithContext = < return null; } + const isEdited = isEditedMessage(message); + return ( <> @@ -141,7 +143,7 @@ const MessageFooterWithContext = < {showMessageStatus && } - {isEditedMessage(message) && !isEditedMessageOpen && ( + {isEdited && !isEditedMessageOpen ? ( <> ('Edited')} - )} + ) : null} - {isEditedMessageOpen && ( + {isEdited && isEditedMessageOpen ? ( - )} + ) : null} ); }; diff --git a/package/src/components/Message/MessageSimple/MessageSimple.tsx b/package/src/components/Message/MessageSimple/MessageSimple.tsx index 4bd5bd0b95..ea3a66d27b 100644 --- a/package/src/components/Message/MessageSimple/MessageSimple.tsx +++ b/package/src/components/Message/MessageSimple/MessageSimple.tsx @@ -154,7 +154,9 @@ const areEqual = = Pick, 'message'> & { + letterInterval?: number; + renderingLetterCount?: number; +}; + +export const StreamingMessageView = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +>( + props: StreamingMessageViewProps, +) => { + const { letterInterval, message: messageFromProps, renderingLetterCount } = props; + const { message: messageFromContext } = useMessageContext(); + const message = messageFromProps || messageFromContext; + const { text = '' } = message; + const { streamedMessageText } = useStreamingMessage({ + letterInterval, + renderingLetterCount, + text, + }); + + return ; +}; + +StreamingMessageView.displayName = 'StreamingMessageView{messageSimple{content}}'; diff --git a/package/src/components/Message/MessageSimple/utils/generateMarkdownText.ts b/package/src/components/Message/MessageSimple/utils/generateMarkdownText.ts index 4e116cc4f2..dd654facb1 100644 --- a/package/src/components/Message/MessageSimple/utils/generateMarkdownText.ts +++ b/package/src/components/Message/MessageSimple/utils/generateMarkdownText.ts @@ -33,7 +33,11 @@ export const generateMarkdownText = (text?: string) => { resultText = resultText.replace(mentionsRegex, `@${displayLink}`); } - resultText = resultText.replace(/[<"'>]/g, '\\$&'); + // Escape the " and ' characters, except in code blocks where we deem this allowed. + resultText = resultText.replace(/(```[\s\S]*?```|`.*?`)|[<"'>]/g, (match, code) => { + if (code) return code; + return `\\${match}`; + }); // Remove whitespaces that come directly after newlines except in code blocks where we deem this allowed. resultText = resultText.replace(/(```[\s\S]*?```|`.*?`)|\n[ ]{2,}/g, (_, code) => { @@ -41,5 +45,9 @@ export const generateMarkdownText = (text?: string) => { return '\n'; }); + // Always replace \n``` with \n\n``` to force the markdown state machine to treat it as a separate block. Otherwise, code blocks inside of list + // items for example were broken. We clean up the code block closing state within the rendering itself. + resultText = resultText.replace(/\n```/g, '\n\n```'); + return resultText; }; diff --git a/package/src/components/Message/MessageSimple/utils/renderText.tsx b/package/src/components/Message/MessageSimple/utils/renderText.tsx index 24a52eadb7..4b870a5d0b 100644 --- a/package/src/components/Message/MessageSimple/utils/renderText.tsx +++ b/package/src/components/Message/MessageSimple/utils/renderText.tsx @@ -1,8 +1,18 @@ -import React, { PropsWithChildren } from 'react'; -import { GestureResponderEvent, Linking, Text, TextProps, View, ViewProps } from 'react-native'; - +import React, { PropsWithChildren, ReactNode, useCallback, useMemo } from 'react'; +import { + GestureResponderEvent, + Linking, + Platform, + Text, + TextProps, + View, + ViewProps, +} from 'react-native'; + +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; // @ts-expect-error import Markdown from 'react-native-markdown-package'; +import Animated, { clamp, scrollTo, useAnimatedRef, useSharedValue } from 'react-native-reanimated'; import { DefaultRules, @@ -26,7 +36,62 @@ import type { DefaultStreamChatGenerics } from '../../../../types/types'; import { escapeRegExp } from '../../../../utils/utils'; import type { MessageType } from '../../../MessageList/hooks/useMessageList'; +export const MarkdownReactiveScrollView = ({ children }: { children: ReactNode }) => { + const scrollViewRef = useAnimatedRef(); + const contentWidth = useSharedValue(0); + const visibleContentWidth = useSharedValue(0); + const offsetBeforeScroll = useSharedValue(0); + + const panGesture = Gesture.Pan() + .activeOffsetX([-5, 5]) + .onUpdate((event) => { + const { translationX } = event; + + scrollTo(scrollViewRef, offsetBeforeScroll.value - translationX, 0, false); + }) + .onEnd((event) => { + const { translationX } = event; + + const velocityEffect = event.velocityX * 0.3; + + const finalPosition = clamp( + offsetBeforeScroll.value - translationX - velocityEffect, + 0, + contentWidth.value - visibleContentWidth.value, + ); + + offsetBeforeScroll.value = finalPosition; + + scrollTo(scrollViewRef, finalPosition, 0, true); + }); + + return ( + + { + contentWidth.value = width; + }} + onLayout={(e) => { + visibleContentWidth.value = e.nativeEvent.layout.width; + }} + ref={scrollViewRef} + scrollEnabled={false} + > + {children} + + + ); +}; + const defaultMarkdownStyles: MarkdownStyle = { + codeBlock: { + fontFamily: Platform.OS === 'ios' ? 'Courier' : 'Monospace', + fontWeight: '500', + marginVertical: 8, + }, inlineCode: { fontSize: 13, padding: 3, @@ -60,6 +125,26 @@ const defaultMarkdownStyles: MarkdownStyle = { marginBottom: 8, marginTop: 8, }, + table: { + borderRadius: 3, + borderWidth: 1, + flex: 1, + flexDirection: 'row', + }, + tableHeader: { + flexDirection: 'row', + justifyContent: 'space-around', + }, + tableHeaderCell: { + fontWeight: '500', + }, + tableRow: { + alignItems: 'center', + justifyContent: 'space-around', + }, + tableRowCell: { + flex: 1, + }, }; const mentionsParseFunction: ParseFunction = (capture, parse, state) => ({ @@ -113,6 +198,13 @@ export const renderText = < color: colors.accent_blue, ...markdownStyles?.autolink, }, + codeBlock: { + ...defaultMarkdownStyles.codeBlock, + backgroundColor: colors.code_block, + color: colors.black, + padding: 8, + ...markdownStyles?.codeBlock, + }, inlineCode: { ...defaultMarkdownStyles.inlineCode, backgroundColor: colors.white_smoke, @@ -125,6 +217,35 @@ export const renderText = < color: colors.accent_blue, ...markdownStyles?.mentions, }, + table: { + ...defaultMarkdownStyles.table, + borderColor: colors.grey_dark, + marginVertical: 8, + ...markdownStyles?.table, + }, + tableHeader: { + ...defaultMarkdownStyles.tableHeader, + backgroundColor: colors.grey, + ...markdownStyles?.tableHeader, + }, + tableHeaderCell: { + ...defaultMarkdownStyles.tableHeaderCell, + padding: 5, + ...markdownStyles?.tableHeaderCell, + }, + tableRow: { + ...defaultMarkdownStyles.tableRow, + ...markdownStyles?.tableRow, + }, + tableRowCell: { + ...defaultMarkdownStyles.tableRowCell, + borderColor: colors.grey_dark, + padding: 5, + ...markdownStyles?.tableRowCell, + }, + tableRowLast: { + ...markdownStyles?.tableRowLast, + }, text: { ...defaultMarkdownStyles.text, color: colors.black, @@ -263,6 +384,18 @@ export const renderText = < /> ); + const codeBlockReact: ReactNodeOutput = (node, _, state) => ( + + {node?.content?.trim()} + + ); + + const tableReact: ReactNodeOutput = (node, output, state) => ( + + + + ); + const customRules = { // do not render images, we will scrape them out of the message and show on attachment card component image: { match: () => null }, @@ -283,6 +416,8 @@ export const renderText = < }, } : {}), + codeBlock: { react: codeBlockReact }, + table: { react: tableReact }, }; return ( @@ -373,3 +508,70 @@ const ListRow = ({ children, style }: PropsWithChildren) => ( const ListItem = ({ children, style }: PropsWithChildren) => ( {children} ); + +export type MarkdownTableProps = { + node: SingleASTNode; + output: ReactOutput; + state: State; + styles: Partial; +}; + +const transpose = (matrix: SingleASTNode[][]) => + matrix[0].map((_, colIndex) => matrix.map((row) => row[colIndex])); + +const MarkdownTable = ({ node, output, state, styles }: MarkdownTableProps) => { + const content = useMemo(() => { + const nodeContent = [node?.header, ...node?.cells]; + return transpose(nodeContent); + }, [node?.cells, node?.header]); + const columns = content?.map((column, idx) => ( + + )); + + return ( + + {columns} + + ); +}; + +export type MarkdownTableRowProps = { + items: SingleASTNode[]; + output: ReactOutput; + state: State; + styles: Partial; +}; + +const MarkdownTableColumn = ({ items, output, state, styles }: MarkdownTableRowProps) => { + const [headerCellContent, ...columnCellContents] = items; + + const ColumnCell = useCallback( + ({ content }: { content: SingleASTNode }) => + content ? ( + + {output(content, state)} + + ) : null, + [output, state, styles], + ); + + return ( + + {headerCellContent ? ( + + {output(headerCellContent, state)} + + ) : null} + {columnCellContents && + columnCellContents.map((content, idx) => ( + + ))} + + ); +}; diff --git a/package/src/components/Message/hooks/useStreamingMessage.ts b/package/src/components/Message/hooks/useStreamingMessage.ts new file mode 100644 index 0000000000..793c632c6f --- /dev/null +++ b/package/src/components/Message/hooks/useStreamingMessage.ts @@ -0,0 +1,54 @@ +import { useEffect, useRef, useState } from 'react'; + +import type { DefaultStreamChatGenerics } from '../../../types/types'; +import { StreamingMessageViewProps } from '../MessageSimple/StreamingMessageView'; + +export type UseStreamingMessageProps< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +> = Pick< + StreamingMessageViewProps, + 'letterInterval' | 'renderingLetterCount' +> & { text: string }; + +const DEFAULT_LETTER_INTERVAL = 0; +const DEFAULT_RENDERING_LETTER_COUNT = 2; + +/** + * A hook that returns text in a streamed, typewriter fashion. The speed of streaming is + * configurable. + * @param {number} [letterInterval=0] - The timeout between each typing animation in milliseconds. + * @param {number} [renderingLetterCount=2] - The number of letters to be rendered each time we update. + * @param {string} text - The text that we want to render in a typewriter fashion. + * @returns {{ streamedMessageText: string }} - A substring of the text property, up until we've finished rendering the typewriter animation. + */ +export const useStreamingMessage = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +>({ + letterInterval = DEFAULT_LETTER_INTERVAL, + renderingLetterCount = DEFAULT_RENDERING_LETTER_COUNT, + text, +}: UseStreamingMessageProps): { streamedMessageText: string } => { + const [streamedMessageText, setStreamedMessageText] = useState(text); + const textCursor = useRef(text.length); + + useEffect(() => { + const textLength = text.length; + const interval = setInterval(() => { + if (!text || textCursor.current >= textLength) { + clearInterval(interval); + } + const newCursorValue = textCursor.current + renderingLetterCount; + const newText = text.substring(0, newCursorValue); + textCursor.current += newText.length - textCursor.current; + const codeBlockCounts = (newText.match(/```/g) || []).length; + const shouldOptimisticallyCloseCodeBlock = codeBlockCounts > 0 && codeBlockCounts % 2 > 0; + setStreamedMessageText(shouldOptimisticallyCloseCodeBlock ? newText + '```' : newText); + }, letterInterval); + + return () => { + clearInterval(interval); + }; + }, [letterInterval, renderingLetterCount, text]); + + return { streamedMessageText }; +}; diff --git a/package/src/components/MessageInput/MessageInput.tsx b/package/src/components/MessageInput/MessageInput.tsx index 2a5eb598c2..d7985e4a3b 100644 --- a/package/src/components/MessageInput/MessageInput.tsx +++ b/package/src/components/MessageInput/MessageInput.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Modal, NativeSyntheticEvent, @@ -58,6 +58,7 @@ import { import { isImageMediaLibraryAvailable, triggerHaptic } from '../../native'; import type { Asset, DefaultStreamChatGenerics } from '../../types/types'; +import { AIStates, useAIState } from '../AITypingIndicatorView'; import { AutoCompleteInput } from '../AutoCompleteInput/AutoCompleteInput'; import { CreatePoll } from '../Poll/CreatePollContent'; @@ -159,6 +160,7 @@ type MessageInputPropsWithContext< | 'showPollCreationDialog' | 'sendMessage' | 'CreatePollContent' + | 'StopMessageStreamingButton' > & Pick, 'Reply'> & Pick< @@ -228,6 +230,7 @@ const MessageInputWithContext = < showPollCreationDialog, ShowThreadMessageInChannelButton, StartAudioRecordingButton, + StopMessageStreamingButton, suggestions, text, thread, @@ -728,6 +731,13 @@ const MessageInputWithContext = < })), }; + const { channel } = useChannelContext(); + const { aiState } = useAIState(channel); + + const stopGenerating = useCallback(() => channel?.stopAIResponse(), [channel]); + const shouldDisplayStopAIGeneration = + [AIStates.Thinking, AIStates.Generating].includes(aiState) && !!StopMessageStreamingButton; + return ( <> )} - {isSendingButtonVisible() && + {shouldDisplayStopAIGeneration ? ( + + ) : ( + isSendingButtonVisible() && (cooldownRemainingSeconds ? ( ) : ( @@ -842,7 +855,8 @@ const MessageInputWithContext = < disabled={sending.current || !isValidMessage() || (giphyActive && !isOnline)} /> - ))} + )) + )} {audioRecordingEnabled && !micLocked && ( void; +}; + +export const StopMessageStreamingButton = (props: StopMessageStreamingButtonProps) => { + const { onPress } = props; + + const { + theme: { + colors: { accent_blue }, + messageInput: { stopMessageStreamingButton, stopMessageStreamingIcon }, + }, + } = useTheme(); + + return ( + + + + ); +}; + +StopMessageStreamingButton.displayName = 'StopMessageStreamingButton{messageInput}'; diff --git a/package/src/components/index.ts b/package/src/components/index.ts index 9e94441424..63fc7d307b 100644 --- a/package/src/components/index.ts +++ b/package/src/components/index.ts @@ -97,6 +97,7 @@ export * from './KeyboardCompatibleView/KeyboardCompatibleView'; export * from './Message/hooks/useCreateMessageContext'; export * from './Message/hooks/useMessageActions'; export * from './Message/hooks/useMessageActionHandlers'; +export * from './Message/hooks/useStreamingMessage'; export * from './Message/Message'; export * from './Message/MessageSimple/MessageAvatar'; export * from './Message/MessageSimple/MessageBounce'; @@ -132,6 +133,7 @@ export * from './MessageInput/InputButtons'; export * from './MessageInput/MessageInput'; export * from './MessageInput/MoreOptionsButton'; export * from './MessageInput/SendButton'; +export * from './MessageInput/StopMessageStreamingButton'; export * from './MessageInput/ShowThreadMessageInChannelButton'; export * from './MessageInput/UploadProgressIndicator'; @@ -172,3 +174,6 @@ export * from './Spinner/Spinner'; export * from './Thread/Thread'; export * from './Thread/components/ThreadFooterComponent'; export * from './ThreadList/ThreadList'; + +export * from './Message/MessageSimple/StreamingMessageView'; +export * from './AITypingIndicatorView'; diff --git a/package/src/contexts/messageInputContext/MessageInputContext.tsx b/package/src/contexts/messageInputContext/MessageInputContext.tsx index 77576e0721..a84bf72413 100644 --- a/package/src/contexts/messageInputContext/MessageInputContext.tsx +++ b/package/src/contexts/messageInputContext/MessageInputContext.tsx @@ -29,7 +29,7 @@ import { useMessageDetailsForState } from './hooks/useMessageDetailsForState'; import { isUploadAllowed, MAX_FILE_SIZE_TO_UPLOAD, prettifyFileSize } from './utils/utils'; -import { PollContentProps } from '../../components'; +import { PollContentProps, StopMessageStreamingButtonProps } from '../../components'; import { AudioAttachmentProps } from '../../components/Attachment/AudioAttachment'; import { parseLinksFromText } from '../../components/Message/MessageSimple/utils/parseLinks'; import type { AttachButtonProps } from '../../components/MessageInput/AttachButton'; @@ -385,6 +385,7 @@ export type InputMessageInputContextValue< * Defaults to and accepts same props as: [AudioRecordingButton](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx) */ StartAudioRecordingButton: React.ComponentType>; + StopMessageStreamingButton: React.ComponentType; /** * Custom UI component to render upload progress indicator on attachment preview. * @@ -586,7 +587,9 @@ export const MessageInputProvider = < editing, initialValue, openPollCreationDialog: openPollCreationDialogFromContext, + StopMessageStreamingButton, } = value; + const { fileUploads, imageUploads, @@ -1481,6 +1484,7 @@ export const MessageInputProvider = < openPollCreationDialog, sendMessage, // overriding the originally passed in sendMessage showPollCreationDialog, + StopMessageStreamingButton, }); return ( ) => void; setQuotedMessageState: (message: MessageType | boolean) => void; supportedReactions: ReactionData[]; + /** + * UI component for StreamingMessageView. Displays the text of a message with a typewriter animation. + */ + StreamingMessageView: React.ComponentType>; /** * UI component for TypingIndicator * Defaults to: [TypingIndicator](https://getstream.io/chat/docs/sdk/reactnative/ui-components/typing-indicator/) diff --git a/package/src/contexts/themeContext/utils/theme.ts b/package/src/contexts/themeContext/utils/theme.ts index 9e380e064c..d69b93fc29 100644 --- a/package/src/contexts/themeContext/utils/theme.ts +++ b/package/src/contexts/themeContext/utils/theme.ts @@ -18,6 +18,7 @@ export const Colors = { black: '#000000', blue_alice: '#E9F2FF', border: '#00000014', // 14 = 8% opacity; top: x=0, y=-1; bottom: x=0, y=1 + code_block: '#DDDDDD', disabled: '#B4BBBA', grey: '#7A7A7A', grey_dark: '#72767E', @@ -79,7 +80,7 @@ export type MarkdownStyle = Partial<{ tableHeader: ViewStyle; tableHeaderCell: TextStyle; tableRow: ViewStyle; - tableRowCell: ViewStyle; + tableRowCell: TextStyle; tableRowLast: ViewStyle; text: TextStyle; u: TextStyle; @@ -87,6 +88,10 @@ export type MarkdownStyle = Partial<{ }>; export type Theme = { + aiTypingIndicatorView: { + container: ViewStyle; + text: TextStyle; + }; attachmentPicker: { bottomSheetContentContainer: ViewStyle; durationText: TextStyle; @@ -351,6 +356,8 @@ export type Theme = { innerContainer: ViewStyle; text: TextStyle; }; + stopMessageStreamingButton: ViewStyle; + stopMessageStreamingIcon: IconProps; suggestions: { command: { args: TextStyle; @@ -792,6 +799,10 @@ export type Theme = { }; export const defaultTheme: Theme = { + aiTypingIndicatorView: { + container: {}, + text: {}, + }, attachmentPicker: { bottomSheetContentContainer: {}, durationText: {}, @@ -1062,6 +1073,8 @@ export const defaultTheme: Theme = { innerContainer: {}, text: {}, }, + stopMessageStreamingButton: {}, + stopMessageStreamingIcon: {}, suggestions: { command: { args: {}, diff --git a/package/src/i18n/en.json b/package/src/i18n/en.json index eb3c96f8ad..dd0fd419c7 100644 --- a/package/src/i18n/en.json +++ b/package/src/i18n/en.json @@ -38,6 +38,7 @@ "Flag": "Flag", "Flag Message": "Flag Message", "Flag action failed either due to a network issue or the message is already flagged": "Flag action failed either due to a network issue or the message is already flagged.", + "Generating...": "Generating...", "Hold to start recording.": "Hold to start recording.", "How about sending your first message to a friend?": "How about sending your first message to a friend?", "Instant Commands": "Instant Commands", @@ -95,6 +96,7 @@ "Suggest an option": "Suggest an option", "The message has been reported to a moderator.": "The message has been reported to a moderator.", "The source message was deleted": "The source message was deleted", + "Thinking...": "Thinking...", "This is already an option": "This is already an option", "This reply was deleted": "This reply was deleted", "Thread Reply": "Thread Reply", diff --git a/package/src/i18n/es.json b/package/src/i18n/es.json index 576a1778dc..d8fe2ba9f7 100644 --- a/package/src/i18n/es.json +++ b/package/src/i18n/es.json @@ -38,6 +38,7 @@ "Flag": "Reportar", "Flag Message": "Reportar mensaje", "Flag action failed either due to a network issue or the message is already flagged": "El reporte falló debido a un problema de red o el mensaje ya fue reportado.", + "Generating...": "Generando...", "Hold to start recording.": "Mantén presionado para comenzar a grabar.", "How about sending your first message to a friend?": "¿Qué tal enviar tu primer mensaje a un amigo?", "Instant Commands": "Comandos instantáneos", @@ -97,6 +98,7 @@ "Suggest an option": "Sugerir una opción", "The message has been reported to a moderator.": "El mensaje ha sido reportado a un moderador.", "The source message was deleted": "El mensaje original fue eliminado", + "Thinking...": "Pensando...", "This is already an option": "Esto ya es una opción", "This reply was deleted": "Esta respuesta fue eliminada", "Thread Reply": "Respuesta de hilo", diff --git a/package/src/i18n/fr.json b/package/src/i18n/fr.json index 06afa66719..ab4d3bf426 100644 --- a/package/src/i18n/fr.json +++ b/package/src/i18n/fr.json @@ -38,6 +38,7 @@ "Flag": "Signaler", "Flag Message": "Signaler le message", "Flag action failed either due to a network issue or the message is already flagged": "L'action de signalisation a échoué en raison d'un problème de réseau ou le message est déjà signalé.", + "Generating...": "Génération...", "Hold to start recording.": "Hold to start recording.", "How about sending your first message to a friend?": "Et si vous envoyiez votre premier message à un ami ?", "Instant Commands": "Commandes Instantanées", @@ -97,6 +98,7 @@ "Suggest an option": "Suggérer une option", "The message has been reported to a moderator.": "Le message a été signalé à un modérateur.", "The source message was deleted": "Le message source a été supprimé", + "Thinking...": "Réflexion...", "This is already an option": "C'est déjà une option", "This reply was deleted": "Cette réponse a été supprimée", "Thread Reply": "Réponse à la discussion", diff --git a/package/src/i18n/he.json b/package/src/i18n/he.json index 48f3b0df02..b2bf283eee 100644 --- a/package/src/i18n/he.json +++ b/package/src/i18n/he.json @@ -38,6 +38,7 @@ "Flag": "סמן", "Flag Message": "סמן הודעה", "Flag action failed either due to a network issue or the message is already flagged": "פעולת הסימון נכשלה בגלל בעיית רשת או שההודעה כבר סומנה.", + "Generating...": "מייצר...", "Hold to start recording.": "לחץ והחזק כדי להתחיל להקליט.", "How about sending your first message to a friend?": "מה דעתך לשלוח את ההודעה הראשונה שלך לחבר?", "Instant Commands": "פעולות מיידיות", @@ -97,6 +98,7 @@ "Suggest an option": "הצע אפשרות", "The message has been reported to a moderator.": "ההודעה דווחה למנהל", "The source message was deleted": "ההודעה המקורית נמחקה", + "Thinking...": "חושב...", "This is already an option": "זו כבר אפשרות קיימת", "This reply was deleted": "התגובה הזו נמחקה", "Thread Reply": "הגב/י בשרשור", diff --git a/package/src/i18n/hi.json b/package/src/i18n/hi.json index a65b30cf97..a7bdd4f5ae 100644 --- a/package/src/i18n/hi.json +++ b/package/src/i18n/hi.json @@ -38,6 +38,7 @@ "Flag": "झंडा", "Flag Message": "झंडा संदेश", "Flag action failed either due to a network issue or the message is already flagged": "फ़्लैग कार्रवाई या तो नेटवर्क समस्या के कारण विफल हो गई या संदेश पहले से फ़्लैग किया गया है।", + "Generating...": "जनरेट कर रहा है...", "Hold to start recording.": "रिकॉर्डिंग शुरू करने के लिए दबाएं।", "How about sending your first message to a friend?": "किसी मित्र को अपना पहला संदेश भेजने के बारे में क्या ख़याल है?", "Instant Commands": "त्वरित कमांड", @@ -95,6 +96,7 @@ "Suggest an option": "एक विकल्प सुझाएं", "The message has been reported to a moderator.": "संदेश एक मॉडरेटर को सूचित किया गया है।", "The source message was deleted": "स्रोत संदेश हटा दिया गया है", + "Thinking...": "सोच रहा है...", "This is already an option": "यह पहले से एक विकल्प है", "This reply was deleted": "यह उत्तर हटा दिया गया है", "Thread Reply": "धागा जवाब", diff --git a/package/src/i18n/it.json b/package/src/i18n/it.json index 46799dd420..e6621a2ed3 100644 --- a/package/src/i18n/it.json +++ b/package/src/i18n/it.json @@ -38,6 +38,7 @@ "Flag": "Contrassegna", "Flag Message": "Contrassegna Messaggio", "Flag action failed either due to a network issue or the message is already flagged": "L'azione di segnalazione non è riuscita a causa di un problema di rete o il messaggio è già segnalato.", + "Generating...": "Generando...", "Hold to start recording.": "Tieni premuto per avviare la registrazione.", "How about sending your first message to a friend?": "Che ne dici di inviare il tuo primo messaggio ad un amico?", "Instant Commands": "Comandi Istantanei", @@ -97,6 +98,7 @@ "Suggest an option": "Suggerisci un'opzione", "The message has been reported to a moderator.": "Il messaggio è stato segnalato a un moderatore.", "The source message was deleted": "Il messaggio originale è stato eliminato", + "Thinking...": "Pensando...", "This is already an option": "Questa è già un'opzione", "This reply was deleted": "Questa risposta è stata eliminata", "Thread Reply": "Rispondi alla Discussione", diff --git a/package/src/i18n/ja.json b/package/src/i18n/ja.json index cd3a540baf..047978bdf9 100644 --- a/package/src/i18n/ja.json +++ b/package/src/i18n/ja.json @@ -38,6 +38,7 @@ "Flag": "フラグ", "Flag Message": "メッセージをフラグする", "Flag action failed either due to a network issue or the message is already flagged": "ネットワーク接続に問題があるか、すでにフラグが設定されているため、フラグが失敗しました。", + "Generating...": "生成中...", "Hold to start recording.": "録音を開始するには押し続けてください。", "How about sending your first message to a friend?": "初めてのメッセージを友達に送ってみてはいかがでしょうか?", "Instant Commands": "インスタントコマンド", @@ -95,6 +96,7 @@ "Suggest an option": "オプションを提案", "The message has been reported to a moderator.": "メッセージはモデレーターに報告されました。", "The source message was deleted": "元のメッセージが削除されました", + "Thinking...": "考え中...", "This is already an option": "これはすでにオプションです", "This reply was deleted": "この返信は削除されました", "Thread Reply": "スレッドの返信", diff --git a/package/src/i18n/ko.json b/package/src/i18n/ko.json index 5ab6624773..5ecab344c7 100644 --- a/package/src/i18n/ko.json +++ b/package/src/i18n/ko.json @@ -38,6 +38,7 @@ "Flag": "플래그", "Flag Message": "메시지를 플래그하기", "Flag action failed either due to a network issue or the message is already flagged": "네트워크 연결에 문제가 있거나 이미 플래그 되어서 플래그에 실패했습니다.", + "Generating...": "생성 중...", "Hold to start recording.": "녹음을 시작하려면 눌러주세요.", "How about sending your first message to a friend?": "친구에게 첫 번째 메시지를 보내는 것은 어떻습니까?", "Instant Commands": "인스턴트 명령", @@ -95,6 +96,7 @@ "Suggest an option": "옵션 제안", "The message has been reported to a moderator.": "메시지는 운영자에보고되었습니다.", "The source message was deleted": "원본 메시지가 삭제되었습니다", + "Thinking...": "생각 중...", "This is already an option": "이미 존재하는 옵션입니다", "This reply was deleted": "이 답글은 삭제되었습니다", "Thread Reply": "스레드\u3000답장", diff --git a/package/src/i18n/nl.json b/package/src/i18n/nl.json index 258c6dbf2f..ce43d7f80a 100644 --- a/package/src/i18n/nl.json +++ b/package/src/i18n/nl.json @@ -38,6 +38,7 @@ "Flag": "Markeer", "Flag Message": "Markeer bericht", "Flag action failed either due to a network issue or the message is already flagged": "Rapporteren mislukt door een netwerk fout of het berich is al gerapporteerd", + "Generating...": "Aan het genereren...", "Hold to start recording.": "Houd vast om opname te starten.", "How about sending your first message to a friend?": "Wat dacht je ervan om je eerste bericht naar een vriend te sturen?", "Instant Commands": "Directe Opdrachten", @@ -95,6 +96,7 @@ "Suggest an option": "Stel een optie voor", "The message has been reported to a moderator.": "Het bericht is gerapporteerd aan een moderator.", "The source message was deleted": "Het oorspronkelijke bericht is verwijderd", + "Thinking...": "Aan het denken...", "This is already an option": "Dit is al een optie", "This reply was deleted": "Deze reactie is verwijderd", "Thread Reply": "Discussie beantwoorden", diff --git a/package/src/i18n/pt-br.json b/package/src/i18n/pt-br.json index 2d4ff45565..c6f24b0c2d 100644 --- a/package/src/i18n/pt-br.json +++ b/package/src/i18n/pt-br.json @@ -38,6 +38,7 @@ "Flag": "Reportar", "Flag Message": "Reportar Mensagem", "Flag action failed either due to a network issue or the message is already flagged": "A ação para reportar a mensagem falhou devido a um problema de rede ou a mensagem já foi reportada.", + "Generating...": "Gerando...", "Hold to start recording.": "Mantenha pressionado para começar a gravar.", "How about sending your first message to a friend?": "Que tal enviar sua primeira mensagem para um amigo?", "Instant Commands": "Comandos Instantâneos", @@ -97,6 +98,7 @@ "Suggest an option": "Sugerir uma opção", "The message has been reported to a moderator.": "A mensagem foi relatada a um moderador.", "The source message was deleted": "A mensagem original foi excluída", + "Thinking...": "Pensando...", "This is already an option": "Isso já é uma opção", "This reply was deleted": "Esta resposta foi excluída", "Thread Reply": "Respostas de Tópico", diff --git a/package/src/i18n/ru.json b/package/src/i18n/ru.json index fa2f946ea2..6ad23a565d 100644 --- a/package/src/i18n/ru.json +++ b/package/src/i18n/ru.json @@ -38,6 +38,7 @@ "Flag": "Пометить", "Flag Message": "Пометить сообщение", "Flag action failed either due to a network issue or the message is already flagged": "Не удалось отправить жалобу. Возможные причины: проблема с подключением к интернету или ваша жалоба уже была принята.", + "Generating...": "Генерирую...", "Hold to start recording.": "Удерживайте, чтобы начать запись.", "How about sending your first message to a friend?": "Как насчет отправки первого сообщения другу?", "Instant Commands": "Мгновенные Команды", @@ -99,6 +100,7 @@ "Suggest an option": "Предложить вариант", "The message has been reported to a moderator.": "Сообщение отправлено модератору.", "The source message was deleted": "Исходное сообщение было удалено", + "Thinking...": "Думаю...", "This is already an option": "Это уже вариант", "This reply was deleted": "Этот ответ был удалён", "Thread Reply": "Тема Ответить", diff --git a/package/src/i18n/tr.json b/package/src/i18n/tr.json index 4493eabbef..f1bd61a28c 100644 --- a/package/src/i18n/tr.json +++ b/package/src/i18n/tr.json @@ -38,6 +38,7 @@ "Flag": "Raporla", "Flag Message": "Mesajı Raporla", "Flag action failed either due to a network issue or the message is already flagged": "Mesajın daha önce raporlanmış olması veya bir ağ bağlantısı sorunu nedeniyle raporlama işlemi başarısız oldu.", + "Generating...": "Oluşturuluyor...", "Hold to start recording.": "Kayıt yapmak için basılı tutun.", "How about sending your first message to a friend?": "İlk mesajınızı bir arkadaşınıza göndermeye ne dersiniz?", "Instant Commands": "Anlık Komutlar", @@ -95,6 +96,7 @@ "Suggest an option": "Bir seçenek öner", "The message has been reported to a moderator.": "Mesaj moderatöre bildirildi.", "The source message was deleted": "Kaynak mesaj silindi", + "Thinking...": "Düşünüyor...", "This is already an option": "Bu zaten bir seçenek", "This reply was deleted": "Bu yanıt silindi", "Thread Reply": "Konu Yanıtı", diff --git a/package/src/utils/utils.ts b/package/src/utils/utils.ts index 009ba25852..18434107aa 100644 --- a/package/src/utils/utils.ts +++ b/package/src/utils/utils.ts @@ -108,7 +108,7 @@ export const isEditedMessage = < StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, >( message: MessageType, -) => !!message.message_text_updated_at; +) => !!message.message_text_updated_at && !message.ai_generated; /** * Default emoji search index for auto complete text input