diff --git a/docusaurus/docs/React/assets/add-poll-comment-form.png b/docusaurus/docs/React/assets/add-poll-comment-form.png new file mode 100644 index 000000000..bed1b19d8 Binary files /dev/null and b/docusaurus/docs/React/assets/add-poll-comment-form.png differ diff --git a/docusaurus/docs/React/assets/dashboard-channel-type-feature-configuration-ui.png b/docusaurus/docs/React/assets/dashboard-channel-type-feature-configuration-ui.png new file mode 100644 index 000000000..b9f32bda7 Binary files /dev/null and b/docusaurus/docs/React/assets/dashboard-channel-type-feature-configuration-ui.png differ diff --git a/docusaurus/docs/React/assets/dashboard-roles-permissions-ui.png b/docusaurus/docs/React/assets/dashboard-roles-permissions-ui.png new file mode 100644 index 000000000..871baba4b Binary files /dev/null and b/docusaurus/docs/React/assets/dashboard-roles-permissions-ui.png differ diff --git a/docusaurus/docs/React/assets/end-poll-dialog.png b/docusaurus/docs/React/assets/end-poll-dialog.png new file mode 100644 index 000000000..bb7647821 Binary files /dev/null and b/docusaurus/docs/React/assets/end-poll-dialog.png differ diff --git a/docusaurus/docs/React/assets/message-with-poll.png b/docusaurus/docs/React/assets/message-with-poll.png new file mode 100644 index 000000000..54184f88c Binary files /dev/null and b/docusaurus/docs/React/assets/message-with-poll.png differ diff --git a/docusaurus/docs/React/assets/poll-comment-list.png b/docusaurus/docs/React/assets/poll-comment-list.png new file mode 100644 index 000000000..415a9066f Binary files /dev/null and b/docusaurus/docs/React/assets/poll-comment-list.png differ diff --git a/docusaurus/docs/React/assets/poll-creation-dialog.png b/docusaurus/docs/React/assets/poll-creation-dialog.png new file mode 100644 index 000000000..4c4abf320 Binary files /dev/null and b/docusaurus/docs/React/assets/poll-creation-dialog.png differ diff --git a/docusaurus/docs/React/assets/poll-option-full-list.png b/docusaurus/docs/React/assets/poll-option-full-list.png new file mode 100644 index 000000000..c20f0cf76 Binary files /dev/null and b/docusaurus/docs/React/assets/poll-option-full-list.png differ diff --git a/docusaurus/docs/React/assets/poll-results.png b/docusaurus/docs/React/assets/poll-results.png new file mode 100644 index 000000000..f4df6626f Binary files /dev/null and b/docusaurus/docs/React/assets/poll-results.png differ diff --git a/docusaurus/docs/React/assets/suggest-poll-option-form.png b/docusaurus/docs/React/assets/suggest-poll-option-form.png new file mode 100644 index 000000000..3a333d80a Binary files /dev/null and b/docusaurus/docs/React/assets/suggest-poll-option-form.png differ diff --git a/docusaurus/docs/React/components/contexts/component-context.mdx b/docusaurus/docs/React/components/contexts/component-context.mdx index 6e4318f2f..7239e3a57 100644 --- a/docusaurus/docs/React/components/contexts/component-context.mdx +++ b/docusaurus/docs/React/components/contexts/component-context.mdx @@ -65,6 +65,30 @@ Custom UI component to display a attachment previews in `MessageInput`. | --------- | ---------------------------------------------------------------------------------------------- | | component | | +### AttachmentSelector + +Custom UI component to control adding attachments to MessageInput, defaults to and accepts same props as: + +| Type | Default | +| --------- | ---------------------------------------------------------------------------------------- | +| component | | + +### AttachmentSelectorInitiationButtonContents + +Custom UI component for contents of attachment selector initiation button. + +| Type | +| --------- | +| component | + +### AudioRecorder + +Custom UI component to display AudioRecorder in `MessageInput`. + +| Type | Default | +| --------- | ------------------------------------------------------------------------------ | +| component | | + ### AutocompleteSuggestionItem Custom UI component to override the default suggestion Item component. @@ -155,9 +179,9 @@ Custom UI component to be displayed when the `MessageList` is empty. | --------- | ------------------------------------------------------------------------------------------------- | | component | | -### FileUploadIcon +### FileUploadIcon (deprecated) -Custom UI component for file upload icon. +Custom UI component for file upload icon. The component is now deprecated. Use [`AttachmentSelectorInitiationButtonContents`](#attachmentselectorinitiationbuttoncontents) instead. | Type | Default | | --------- | ----------------------------------------------------------------------- | @@ -309,6 +333,46 @@ Custom UI component to override default pinned message indicator. | --------- | ---------------------------------------------------------------- | | component | | +### PollActions + +Custom UI component to override default poll actions rendering in a message. + +| Type | Default | +| --------- | ------------------------------------------------------------------------------ | +| component | | + +### PollContent + +Custom UI component to override default poll rendering in a message. + +| Type | Default | +| --------- | ------------------------------------------------------------------ | +| component | | + +### PollCreationDialog + +Custom UI component to override default poll creation dialog contents. + +| Type | Default | +| --------- | --------------------------------------------------------------------------------------------------- | +| component | | + +### PollHeader + +Custom UI component to override default poll header in a message. + +| Type | Default | +| --------- | ---------------------------------------------------------------- | +| component | | + +### PollOptionSelector + +Custom UI component to override default poll option selector. + +| Type | Default | +| --------- | -------------------------------------------------------------------------------- | +| component | | + ### QuotedMessage Custom UI component to override quoted message UI on a sent message. @@ -325,6 +389,14 @@ Custom UI component to override the message input's quoted message preview. | --------- | -------------------------------------------------------------------------------------------- | | component | | +### QuotedPoll + +Custom UI component to override the rendering of quoted poll. + +| Type | Default | +| --------- | ---------------------------------------------------------------- | +| component | | + ### ReactionSelector Custom UI component to display the reaction selector. diff --git a/docusaurus/docs/React/components/contexts/message-input-context.mdx b/docusaurus/docs/React/components/contexts/message-input-context.mdx index 9756827cc..c57a222e9 100644 --- a/docusaurus/docs/React/components/contexts/message-input-context.mdx +++ b/docusaurus/docs/React/components/contexts/message-input-context.mdx @@ -229,6 +229,14 @@ Function to insert text into the value of the underlying `textarea` component. | ------------------------------ | | (textToInsert: string) => void | +### isThreadInput + +Signals that the MessageInput is rendered in a message thread (Thread component). + +| Type | +| ------- | +| boolean | + ### isUploadEnabled If true, file uploads are enabled in the currently active channel. diff --git a/docusaurus/docs/React/components/core-components/channel.mdx b/docusaurus/docs/React/components/core-components/channel.mdx index ad0cc3b35..a8984f272 100644 --- a/docusaurus/docs/React/components/core-components/channel.mdx +++ b/docusaurus/docs/React/components/core-components/channel.mdx @@ -142,6 +142,30 @@ Custom UI component to display an attachment previews in `MessageInput`. | --------- | ---------------------------------------------------------------------------------------------- | | component | | +### AttachmentSelector + +Custom UI component to control adding attachments to MessageInput, defaults to and accepts same props as: + +| Type | Default | +| --------- | ---------------------------------------------------------------------------------------- | +| component | | + +### AttachmentSelectorInitiationButtonContents + +Custom UI component for contents of attachment selector initiation button. + +| Type | +| --------- | +| component | + +### AudioRecorder + +Custom UI component to display AudioRecorder in `MessageInput`. + +| Type | Default | +| --------- | ------------------------------------------------------------------------------ | +| component | | + ### AutocompleteSuggestionItem Custom UI component to override the default suggestion Item component. @@ -609,6 +633,46 @@ Custom UI component to override default pinned message indicator. | --------- | ---------------------------------------------------------------- | | component | | +### PollActions + +Custom UI component to override default poll actions rendering in a message. + +| Type | Default | +| --------- | ------------------------------------------------------------------------------ | +| component | | + +### PollContent + +Custom UI component to override default poll rendering in a message. + +| Type | Default | +| --------- | ------------------------------------------------------------------ | +| component | | + +### PollCreationDialog + +Custom UI component to override default poll creation dialog contents. + +| Type | Default | +| --------- | --------------------------------------------------------------------------------------------------- | +| component | | + +### PollHeader + +Custom UI component to override default poll header in a message. + +| Type | Default | +| --------- | ---------------------------------------------------------------- | +| component | | + +### PollOptionSelector + +Custom UI component to override default poll option selector. + +| Type | Default | +| --------- | -------------------------------------------------------------------------------- | +| component | | + ### QuotedMessage Custom UI component to override quoted message UI on a sent message. @@ -625,6 +689,14 @@ Custom UI component to override the message input's quoted message preview. | --------- | -------------------------------------------------------------------------------------------- | | component | | +### QuotedPoll + +Custom UI component to override the rendering of quoted poll. + +| Type | Default | +| --------- | ---------------------------------------------------------------- | +| component | | + ### ReactionSelector Custom UI component to display the reaction selector. diff --git a/docusaurus/docs/React/components/message-components/poll.mdx b/docusaurus/docs/React/components/message-components/poll.mdx new file mode 100644 index 000000000..644589eaf --- /dev/null +++ b/docusaurus/docs/React/components/message-components/poll.mdx @@ -0,0 +1,217 @@ +--- +id: poll +title: Poll +--- + +Messages can contain polls. Polls are by default created using `PollCreationDialog` that is invoked from [`AttachmentSelector`](../../message-input-components/attachment-selector). Messages that render polls are not editable. Polls can be only closed by the poll creator. The top-level component to render the message poll data is `Poll` and it renders a header followed by option list and poll actions section. + +![](../../assets/message-with-poll.png) + +## Poll UI customization + +The following part of the poll UI can be customized: + +- `QuotedPoll` - UI rendered if the poll is rendered in a quoted message +- `PollContent` - component that renders the whole non-quoted poll UI +- `PollHeader` - customizes the topmost part of the poll UI +- `PollOptionSelector` - customizes the individual clickable option selectors +- `PollActions` - customizes the bottom part of the poll UI that consists of buttons that invoke action dialogs + +### Poll header customization + +```tsx +import { ReactNode } from 'react'; +import { Channel } from 'stream-chat-react'; + +const PollHeader = () =>
Custom Header
; + +const ChannelWrapper = ({ children }: { children: ReactNode }) => ( + {children} +); +``` + +### Poll option selector customization + +If we wanted to customize only the option selector we can do it with custom `PollOptionSelector` component. + +```tsx +import { ReactNode } from 'react'; +import { Channel } from 'stream-chat-react'; + +const PollOptionSelector = () =>
Custom Option Selector
; + +const ChannelWrapper = ({ children }: { children: ReactNode }) => ( + {children} +); +``` + +### Poll actions customization + +The component `PollActions` controls the display of dialogs or modals that allow user to further interact with the poll data. There are the following poll actions supported by the component that invoke corresponding dialogs resp. modals: + +| Action button | Visible condition | Invokes | +| ------------------------- | ----------------------------------------------------------------------------------------- | ----------------------- | +| See all options | option count > 10 | `PollOptionsFullList` | +| Suggest an option | poll is not closed and `poll.allow_user_suggested_options === true` | `SuggestPollOptionForm` | +| Add or update own comment | poll is not closed and `poll.allow_answers === true` | `AddCommentForm` | +| View comments | `channel.own_capabilities` array contains `'query-poll-votes'` & `poll.answers_count > 0` | `PollAnswerList` | +| View results | always visible | `PollResults` | +| End vote | owner of the poll | `EndPollDialog` | + +**Default PollOptionsFullList** + +![](../../assets/poll-option-full-list.png) + +**Default SuggestPollOptionForm** + +![](../../assets/suggest-poll-option-form.png) + +**Default AddCommentForm** + +![](../../assets/add-poll-comment-form.png) + +**Default PollAnswerList** + +![](../../assets/poll-comment-list.png) + +**Default PollResults** + +![](../../assets/poll-results.png) + +**Default EndPollDialog** + +![](../../assets/end-poll-dialog.png) + +Individual dialogs and thus the whole `PollActions` component can be overridden via `PollActions` component props as follows: + +```tsx +import { ReactNode } from 'react'; +import { Channel, PollActions } from 'stream-chat-react'; +import { + CustomAddCommentForm, + CustomEndPollDialog, + CustomPollAnswerList, + CustomPollOptionsFullList, + CustomPollResults, + CustomSuggestPollOptionForm, +} from './PollActions'; + +const CustomPollActions = () => ( + +); + +const ChannelWrapper = ({ children }: { children: ReactNode }) => ( + {children} +); +``` + +### Poll contents layout customization + +This approach is useful when we want to change the organization of the poll UI. For the purpose we can provide custom `PollContent` component to `Channel`. + +```tsx +import { ReactNode } from 'react'; +import { Channel } from 'stream-chat-react'; +import { CustomPollHeader, CustomPollOptionList } from './Poll'; + +const PollContents = () => ( +
+ + +
+); + +const ChannelWrapper = ({ children }: { children: ReactNode }) => ( + {children} +); +``` + +## Poll API and state + +In order to be fully capable to customize the poll UI, we need to learn how to utilize Poll API and later access the reactive poll state. + +First of all, the Poll API is exposed via a `Poll` instance. This instance is provided via React context to all the children of the `Poll` component that is rendered internally by `Message` component. The context can be consumed using `usePollContext` hook: + +```tsx +import { usePollContext } from 'stream-chat-react'; + +const Component = () => { + const { poll } = usePollContext(); +}; +``` + +The `Poll` instance exposes the following methods: + +- `query` - queries the data for a given poll (permission to query polls is required) +- `update` - overwrites the poll data +- `partialUpdate` - overwrites only the given poll data +- `close` - marks the poll as closed (useful for custom `EndPollDialog`) +- `delete` - deletes the poll +- `createOption` - creates a new option for given poll (useful for custom `SuggestPollOptionForm`) +- `updateOption` - updates an option +- `deleteOption` - removes the option from a poll +- `castVote` - casts a vote to a given option (useful for custom `PollOptionSelector`) +- `removeVote` - removes a vote from a given option (useful for custom `PollOptionSelector`) +- `addAnswer` - adds an answer (comment) +- `removeAnswer` - removes an answer (comment) +- `queryAnswers` - queries and paginates answers (useful for custom `PollAnswerList`) +- `queryOptionVotes` - queries and paginates votes for a given option (useful for option detail) + +The poll state can be accessed inside the custom React components using the following pattern + +```tsx +import { usePollContext, useStateStore } from 'stream-chat-react'; + +import type { PollState, PollVote } from 'stream-chat'; +import type { StreamChatGenerics } from './types'; + +type PollStateSelectorReturnValue = { + latest_votes_by_option: Record[]>; +}; + +// 1. Define the selector function that receives the fresh value every time the observed property changes +const pollStateSelector = ( + nextValue: PollState, +): PollStateSelectorReturnValue => ({ latest_votes_by_option: nextValue.latest_votes_by_option }); + +const CustomComponent = () => { + // 2. Retrieve the poll instance from the context + const { poll } = usePollContext(); + // 3. Use the useStateStore hook to subscribe to updates in the poll state with selector picking out only properties we are interested in + const { latest_votes_by_option } = useStateStore(poll.state, pollStateSelector); +}; +``` + +:::warning +Do not try to access the `poll` data via `message` object available from `MessageContext`. This data is not updated and serve only as a seed, for the `poll` state. +::: + +## PollContext + +The context is available to all the children of the `Poll` component. Currently, it exposes the following properties: + +### poll + +The instance of a `Poll` class provided by the low-level client. The instance is retrieved from `PollManager` via `client.polls.fromState(pollId)` + +```jsx +import { Poll, useChatContext, useMessageContext } from 'stream-chat-react'; + +const Component = () => { + const { client } = useChatContext(); + const { message } = useMessageContext(); + const poll = message.poll_id && client.polls.fromState(message.poll_id); + + if (!poll) return null; + return ; +}; +``` + +This extraction is done internally by the `MessageSimple` component. diff --git a/docusaurus/docs/React/components/message-input-components/attachment-selector.mdx b/docusaurus/docs/React/components/message-input-components/attachment-selector.mdx new file mode 100644 index 000000000..975d39100 --- /dev/null +++ b/docusaurus/docs/React/components/message-input-components/attachment-selector.mdx @@ -0,0 +1,132 @@ +--- +id: attachment-selector +title: Attachment Selector +--- + +Messages can be enriched with attachments or polls by default. The `AttachmentSelector` component is a component that allows to select what information is to be attached to a message. The attachment objects are included in `message.attachments` property and represent various file uploads. The poll representation is available via `message.poll` property. + +## Enabling the default attachment selector features + +The configuration is possible via Stream dashboard. File uploads and poll creation can be controlled via + +1. role permissions + +![](../../assets/dashboard-roles-permissions-ui.png) + +2. channel type configuration + +![](../../assets/dashboard-channel-type-feature-configuration-ui.png) + +## File uploads + +Uploads are possible only if both Upload Attachment permission is granted to the user role and channel type Uploads configuration is enabled. + +## Poll creation + +:::note +Polls feature is available in the React SDK as of version 12.5.0 +::: + +Poll creation is enabled only if both Create Poll permission is granted to the user role and channel type Polls configuration is enabled. Poll creation is not possible withing threads. + +### Poll creation UI + +The component in charge of rendering [the poll creation UI is `PollCreationDialog`](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Poll/PollCreationDialog/PollCreationDialog.tsx). The component is rendered in a modal and therefore accepts a prop `close`. + +![](../../assets/poll-creation-dialog.png) + +Custom `PollCreationDialog` can be provided via `Channel` prop `PollCreationDialog`: + +```tsx +import { ReactNode } from 'react'; +import { Channel } from 'stream-chat-react'; +import type { PollCreationDialogProps } from 'stream-chat-react'; + +const CustomPollCreationDialog = ({ close }: PollCreationDialogProps) => ( +
Custom Poll Creation Dialog
+); + +const ChannelWrapper = ({ children }: { children: ReactNode }) => ( + {children} +); +``` + +Created poll is then rendered within a message list by [`Poll` component](../../message-components/poll). + +## Attachment selector customization + +### Custom attachment selector actions + +Items in the `AttachementSelector` menu can be customized via its `attachmentSelectorActionSet` prop: + +```tsx +import { ReactNode } from 'react'; +import { AttachmentSelector, Channel, defaultAttachmentSelectorActionSet } from 'stream-chat-react'; +import type { + AttachmentSelectorAction, + AttachmentSelectorActionProps, + AttachmentSelectorModalContentProps, +} from 'stream-chat-react'; + +// Define the menu button +const AddLocationAttachmentAction = ({ + closeMenu, + openModalForAction, +}: AttachmentSelectorActionProps) => ( + +); + +// Define the modal contents to be rendered if AddLocationAttachmentAction button is clicked +const AddLocationModalContent = ({ close }: AttachmentSelectorModalContentProps) => { + return
abc
; +}; + +// the custom action will be at the top of the menu +const attachmentSelectorActionSet: AttachmentSelectorAction[] = [ + { + ActionButton: AddLocationAttachmentAction, + ModalContent: AddLocationModalContent, + type: 'addLocation', + }, + ...defaultAttachmentSelectorActionSet, +]; + +const CustomAttachmentSelector = () => ( + +); + +const ChannelWrapper = ({ children }: { children: ReactNode }) => ( + {children} +); +``` + +### Custom modal portal destination + +By default, the modals invoked by clicking on AttachmentSelector menu buttons are anchored to the channel container `div` element. The destination element can be changed by providing `getModalPortalDestination` prop to `AttachmentSelector`. This would be function that would return a reference to the target element that would serve as a parent for the modal. + +```tsx +const getModalPortalDestination = () => document.querySelector('#my-element-id'); + +const CustomAttachmentSelector = () => ( + +); +``` + +## AttachmentSelector context + +Components rendered as children of `AttachmentSelector` can access `AttachmentSelectorContext`. The context exposes the following properties: + +### fileInput + +Reference to `input` element of type `file` used to select files to upload. The reference is `null` if the user does not have a permission to upload files. + +| Type | Default | +| ------------------ | ------- | +| `HTMLInputElement` | `null` | diff --git a/docusaurus/docs/React/components/message-input-components/message-input.mdx b/docusaurus/docs/React/components/message-input-components/message-input.mdx index 38373d48d..f918a7d92 100644 --- a/docusaurus/docs/React/components/message-input-components/message-input.mdx +++ b/docusaurus/docs/React/components/message-input-components/message-input.mdx @@ -131,6 +131,14 @@ Custom UI component handling how the message input is rendered. | --------- | ------------------------------------------------------------------------------------------------------------------------------- | | component | [MessageInputFlat](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageInput/MessageInputFlat.tsx) | +### isThreadInput + +Signals that the MessageInput is rendered in a message thread (Thread component). + +| Type | +| ------- | +| boolean | + ### maxRows Max number of rows the underlying `textarea` component is allowed to grow. diff --git a/docusaurus/docs/React/guides/dialog-management.mdx b/docusaurus/docs/React/guides/dialog-management.mdx index f2c500115..9055b79af 100644 --- a/docusaurus/docs/React/guides/dialog-management.mdx +++ b/docusaurus/docs/React/guides/dialog-management.mdx @@ -65,7 +65,7 @@ const Container = () => { - diff --git a/docusaurus/sidebars-react.json b/docusaurus/sidebars-react.json index 87c847d2f..3563799b1 100644 --- a/docusaurus/sidebars-react.json +++ b/docusaurus/sidebars-react.json @@ -59,6 +59,7 @@ "components/message-components/ui-components", "components/utility-components/avatar", "components/utility-components/base-image", + "components/message-components/poll", { "Attachment": [ "components/message-components/attachment", @@ -78,6 +79,7 @@ "components/message-input-components/ui_components", "components/message-input-components/emoji-picker", "components/message-input-components/audio_recorder", + "components/message-input-components/attachment-selector", "components/contexts/typing_context" ] }, diff --git a/package.json b/package.json index 4b88c74c4..ad76321b4 100644 --- a/package.json +++ b/package.json @@ -145,7 +145,7 @@ "emoji-mart": "^5.4.0", "react": "^18.0.0 || ^17.0.0 || ^16.8.0", "react-dom": "^18.0.0 || ^17.0.0 || ^16.8.0", - "stream-chat": "^8.41.1" + "stream-chat": "^8.42.0" }, "peerDependenciesMeta": { "@breezystack/lamejs": { @@ -186,7 +186,7 @@ "@semantic-release/changelog": "^6.0.2", "@semantic-release/git": "^10.0.1", "@stream-io/rollup-plugin-node-builtins": "^2.1.5", - "@stream-io/stream-chat-css": "^5.0.0", + "@stream-io/stream-chat-css": "^5.2.0", "@testing-library/jest-dom": "^6.1.4", "@testing-library/react": "^13.1.1", "@testing-library/react-hooks": "^8.0.0", @@ -255,7 +255,7 @@ "react-dom": "^18.1.0", "react-test-renderer": "^18.1.0", "semantic-release": "^19.0.5", - "stream-chat": "^8.41.1", + "stream-chat": "^8.42.0", "ts-jest": "^29.1.4", "typescript": "^5.4.5" }, diff --git a/src/components/Attachment/components/ProgressBar.tsx b/src/components/Attachment/components/ProgressBar.tsx index d8eb0d2be..1e5d5fd10 100644 --- a/src/components/Attachment/components/ProgressBar.tsx +++ b/src/components/Attachment/components/ProgressBar.tsx @@ -1,13 +1,14 @@ +import clsx from 'clsx'; import React from 'react'; export type ProgressBarProps = { /** Progress expressed in fractional number value btw 0 and 100. */ progress: number; -} & Pick, 'onClick'>; +} & Pick, 'className' | 'onClick'>; -export const ProgressBar = ({ onClick, progress }: ProgressBarProps) => ( +export const ProgressBar = ({ className, onClick, progress }: ProgressBarProps) => (
, | 'Attachment' | 'AttachmentPreviewList' + | 'AttachmentSelector' + | 'AttachmentSelectorInitiationButtonContents' | 'AudioRecorder' | 'AutocompleteSuggestionItem' | 'AutocompleteSuggestionList' @@ -131,8 +139,14 @@ type ChannelPropsForwardedToComponentContext< | 'MessageTimestamp' | 'ModalGallery' | 'PinIndicator' + | 'PollActions' + | 'PollContent' + | 'PollCreationDialog' + | 'PollHeader' + | 'PollOptionSelector' | 'QuotedMessage' | 'QuotedMessagePreview' + | 'QuotedPoll' | 'reactionOptions' | 'ReactionSelector' | 'ReactionsList' @@ -238,6 +252,25 @@ export type ChannelProps< videoAttachmentSizeHandler?: VideoAttachmentSizeHandler; }; +const ChannelContainer = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +>({ + children, + className: additionalClassName, + ...props +}: PropsWithChildren>) => { + const { customClasses, theme } = useChatContext('Channel'); + const { channelClass, chatClass } = useChannelContainerClasses({ + customClasses, + }); + const className = clsx(chatClass, theme, channelClass, additionalClassName); + return ( +
+ {children} +
+ ); +}; + const UnMemoizedChannel = < StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, V extends CustomTrigger = CustomTrigger @@ -251,38 +284,30 @@ const UnMemoizedChannel = < LoadingIndicator = DefaultLoadingIndicator, } = props; - const { - channel: contextChannel, - channelsQueryState, - customClasses, - theme, - } = useChatContext('Channel'); - const { channelClass, chatClass } = useChannelContainerClasses({ - customClasses, - }); + const { channel: contextChannel, channelsQueryState } = useChatContext( + 'Channel', + ); const channel = propsChannel || contextChannel; - const className = clsx(chatClass, theme, channelClass); - if (channelsQueryState.queryInProgress === 'reload' && LoadingIndicator) { return ( -
+ -
+ ); } if (channelsQueryState.error && LoadingErrorIndicator) { return ( -
+ -
+ ); } if (!channel?.cid) { - return
{EmptyPlaceholder}
; + return {EmptyPlaceholder}; } return ; @@ -338,16 +363,10 @@ const ChannelInner = < customClasses, latestMessageDatesByChannels, mutes, - theme, } = useChatContext('Channel'); const { t } = useTranslationContext('Channel'); - const { - channelClass, - chatClass, - chatContainerClass, - windowsEmojiClass, - } = useChannelContainerClasses({ customClasses }); - + const chatContainerClass = getChatContainerClass(customClasses?.chatContainer); + const windowsEmojiClass = useImageFlagEmojisOnWindowsClass(); const thread = useThreadContext(); const [channelConfig, setChannelConfig] = useState(channel.getConfig()); @@ -1208,6 +1227,8 @@ const ChannelInner = < () => ({ Attachment: props.Attachment, AttachmentPreviewList: props.AttachmentPreviewList, + AttachmentSelector: props.AttachmentSelector, + AttachmentSelectorInitiationButtonContents: props.AttachmentSelectorInitiationButtonContents, AudioRecorder: props.AudioRecorder, AutocompleteSuggestionItem: props.AutocompleteSuggestionItem, AutocompleteSuggestionList: props.AutocompleteSuggestionList, @@ -1239,8 +1260,14 @@ const ChannelInner = < MessageTimestamp: props.MessageTimestamp, ModalGallery: props.ModalGallery, PinIndicator: props.PinIndicator, + PollActions: props.PollActions, + PollContent: props.PollContent, + PollCreationDialog: props.PollCreationDialog, + PollHeader: props.PollHeader, + PollOptionSelector: props.PollOptionSelector, QuotedMessage: props.QuotedMessage, QuotedMessagePreview: props.QuotedMessagePreview, + QuotedPoll: props.QuotedPoll, reactionOptions: props.reactionOptions, ReactionSelector: props.ReactionSelector, ReactionsList: props.ReactionsList, @@ -1259,6 +1286,8 @@ const ChannelInner = < [ props.Attachment, props.AttachmentPreviewList, + props.AttachmentSelector, + props.AttachmentSelectorInitiationButtonContents, props.AudioRecorder, props.AutocompleteSuggestionItem, props.AutocompleteSuggestionList, @@ -1289,8 +1318,14 @@ const ChannelInner = < props.MessageTimestamp, props.ModalGallery, props.PinIndicator, + props.PollActions, + props.PollContent, + props.PollCreationDialog, + props.PollHeader, + props.PollOptionSelector, props.QuotedMessage, props.QuotedMessagePreview, + props.QuotedPoll, props.ReactionSelector, props.ReactionsList, props.SendButton, @@ -1313,34 +1348,32 @@ const ChannelInner = < typing, }); - const className = clsx(chatClass, theme, channelClass); - if (state.error) { return ( -
+ -
+ ); } if (state.loading) { return ( -
+ -
+ ); } if (!channel.watch) { return ( -
+
{t('Channel Missing')}
-
+ ); } return ( -
+ @@ -1355,7 +1388,7 @@ const ChannelInner = < -
+ ); }; diff --git a/src/components/Channel/__tests__/__snapshots__/Channel.test.js.snap b/src/components/Channel/__tests__/__snapshots__/Channel.test.js.snap index 328c0e14f..e8de2adff 100644 --- a/src/components/Channel/__tests__/__snapshots__/Channel.test.js.snap +++ b/src/components/Channel/__tests__/__snapshots__/Channel.test.js.snap @@ -4,6 +4,7 @@ exports[`Channel should render a LoadingIndicator if it is loading 1`] = `
`; @@ -244,6 +247,7 @@ exports[`Channel should render empty channel container if channels query failed
`; diff --git a/src/components/Channel/constants.ts b/src/components/Channel/constants.ts new file mode 100644 index 000000000..925b246a3 --- /dev/null +++ b/src/components/Channel/constants.ts @@ -0,0 +1 @@ +export const CHANNEL_CONTAINER_ID = 'str-chat__channel'; diff --git a/src/components/Channel/hooks/useChannelContainerClasses.ts b/src/components/Channel/hooks/useChannelContainerClasses.ts index a901a903d..f3d847735 100644 --- a/src/components/Channel/hooks/useChannelContainerClasses.ts +++ b/src/components/Channel/hooks/useChannelContainerClasses.ts @@ -3,20 +3,27 @@ import { useChatContext } from '../../../context/ChatContext'; import type { DefaultStreamChatGenerics } from '../../../types/types'; +export const useImageFlagEmojisOnWindowsClass = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +>() => { + const { useImageFlagEmojisOnWindows } = useChatContext('Channel'); + return useImageFlagEmojisOnWindows && navigator.userAgent.match(/Win/) + ? 'str-chat--windows-flags' + : ''; +}; + +export const getChatContainerClass = (customClass?: string) => customClass ?? 'str-chat__container'; + export const useChannelContainerClasses = < StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics >({ customClasses, }: Pick) => { - const { useImageFlagEmojisOnWindows } = useChatContext('Channel'); - + const windowsEmojiClass = useImageFlagEmojisOnWindowsClass(); return { channelClass: customClasses?.channel ?? 'str-chat__channel', chatClass: customClasses?.chat ?? 'str-chat', - chatContainerClass: customClasses?.chatContainer ?? 'str-chat__container', - windowsEmojiClass: - useImageFlagEmojisOnWindows && navigator.userAgent.match(/Win/) - ? 'str-chat--windows-flags' - : '', + chatContainerClass: getChatContainerClass(customClasses?.chatContainer), + windowsEmojiClass, }; }; diff --git a/src/components/ChannelPreview/utils.tsx b/src/components/ChannelPreview/utils.tsx index 1aaa77672..527f4e62e 100644 --- a/src/components/ChannelPreview/utils.tsx +++ b/src/components/ChannelPreview/utils.tsx @@ -2,7 +2,7 @@ import React from 'react'; import ReactMarkdown from 'react-markdown'; -import type { Channel, TranslationLanguages, UserResponse } from 'stream-chat'; +import type { Channel, PollVote, TranslationLanguages, UserResponse } from 'stream-chat'; import type { TranslationContextValue } from '../../context/TranslationContext'; @@ -10,6 +10,22 @@ import type { DefaultStreamChatGenerics } from '../../types/types'; export const renderPreviewText = (text: string) => {text}; +const getLatestPollVote = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +>( + latestVotesByOption: Record[]>, +) => { + let latestVote: PollVote | undefined; + for (const optionVotes of Object.values(latestVotesByOption)) { + optionVotes.forEach((vote) => { + if (latestVote && new Date(latestVote.updated_at) >= new Date(vote.created_at)) return; + latestVote = vote; + }); + } + + return latestVote; +}; + export const getLatestMessagePreview = < StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics >( @@ -22,6 +38,7 @@ export const getLatestMessagePreview = < const previewTextToRender = latestMessage?.i18n?.[`${userLanguage}_text` as `${TranslationLanguages}_text`] || latestMessage?.text; + const poll = latestMessage?.poll; if (!latestMessage) { return t('Nothing yet...'); @@ -31,6 +48,34 @@ export const getLatestMessagePreview = < return t('Message deleted'); } + if (poll) { + if (!poll.vote_count) { + const createdBy = + poll.created_by?.id === channel.getClient().userID + ? t('You') + : poll.created_by?.name ?? t('Poll'); + return t('📊 {{createdBy}} created: {{ pollName}}', { + createdBy, + pollName: poll.name, + }); + } else { + const latestVote = getLatestPollVote( + poll.latest_votes_by_option as Record[]>, + ); + const option = latestVote && poll.options.find((opt) => opt.id === latestVote.option_id); + + if (option && latestVote) { + return t('📊 {{votedBy}} voted: {{pollOptionText}}', { + pollOptionText: option.text, + votedBy: + latestVote?.user?.id === channel.getClient().userID + ? t('You') + : latestVote.user?.name ?? t('Poll'), + }); + } + } + } + if (previewTextToRender) { return renderPreviewText(previewTextToRender); } diff --git a/src/components/Chat/hooks/useChat.ts b/src/components/Chat/hooks/useChat.ts index 157b38bee..408d2c944 100644 --- a/src/components/Chat/hooks/useChat.ts +++ b/src/components/Chat/hooks/useChat.ts @@ -66,9 +66,11 @@ export const useChat = < } client.threads.registerSubscriptions(); + client.polls.registerSubscriptions(); return () => { client.threads.unregisterSubscriptions(); + client.polls.unregisterSubscriptions(); }; }, [client]); diff --git a/src/components/Dialog/DialogAnchor.tsx b/src/components/Dialog/DialogAnchor.tsx index 5e411ba0f..fd42389f5 100644 --- a/src/components/Dialog/DialogAnchor.tsx +++ b/src/components/Dialog/DialogAnchor.tsx @@ -53,7 +53,7 @@ export function useDialogAnchor({ }; } -type DialogAnchorProps = PropsWithChildren> & { +export type DialogAnchorProps = PropsWithChildren> & { id: string; focus?: boolean; trapFocus?: boolean; diff --git a/src/components/Dialog/DialogMenu.tsx b/src/components/Dialog/DialogMenu.tsx new file mode 100644 index 000000000..9fe04f998 --- /dev/null +++ b/src/components/Dialog/DialogMenu.tsx @@ -0,0 +1,11 @@ +import React, { ComponentProps } from 'react'; +import clsx from 'clsx'; + +export type DialogMenuButtonProps = ComponentProps<'button'>; + +export const DialogMenuButton = ({ children, className, ...props }: DialogMenuButtonProps) => ( + +); diff --git a/src/components/Dialog/DialogPortal.tsx b/src/components/Dialog/DialogPortal.tsx index e9bb63de7..aa814abac 100644 --- a/src/components/Dialog/DialogPortal.tsx +++ b/src/components/Dialog/DialogPortal.tsx @@ -1,6 +1,6 @@ -import React, { PropsWithChildren, useLayoutEffect, useState } from 'react'; -import { createPortal } from 'react-dom'; +import React, { PropsWithChildren, useCallback } from 'react'; import { useDialogIsOpen, useOpenedDialogCount } from './hooks'; +import { Portal } from '../Portal/Portal'; import { useDialogManager } from '../../context'; export const DialogPortalDestination = () => { @@ -32,16 +32,15 @@ export const DialogPortalEntry = ({ }: PropsWithChildren) => { const { dialogManager } = useDialogManager(); const dialogIsOpen = useDialogIsOpen(dialogId); - const [portalDestination, setPortalDestination] = useState(null); - useLayoutEffect(() => { - const destination = document.querySelector( - `div[data-str-chat__portal-id="${dialogManager.id}"]`, - ); - if (!destination) return; - setPortalDestination(destination); - }, [dialogManager, dialogIsOpen]); - if (!portalDestination) return null; + const getPortalDestination = useCallback( + () => document.querySelector(`div[data-str-chat__portal-id="${dialogManager.id}"]`), + [dialogManager.id], + ); - return createPortal(children, portalDestination); + return ( + + {children} + + ); }; diff --git a/src/components/Dialog/FormDialog.tsx b/src/components/Dialog/FormDialog.tsx new file mode 100644 index 000000000..d9eacd9d3 --- /dev/null +++ b/src/components/Dialog/FormDialog.tsx @@ -0,0 +1,151 @@ +import React, { + ChangeEvent, + ChangeEventHandler, + ComponentProps, + useCallback, + useState, +} from 'react'; +import clsx from 'clsx'; +import { FieldError } from '../Form/FieldError'; +import { useTranslationContext } from '../../context'; + +type FormElements = 'input' | 'textarea'; +type FieldId = string; +type Validator = ( + value: string | readonly string[] | number | boolean | undefined, +) => Error | undefined; + +export type FieldConfig = { + element: FormElements; + props: ComponentProps; + label?: React.ReactNode; + validator?: Validator; +}; + +type TextInputFormProps>> = { + close: () => void; + fields: Record; + onSubmit: (formValue: F) => Promise; + className?: string; + shouldDisableSubmitButton?: (formValue: F) => boolean; + title?: string; +}; + +type FormValue> = { + [K in keyof F]: F[K]['props']['value']; +}; + +export const FormDialog = < + F extends FormValue> = FormValue> +>({ + className, + close, + fields, + onSubmit, + shouldDisableSubmitButton, + title, +}: TextInputFormProps) => { + const { t } = useTranslationContext(); + const [fieldErrors, setFieldErrors] = useState>({}); + const [value, setValue] = useState(() => { + let acc: Partial = {}; + for (const [id, config] of Object.entries(fields)) { + acc = { ...acc, [id]: config.props.value }; + } + return acc as F; + }); + + const handleChange = useCallback>( + (event) => { + const fieldId = event.target.id; + const fieldConfig = fields[fieldId]; + if (!fieldConfig) return; + + const error = fieldConfig.validator?.(event.target.value); + if (error) { + setFieldErrors((prev) => ({ [fieldId]: error, ...prev })); + } else { + setFieldErrors((prev) => { + delete prev[fieldId]; + return prev; + }); + } + setValue((prev) => ({ ...prev, [fieldId]: event.target.value })); + + if (!fieldConfig.props.onChange) return; + + if (fieldConfig.element === 'input') { + (fieldConfig.props.onChange as ChangeEventHandler)( + event as ChangeEvent, + ); + } else if (fieldConfig.element === 'textarea') { + (fieldConfig.props.onChange as ChangeEventHandler)( + event as ChangeEvent, + ); + } + }, + [fields], + ); + + const handleSubmit = async () => { + if (!Object.keys(value).length) return; + const errors: Record = {}; + for (const [id, fieldValue] of Object.entries(value)) { + const thisFieldError = fields[id].validator?.(fieldValue); + if (thisFieldError) { + errors[id] = thisFieldError; + } + } + if (Object.keys(errors).length) { + setFieldErrors(errors); + return; + } + await onSubmit(value); + close(); + }; + + return ( +
+
+ {title &&
{title}
} +
+ {Object.entries(fields).map(([id, fieldConfig]) => ( +
+ {fieldConfig.label && ( + + )} + {React.createElement(fieldConfig.element, { + id, + ...fieldConfig.props, + onChange: handleChange, + value: value[id], + })} + +
+ ))} +
+
+
+ + +
+
+ ); +}; diff --git a/src/components/Dialog/PromptDialog.tsx b/src/components/Dialog/PromptDialog.tsx new file mode 100644 index 000000000..c916fb5a8 --- /dev/null +++ b/src/components/Dialog/PromptDialog.tsx @@ -0,0 +1,27 @@ +import React, { ComponentProps } from 'react'; +import clsx from 'clsx'; + +export type ConfirmationDialogProps = { + actions: ComponentProps<'button'>[]; + prompt: string; + className?: string; + title?: string; +}; + +export const PromptDialog = ({ actions, className, prompt, title }: ConfirmationDialogProps) => ( +
+
+ {title &&
{title}
} +
{prompt}
+
+
+ {actions.map(({ className, ...props }, i) => ( +
+
+); diff --git a/src/components/DragAndDrop/DragAndDropContainer.tsx b/src/components/DragAndDrop/DragAndDropContainer.tsx new file mode 100644 index 000000000..fda1cbd90 --- /dev/null +++ b/src/components/DragAndDrop/DragAndDropContainer.tsx @@ -0,0 +1,138 @@ +import React, { PropsWithChildren, useEffect, useState } from 'react'; +import clsx from 'clsx'; + +export type DragAndDropContainerProps = PropsWithChildren<{ + className?: string; + draggable?: boolean; + onSetNewOrder?: (newOrder: number[]) => void; +}>; + +export const DragAndDropContainer = ({ + children, + className, + draggable, + onSetNewOrder, +}: DragAndDropContainerProps) => { + const [order, setOrder] = useState([]); + const [dragStartIndex, setDragStartIndex] = useState(null); + const [dragOverIndex, setDragOverIndex] = useState(null); + const [container, setContainer] = useState(null); + + const moveDirection = + dragStartIndex === null || dragOverIndex === null + ? undefined + : dragStartIndex <= dragOverIndex + ? 'down' + : 'up'; + + const childrenArray = React.Children.toArray(children); + + useEffect(() => { + setOrder(React.Children.map(children, (_, index) => index) || []); + }, [children]); + + useEffect(() => { + if (!container) return; + + const handleDragStart = (e: DragEvent) => { + const target = e.target as HTMLElement; + const draggableItem = target.closest('.str-chat__drag-and-drop-container__item'); + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move'; + } + + if (draggableItem instanceof HTMLElement) { + const index = Array.from(draggableItem.parentElement?.children || []).indexOf( + draggableItem, + ); + setDragStartIndex(index); + e.dataTransfer?.setData('text/plain', index.toString()); + draggableItem.style.opacity = '0.3'; + } + }; + + const handleDragOver = (e: DragEvent) => { + e.preventDefault(); + const target = e.target as HTMLElement; + const draggableItem = target.closest('.str-chat__drag-and-drop-container__item'); + if (draggableItem instanceof HTMLElement) { + const index = Array.from(draggableItem.parentElement?.children || []).indexOf( + draggableItem, + ); + setDragOverIndex(index); + } + }; + + const handleDragLeave = () => { + setDragOverIndex(null); + }; + + const handleDrop = (e: DragEvent) => { + e.preventDefault(); + const draggedIndex = parseInt(e.dataTransfer?.getData('text/plain') || '-1', 10); + const target = e.target as HTMLElement; + const draggableItem = target.closest('.str-chat__drag-and-drop-container__item'); + if (draggableItem instanceof HTMLElement) { + const dropIndex = Array.from(draggableItem.parentElement?.children || []).indexOf( + draggableItem, + ); + if (draggedIndex !== -1 && draggedIndex !== dropIndex) { + setOrder((prevOrder) => { + const newOrder = [...prevOrder]; + const [removed] = newOrder.splice(draggedIndex, 1); + newOrder.splice(dropIndex, 0, removed); + onSetNewOrder?.(newOrder); + return newOrder; + }); + } + } + setDragStartIndex(null); + setDragOverIndex(null); + }; + + const handleDragEnd = (e: DragEvent) => { + const target = e.target as HTMLElement; + if (target instanceof HTMLElement) { + target.style.opacity = ''; + } + setDragStartIndex(null); + setDragOverIndex(null); + }; + + container.addEventListener('dragstart', handleDragStart); + container.addEventListener('dragover', handleDragOver); + container.addEventListener('dragleave', handleDragLeave); + container.addEventListener('drop', handleDrop); + container.addEventListener('dragend', handleDragEnd); + + return () => { + container.removeEventListener('dragstart', handleDragStart); + container.removeEventListener('dragover', handleDragOver); + container.removeEventListener('dragleave', handleDragLeave); + container.removeEventListener('drop', handleDrop); + container.removeEventListener('dragend', handleDragEnd); + }; + }, [container, onSetNewOrder]); + + return ( +
+ {order.map((originalIndex, currentIndex) => { + const child = childrenArray[originalIndex]; + return ( +
+ {child} +
+ ); + })} +
+ ); +}; diff --git a/src/components/Form/FieldError.tsx b/src/components/Form/FieldError.tsx new file mode 100644 index 000000000..147452426 --- /dev/null +++ b/src/components/Form/FieldError.tsx @@ -0,0 +1,11 @@ +import clsx from 'clsx'; +import React, { ComponentProps } from 'react'; + +type FieldErrorProps = ComponentProps<'div'> & { + text?: string; +}; +export const FieldError = ({ className, text, ...props }: FieldErrorProps) => ( +
+ {text} +
+); diff --git a/src/components/Form/SwitchField.tsx b/src/components/Form/SwitchField.tsx new file mode 100644 index 000000000..3b6a6067a --- /dev/null +++ b/src/components/Form/SwitchField.tsx @@ -0,0 +1,47 @@ +import clsx from 'clsx'; +import React, { + ComponentProps, + ElementRef, + KeyboardEventHandler, + PropsWithChildren, + useRef, +} from 'react'; + +export type SwitchFieldProps = PropsWithChildren>; + +export const SwitchField = ({ children, ...props }: SwitchFieldProps) => { + const inputRef = useRef>(null); + const handleKeyUp: KeyboardEventHandler = (event) => { + if (![' ', 'Enter'].includes(event.key) || !inputRef.current) return; + event.preventDefault(); + inputRef.current.click(); + }; + + return ( +
+