stream-chat-react 13.2.0 → 13.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -98,7 +98,9 @@ const ChannelInner = (props) => {
98
98
  }
99
99
  else {
100
100
  const markReadResponse = await channel.markRead();
101
- if (updateChannelUiUnreadState && markReadResponse) {
101
+ // markReadResponse.event can be null in case of a user that is not a member of a channel being marked read
102
+ // in that case event is null and we should not set unread UI
103
+ if (updateChannelUiUnreadState && markReadResponse?.event) {
102
104
  _setChannelUnreadUiState({
103
105
  last_read: lastRead.current,
104
106
  last_read_message_id: markReadResponse.event.last_read_message_id,
@@ -11,6 +11,7 @@ import type { ChatContextValue } from '../../context';
11
11
  import type { ChannelAvatarProps } from '../Avatar';
12
12
  import type { TranslationContextValue } from '../../context/TranslationContext';
13
13
  import type { PaginatorProps } from '../../types/types';
14
+ import type { LoadingErrorIndicatorProps } from '../Loading';
14
15
  export type ChannelListProps = {
15
16
  /** Additional props for underlying ChannelSearch component and channel search controller, [available props](https://getstream.io/chat/docs/sdk/react/utility-components/channel_search/#props) */
16
17
  additionalChannelSearchProps?: Omit<ChannelSearchProps, 'setChannels'>;
@@ -40,7 +41,7 @@ export type ChannelListProps = {
40
41
  /** Custom UI component to display the container for the queried channels, defaults to and accepts same props as: [ChannelListMessenger](https://github.com/GetStream/stream-chat-react/blob/master/src/components/ChannelList/ChannelListMessenger.tsx) */
41
42
  List?: React.ComponentType<ChannelListMessengerProps>;
42
43
  /** Custom UI component to display the loading error indicator, defaults to component that renders null */
43
- LoadingErrorIndicator?: React.ComponentType;
44
+ LoadingErrorIndicator?: React.ComponentType<LoadingErrorIndicatorProps>;
44
45
  /** Custom UI component to display the loading state, defaults to and accepts same props as: [LoadingChannels](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Loading/LoadingChannels.tsx) */
45
46
  LoadingIndicator?: React.ComponentType;
46
47
  /** When true, channels won't dynamically sort by most recent message */
@@ -1,6 +1,7 @@
1
1
  import React from 'react';
2
2
  import type { PropsWithChildren } from 'react';
3
3
  import type { APIErrorResponse, Channel, ErrorFromResponse } from 'stream-chat';
4
+ import type { LoadingErrorIndicatorProps } from '../Loading';
4
5
  export type ChannelListMessengerProps = {
5
6
  /** Whether the channel query request returned an errored response */
6
7
  error: ErrorFromResponse<APIErrorResponse> | null;
@@ -9,7 +10,7 @@ export type ChannelListMessengerProps = {
9
10
  /** Whether the channels are currently loading */
10
11
  loading?: boolean;
11
12
  /** Custom UI component to display the loading error indicator, defaults to component that renders null */
12
- LoadingErrorIndicator?: React.ComponentType;
13
+ LoadingErrorIndicator?: React.ComponentType<LoadingErrorIndicatorProps>;
13
14
  /** Custom UI component to display a loading indicator, defaults to and accepts same props as: [LoadingChannels](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Loading/LoadingChannels.tsx) */
14
15
  LoadingIndicator?: React.ComponentType;
15
16
  /** Local state hook that resets the currently loaded channels */
@@ -9,7 +9,7 @@ export const ChannelListMessenger = (props) => {
9
9
  const { children, error = null, loading, LoadingErrorIndicator = NullComponent, LoadingIndicator = LoadingChannels, } = props;
10
10
  const { t } = useTranslationContext('ChannelListMessenger');
11
11
  if (error) {
12
- return React.createElement(LoadingErrorIndicator, null);
12
+ return React.createElement(LoadingErrorIndicator, { error: error });
13
13
  }
14
14
  if (loading) {
15
15
  return React.createElement(LoadingIndicator, null);
@@ -24,7 +24,7 @@ export const useChat = ({ client, defaultLanguage = 'en', i18nInstance, initialN
24
24
  useEffect(() => {
25
25
  if (!client)
26
26
  return;
27
- const version = "13.2.0";
27
+ const version = "13.2.2";
28
28
  const userAgent = client.getUserAgent();
29
29
  if (!userAgent.includes('stream-chat-react')) {
30
30
  // result looks like: 'stream-chat-react-2.3.2-stream-chat-javascript-client-browser-2.2.2'
@@ -71,6 +71,6 @@ export const FormDialog = ({ className, close, fields, onSubmit, shouldDisableSu
71
71
  }),
72
72
  React.createElement(FieldError, { text: fieldErrors[id]?.message })))),
73
73
  React.createElement("div", { className: 'str-chat__dialog__controls' },
74
- React.createElement("button", { className: 'str-chat__dialog__controls-button str-chat__dialog__controls-button--cancel', onClick: close }, t('Cancel')),
74
+ React.createElement("button", { className: 'str-chat__dialog__controls-button str-chat__dialog__controls-button--cancel', onClick: close, type: 'button' }, t('Cancel')),
75
75
  React.createElement("button", { className: 'str-chat__dialog__controls-button str-chat__dialog__controls-button--submit', disabled: Object.keys(fieldErrors).length > 0 || shouldDisableSubmitButton?.(value), type: 'submit' }, t('Send')))))));
76
76
  };
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
  export type LoadingErrorIndicatorProps = {
3
3
  /** Error object */
4
- error?: Error;
4
+ error?: Error | null;
5
5
  };
6
6
  export declare const LoadingErrorIndicator: ({ error }: LoadingErrorIndicatorProps) => React.JSX.Element | null;
@@ -16,4 +16,4 @@ export type RenderTextOptions = {
16
16
  getRehypePlugins?: RenderTextPluginConfigurator;
17
17
  getRemarkPlugins?: RenderTextPluginConfigurator;
18
18
  };
19
- export declare const renderText: (text?: string, mentionedUsers?: UserResponse[], { allowedTagNames, customMarkDownRenderers, getRehypePlugins, getRemarkPlugins, }?: RenderTextOptions) => React.JSX.Element | null;
19
+ export declare const renderText: (text?: string, mentionedUsers?: UserResponse[], { allowedTagNames, customMarkDownRenderers, getRehypePlugins, getRemarkPlugins, }?: RenderTextOptions) => React.JSX.Element | null | undefined;
@@ -1,10 +1,9 @@
1
1
  import React from 'react';
2
2
  import ReactMarkdown, { defaultUrlTransform } from 'react-markdown';
3
3
  import { find } from 'linkifyjs';
4
- import uniqBy from 'lodash.uniqby';
5
4
  import remarkGfm from 'remark-gfm';
6
5
  import { Anchor, Emoji, Mention } from './componentRenderers';
7
- import { detectHttp, escapeRegExp, matchMarkdownLinks, messageCodeBlocks } from './regex';
6
+ import { detectHttp, matchMarkdownLinks, messageCodeBlocks } from './regex';
8
7
  import { emojiMarkdownPlugin, mentionsMarkdownPlugin } from './rehypePlugins';
9
8
  import { htmlToTextPlugin, keepLineBreaksPlugin } from './remarkPlugins';
10
9
  import { ErrorBoundary } from '../../UtilityComponents';
@@ -67,8 +66,12 @@ export const renderText = (text, mentionedUsers, { allowedTagNames = defaultAllo
67
66
  let newText = text;
68
67
  const markdownLinks = matchMarkdownLinks(newText);
69
68
  const codeBlocks = messageCodeBlocks(newText);
70
- // extract all valid links/emails within text and replace it with proper markup
71
- uniqBy([...find(newText, 'email'), ...find(newText, 'url')], 'value').forEach(({ href, type, value }) => {
69
+ // Extract all valid links/emails within text and replace it with proper markup
70
+ // Revert the link order to avoid getting out of sync of the original start and end positions of links
71
+ // - due to the addition of new characters when creating Markdown links
72
+ const links = [...find(newText, 'email'), ...find(newText, 'url')];
73
+ for (let i = links.length - 1; i >= 0; i--) {
74
+ const { end, href, start, type, value } = links[i];
72
75
  const linkIsInBlock = codeBlocks.some((block) => block?.includes(value));
73
76
  // check if message is already markdown
74
77
  const noParsingNeeded = markdownLinks &&
@@ -77,7 +80,7 @@ export const renderText = (text, mentionedUsers, { allowedTagNames = defaultAllo
77
80
  const strippedText = text?.replace(detectHttp, '');
78
81
  if (!strippedHref || !strippedText)
79
82
  return false;
80
- return (strippedHref.includes(strippedText) || strippedText.includes(strippedHref));
83
+ return strippedHref.includes(strippedText) || strippedText.includes(strippedHref);
81
84
  });
82
85
  if (noParsingNeeded.length > 0 || linkIsInBlock)
83
86
  return;
@@ -87,24 +90,30 @@ export const renderText = (text, mentionedUsers, { allowedTagNames = defaultAllo
87
90
  // in that case, we check whether the found e-mail is actually a mention
88
91
  // by naively checking for an existence of @ sign in front of it.
89
92
  if (type === 'email' && mentionedUsers) {
90
- const emailMatchesWithName = mentionedUsers.some((u) => u.name === value);
93
+ const emailMatchesWithName = mentionedUsers.find((u) => u.name === value);
91
94
  if (emailMatchesWithName) {
92
- newText = newText.replace(new RegExp(escapeRegExp(value), 'g'), (match, position) => {
93
- const isMention = newText.charAt(position - 1) === '@';
94
- // in case of mention, we leave the match in its original form,
95
- // and we let `mentionsMarkdownPlugin` to do its job
96
- return isMention ? match : `[${match}](${encodeDecode(href)})`;
97
- });
98
- return;
95
+ // FIXME: breaks if the mention symbol is not '@'
96
+ const isMention = newText.charAt(start - 1) === '@';
97
+ // in case of mention, we leave the match in its original form,
98
+ // and we let `mentionsMarkdownPlugin` to do its job
99
+ newText =
100
+ newText.slice(0, start) +
101
+ (isMention ? value : `[${value}](${encodeDecode(href)})`) +
102
+ newText.slice(end);
99
103
  }
100
104
  }
101
- const displayLink = type === 'email' ? value : formatUrlForDisplay(href);
102
- newText = newText.replace(new RegExp(escapeRegExp(value), 'g'), `[${displayLink}](${encodeDecode(href)})`);
105
+ else {
106
+ const displayLink = type === 'email' ? value : formatUrlForDisplay(href);
107
+ newText =
108
+ newText.slice(0, start) +
109
+ `[${displayLink}](${encodeDecode(href)})` +
110
+ newText.slice(end);
111
+ }
103
112
  }
104
113
  catch (e) {
105
114
  void e;
106
115
  }
107
- });
116
+ }
108
117
  const remarkPlugins = [
109
118
  htmlToTextPlugin,
110
119
  keepLineBreaksPlugin,
@@ -28,7 +28,7 @@ export const EditMessageForm = () => {
28
28
  return (React.createElement("form", { autoComplete: 'off', className: 'str-chat__edit-message-form', onSubmit: handleSubmit },
29
29
  React.createElement(MessageInputFlat, null),
30
30
  React.createElement("div", { className: 'str-chat__edit-message-form-options' },
31
- React.createElement("button", { className: 'str-chat__edit-message-cancel', "data-testid": 'cancel-button', onClick: cancel }, t('Cancel')),
31
+ React.createElement("button", { className: 'str-chat__edit-message-cancel', "data-testid": 'cancel-button', onClick: cancel, type: 'button' }, t('Cancel')),
32
32
  React.createElement(EditMessageFormSendButton, null))));
33
33
  };
34
34
  export const EditMessageModal = ({ additionalMessageInputProps, }) => {
@@ -40,5 +40,5 @@ export const EditMessageModal = ({ additionalMessageInputProps, }) => {
40
40
  messageComposer.restore();
41
41
  }, [clearEditingState, messageComposer]);
42
42
  return (React.createElement(Modal, { className: 'str-chat__edit-message-modal', onClose: onEditModalClose, open: true },
43
- React.createElement(MessageInput, { clearEditingState: clearEditingState, hideSendButton: true, Input: EditMessageInput, ...additionalMessageInputProps })));
43
+ React.createElement(MessageInput, { clearEditingState: clearEditingState, focus: true, hideSendButton: true, Input: EditMessageInput, ...additionalMessageInputProps })));
44
44
  };
@@ -25,7 +25,10 @@ const MessageInputProvider = (props) => {
25
25
  }, [messageComposer]);
26
26
  useEffect(() => {
27
27
  const threadId = messageComposer.threadId;
28
- if (!threadId || !messageComposer.channel || !messageComposer.compositionIsEmpty)
28
+ if (!threadId ||
29
+ !messageComposer.channel ||
30
+ !messageComposer.compositionIsEmpty ||
31
+ !messageComposer.config.drafts.enabled)
29
32
  return;
30
33
  // get draft data for legacy thead composer
31
34
  messageComposer.channel.getDraft({ parent_id: threadId }).then(({ draft }) => {
@@ -38,5 +38,5 @@ export const MultipleAnswersField = () => {
38
38
  ? e.target.value
39
39
  : pollComposer.max_votes_allowed,
40
40
  }, nativeFieldValidation);
41
- }, placeholder: t('Maximum number of votes (from 2 to 10)'), type: 'number', value: max_votes_allowed }))))));
41
+ }, placeholder: t('Maximum number of votes (from 2 to 10)'), type: 'text', value: max_votes_allowed }))))));
42
42
  };
@@ -35,7 +35,8 @@ export const OptionFieldSet = () => {
35
35
  options: { index: i, text: e.target.value },
36
36
  });
37
37
  }, onKeyUp: (event) => {
38
- if (event.key === 'Enter') {
38
+ const isFocusedLastOptionField = i === options.length - 1;
39
+ if (event.key === 'Enter' && !isFocusedLastOptionField) {
39
40
  const nextInputId = options[i + 1].id;
40
41
  document.getElementById(nextInputId)?.focus();
41
42
  }
@@ -10,7 +10,7 @@ export const PollCreationDialogControls = ({ close, }) => {
10
10
  React.createElement("button", { className: 'str-chat__dialog__controls-button str-chat__dialog__controls-button--cancel', onClick: () => {
11
11
  messageComposer.pollComposer.initState();
12
12
  close();
13
- } }, t('Cancel')),
13
+ }, type: 'button' }, t('Cancel')),
14
14
  React.createElement("button", { className: 'str-chat__dialog__controls-button str-chat__dialog__controls-button--submit', disabled: !canCreatePoll, onClick: () => {
15
15
  messageComposer
16
16
  .createPoll()
@@ -20,7 +20,7 @@ export const UploadButton = forwardRef(function UploadButton({ onFileChange, res
20
20
  export const FileInput = UploadButton;
21
21
  export const UploadFileInput = forwardRef(function UploadFileInput({ className, onFileChange: onFileChangeCustom, ...props }, ref) {
22
22
  const { t } = useTranslationContext('UploadFileInput');
23
- const { cooldownRemaining } = useMessageInputContext();
23
+ const { cooldownRemaining, textareaRef } = useMessageInputContext();
24
24
  const messageComposer = useMessageComposer();
25
25
  const { attachmentManager } = messageComposer;
26
26
  const { isUploadEnabled } = useAttachmentManagerState();
@@ -28,7 +28,8 @@ export const UploadFileInput = forwardRef(function UploadFileInput({ className,
28
28
  const id = useMemo(() => nanoid(), []);
29
29
  const onFileChange = useCallback((files) => {
30
30
  attachmentManager.uploadFiles(files);
31
+ textareaRef.current?.focus();
31
32
  onFileChangeCustom?.(files);
32
- }, [onFileChangeCustom, attachmentManager]);
33
+ }, [onFileChangeCustom, attachmentManager, textareaRef]);
33
34
  return (React.createElement(FileInput, { accept: acceptedFiles?.join(','), "aria-label": t('aria/File upload'), "data-testid": 'file-input', disabled: !isUploadEnabled || !!cooldownRemaining, id: id, multiple: maxNumberOfFilesPerMessage > 1, ...props, className: clsx('str-chat__file-input', className), onFileChange: onFileChange, ref: ref }));
34
35
  });
@@ -17,6 +17,12 @@ const searchSourceStateSelector = (state) => ({
17
17
  const configStateSelector = (state) => ({
18
18
  enabled: state.text.enabled,
19
19
  });
20
+ const messageComposerStateSelector = (state) => ({
21
+ quotedMessage: state.quotedMessage,
22
+ });
23
+ const attachmentManagerStateSelector = (state) => ({
24
+ attachments: state.attachments,
25
+ });
20
26
  /**
21
27
  * isComposing prevents double submissions in Korean and other languages.
22
28
  * starting point for a read:
@@ -27,7 +33,7 @@ const defaultShouldSubmit = (event) => event.key === 'Enter' && !event.shiftKey
27
33
  export const TextareaComposer = ({ className, closeSuggestionsOnClickOutside, containerClassName, listClassName, maxRows: maxRowsProp, minRows: minRowsProp, onBlur, onChange, onKeyDown, onScroll, onSelect, placeholder: placeholderProp, shouldSubmit: shouldSubmitProp, ...restTextareaProps }) => {
28
34
  const { t } = useTranslationContext();
29
35
  const { AutocompleteSuggestionList = DefaultSuggestionList } = useComponentContext();
30
- const { additionalTextareaProps, cooldownRemaining, handleSubmit, maxRows: maxRowsContext, minRows: minRowsContext, onPaste, shouldSubmit: shouldSubmitContext, textareaRef, } = useMessageInputContext();
36
+ const { additionalTextareaProps, cooldownRemaining, focus, handleSubmit, maxRows: maxRowsContext, minRows: minRowsContext, onPaste, shouldSubmit: shouldSubmitContext, textareaRef, } = useMessageInputContext();
31
37
  const maxRows = maxRowsProp ?? maxRowsContext ?? 1;
32
38
  const minRows = minRowsProp ?? minRowsContext;
33
39
  const placeholder = placeholderProp ?? additionalTextareaProps?.placeholder;
@@ -36,6 +42,8 @@ export const TextareaComposer = ({ className, closeSuggestionsOnClickOutside, co
36
42
  const { textComposer } = messageComposer;
37
43
  const { selection, suggestions, text } = useStateStore(textComposer.state, textComposerStateSelector);
38
44
  const { enabled } = useStateStore(messageComposer.configState, configStateSelector);
45
+ const { quotedMessage } = useStateStore(messageComposer.state, messageComposerStateSelector);
46
+ const { attachments } = useStateStore(messageComposer.attachmentManager.state, attachmentManagerStateSelector);
39
47
  const { isLoadingItems } = useStateStore(suggestions?.searchSource.state, searchSourceStateSelector) ?? {};
40
48
  const containerRef = useRef(null);
41
49
  const [focusedItemIndex, setFocusedItemIndex] = useState(0);
@@ -149,11 +157,30 @@ export const TextareaComposer = ({ className, closeSuggestionsOnClickOutside, co
149
157
  setFocusedItemIndex(0);
150
158
  }
151
159
  }, [textComposer.suggestions]);
160
+ useEffect(() => {
161
+ const textareaIsFocused = textareaRef.current?.matches(':focus');
162
+ if (!textareaRef.current || textareaIsFocused || !focus)
163
+ return;
164
+ textareaRef.current.focus();
165
+ }, [attachments, focus, quotedMessage, textareaRef]);
166
+ useEffect(() => {
167
+ /**
168
+ * The textarea value has to be overridden outside the render cycle so that the events like compositionend can be triggered.
169
+ * If we have overridden the value during the component rendering, the compositionend event would not be triggered, and
170
+ * it would not be possible to type composed characters (ô).
171
+ * On the other hand, just removing the value override via prop (value={text}) would not allow us to change the text based on
172
+ * middleware results (e.g. replace characters with emojis)
173
+ */
174
+ const textarea = textareaRef.current;
175
+ if (!textarea)
176
+ return;
177
+ textarea.value = text;
178
+ }, [textareaRef, text]);
152
179
  return (React.createElement("div", { className: clsx('rta', 'str-chat__textarea str-chat__message-textarea-react-host', containerClassName, {
153
180
  ['rta--loading']: isLoadingItems,
154
181
  }), ref: containerRef },
155
182
  React.createElement(Textarea, { ...additionalTextareaProps, ...restTextareaProps, "aria-label": cooldownRemaining ? t('Slow Mode ON') : placeholder, className: clsx('rta__textarea', 'str-chat__textarea__textarea str-chat__message-textarea', className), "data-testid": 'message-input', disabled: !enabled || !!cooldownRemaining, maxRows: maxRows, minRows: minRows, onBlur: onBlur, onChange: changeHandler, onCompositionEnd: onCompositionEnd, onCompositionStart: onCompositionStart, onKeyDown: keyDownHandler, onPaste: onPaste, onScroll: scrollHandler, onSelect: setSelectionDebounced, placeholder: placeholder || t('Type your message'), ref: (ref) => {
156
183
  textareaRef.current = ref;
157
- }, value: text }),
184
+ } }),
158
185
  !isComposing && (React.createElement(AutocompleteSuggestionList, { className: listClassName, closeOnClickOutside: closeSuggestionsOnClickOutside, focusedItemIndex: focusedItemIndex, setFocusedItemIndex: setFocusedItemIndex }))));
159
186
  };