Skip to content

Commit

Permalink
feat(blade): chat message component (#2513)
Browse files Browse the repository at this point in the history
* feat/chat-bubble-api-decision

* chore: api docs

* chore: update is user message

* fix: typo

* feat: updated decisions

* chore: more changes

* chore: update decision and chatBubble

* feat: done with right message

* feat: response message

* feat: api changes

* chore: add motion

* feat: add animation and update some props

* chore: code refactor

* chore: updated docs for chatbubble

* chore: update width

* chore: more refactor

* fix: getStringFromReactText from utils

* feat: add style props

* feat: add message

* chore: added more example

* feat: add types

* feat: added ref

* feat: update types and example

* chore: more ts and other changes

* chore: update decision.md

* chore: update ray icon

* chore: update snops

* chore: add more tests

* feat: add sort and prompt icon [p0] (#2511)

* feat: add sort and cherry pick icons

* chore: add changeset

* chore: change sort

* fix: more lint changes

* chore: few changes

* chore: sort icon

* chore: remove path

* chore: update changeset

* build(blade): update version (#2512)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fix(Avatar): add missing ref on default avatar (#2516)

* fix(Avatar): add missing ref on default avatar

* Create nasty-frogs-end.md

* build(blade): update version (#2518)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* feat: add chatbubble

* chore: minior review changes

* chore: more review changes

* chore: typescript gymnastics

* chore: use Meta constans

* chore: remove lazy motion

* chore: added jsDoc comments

* chore: added tokens

* chore: update comments

* chore: remove stringChildType and change export

* chore: fix another error

* chore: added wrapper component

* chore: more changes

* chrore: add footer actions

* chore: update tests and snap

* feat: more tests

* fix: add should add padding

* chore: add typing animation

* chore: file name refactor , new animation

* chore:sender type

* chore: removed max width

* chore: update docs

* chore: chatMessage refactor

* chore: update snaps

* chore: fix ts

* chore: more changes

* feat: chatMessage

* chore: docs update

* chore: update snaps

* chore: update example

* chore: change styledPropsBlade to BaseBoxProps

* fix: types

* chore: added more types

* chore: import type

* chore: change types and footer spacing

* chore: update snaps

* chore: more changes

* chore: reverse rfc change

* chore: update test

* chore: update snap and wrapper

* chore: update web snap

* chore: add make box props

* feat: update snaps

* chore: more changes

* chore: update type and example

---------

Co-authored-by: rzpcibot <[email protected]>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Saurabh Daware <[email protected]>
  • Loading branch information
4 people authored Feb 7, 2025
1 parent 4b6b43a commit d906838
Show file tree
Hide file tree
Showing 18 changed files with 3,325 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .changeset/dirty-otters-jump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@razorpay/blade': minor
---

feat(blade): add Chat Message component
15 changes: 15 additions & 0 deletions packages/blade/src/components/ChatMessage/ChatMessage.native.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import React from 'react';
import type { ChatMessageProps } from './types';
import { Text } from '~components/Typography';
import { throwBladeError } from '~utils/logger';

const ChatMessage = (_prop: ChatMessageProps): React.ReactElement => {
throwBladeError({
message: 'ChatMessage is not yet implemented for native',
moduleName: 'ChatMessage',
});

return <Text>ChatMessage is not available for Native mobile apps.</Text>;
};

export { ChatMessage };
102 changes: 102 additions & 0 deletions packages/blade/src/components/ChatMessage/ChatMessage.web.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import React from 'react';
import { SelfMessageBubble } from './SelfMessageBubble.web';
import { DefaultMessageBubble } from './DefaultMessageBubble.web';
import type { ChatMessageProps } from './types';
import { Text } from '~components/Typography';
import BaseBox from '~components/Box/BaseBox';
import { getStringFromReactText } from '~utils/getStringChildren';
import { assignWithoutSideEffects } from '~utils/assignWithoutSideEffects';
import type { BladeElementRef } from '~utils/types';
import { MetaConstants, metaAttribute } from '~utils/metaAttribute';
import { makeAnalyticsAttribute } from '~utils/makeAnalyticsAttribute';
import { getStyledProps } from '~components/Box/styledProps';

const ButtonResetCss = {
background: 'none',
border: 'none',
padding: undefined,
cursor: 'pointer',
color: 'inherit',
font: 'inherit',
textAlign: 'left' as const,
outline: 'inherit',
appearance: 'none',
backgroundColor: 'inherit',
};

const _ChatMessage: React.ForwardRefRenderFunction<BladeElementRef, ChatMessageProps> = (
{
messageType = 'default',
senderType = 'self',
isLoading = false,
validationState = 'none',
errorText,
onClick,
footerActions,
children,
leading,
loadingText,
wordBreak = 'break-word',
maxWidth,
...props
}: ChatMessageProps,
ref: React.Ref<BladeElementRef>,
): React.ReactElement => {
// since we can pass both string and Card component as children, we need to check if children is string or Card component
// if children is string or array of string, we need to wrap it in Text component otherwise we will pass children as it is
const shouldWrapInText =
typeof children === 'string' ||
(Array.isArray(children) && children.every((child) => typeof child === 'string')) ||
isLoading;

const finalChildren = shouldWrapInText ? (
<Text
color={isLoading ? 'surface.text.gray.muted' : 'surface.text.gray.normal'}
weight="regular"
variant="body"
size="medium"
wordBreak={wordBreak}
>
{isLoading ? loadingText : getStringFromReactText(children as string | string[])}
</Text>
) : (
(children as React.ReactElement)
);

return (
<BaseBox
onClick={onClick}
{...(onClick ? { ...ButtonResetCss } : {})}
{...metaAttribute({ name: MetaConstants.ChatMessage, testID: props.testID })}
{...makeAnalyticsAttribute(props)}
{...getStyledProps(props)}
maxWidth={maxWidth}
ref={ref as never}
as={onClick ? 'button' : undefined}
>
{senderType === 'self' ? (
<SelfMessageBubble
validationState={validationState}
errorText={errorText}
children={finalChildren}
messageType={messageType}
isChildText={shouldWrapInText}
/>
) : (
<DefaultMessageBubble
children={finalChildren}
leading={leading}
isLoading={isLoading}
footerActions={footerActions}
isChildText={shouldWrapInText}
/>
)}
</BaseBox>
);
};

const ChatMessage = assignWithoutSideEffects(React.forwardRef(_ChatMessage), {
displayName: 'ChatMessage',
componentId: MetaConstants.ChatMessage,
});
export { ChatMessage };
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import React from 'react';
import Rotate from './Rotate.web';
import type { CommonChatMessageProps } from './types';
import BaseBox from '~components/Box/BaseBox';

const DefaultMessageBubble = ({
children,
leading,
isLoading,
footerActions,
isChildText,
}: Pick<CommonChatMessageProps, 'children' | 'leading' | 'isLoading' | 'footerActions'> & {
isChildText: boolean;
}): React.ReactElement => {
return (
<BaseBox>
<BaseBox
display="grid"
gridTemplateColumns="auto 1fr"
gridTemplateRows="auto auto"
columnGap="spacing.4"
>
<BaseBox padding="spacing.2">
<Rotate animate={isLoading}>{leading as React.ReactElement}</Rotate>
</BaseBox>

<BaseBox
display="flex"
alignItems="center"
paddingY={isChildText ? 'spacing.2' : 'spacing.0'}
>
{children}
</BaseBox>

<BaseBox gridColumn="2">{footerActions}</BaseBox>
</BaseBox>
</BaseBox>
);
};

export { DefaultMessageBubble };
39 changes: 39 additions & 0 deletions packages/blade/src/components/ChatMessage/Rotate.web.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import React from 'react';
import { m } from 'framer-motion';
import { castWebType } from '~utils';
import { useTheme } from '~components/BladeProvider';
import { msToSeconds } from '~utils/msToSeconds';
import { cssBezierToArray } from '~utils/cssBezierToArray';

const Rotate = ({
children,
animate,
}: {
children: React.ReactElement;
animate?: boolean;
}): React.ReactElement => {
const { theme } = useTheme();

if (!animate) {
return children;
}

return (
<m.div
style={{
display: 'flex',
}}
animate={{ rotate: 90 }}
transition={{
duration: msToSeconds(theme.motion.duration.gentle),
repeat: Infinity,
ease: cssBezierToArray(castWebType(theme.motion.easing.emphasized)),
delay: msToSeconds(theme.motion.delay.gentle),
}}
>
{children}
</m.div>
);
};

export default Rotate;
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import React from 'react';
import type { CommonChatMessageProps } from './types';
import { chatMessageToken } from './token';
import BaseBox from '~components/Box/BaseBox';
import { FormHint } from '~components/Form/FormHint';

const SelfMessageBubble = ({
children,
validationState,
errorText = 'Message not sent. Tap to retry.',
messageType,
isChildText,
}: Pick<CommonChatMessageProps, 'children' | 'validationState' | 'errorText' | 'messageType'> & {
// is child is text then only add padding otherwise no need to add padding
isChildText: boolean;
}): React.ReactElement => {
const isError = validationState === 'error';
return (
<BaseBox display="flex" flexDirection="column">
<BaseBox
backgroundColor={
isError
? chatMessageToken.self.backgroundColor.error
: chatMessageToken.self.backgroundColor.default
}
padding={isChildText ? 'spacing.4' : 'spacing.0'}
borderTopLeftRadius={chatMessageToken.self.borderTopLeftRadius}
borderTopRightRadius={chatMessageToken.self.borderTopRightRadius}
borderBottomLeftRadius={chatMessageToken.self.borderBottomLeftRadius}
borderBottomRightRadius={
messageType === 'last'
? chatMessageToken.self.borderBottomRightRadiusForLastMessage
: chatMessageToken.self.borderBottomRightRadius
}
width="fit-content"
height="auto"
alignSelf="flex-end"
>
{children}
</BaseBox>
<BaseBox alignSelf="flex-end">
{isError && <FormHint type="error" errorText={errorText} size="small" />}
</BaseBox>
</BaseBox>
);
};

export { SelfMessageBubble };
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// test case for ChatMessage component
import { fireEvent } from '@testing-library/react';
import { ChatMessage } from '../ChatMessage';
import renderWithSSR from '~utils/testing/renderWithSSR.web';
import { RayIcon } from '~components/Icons';
import { Card, CardBody } from '~components/Card';
import { Box } from '~components/Box';
import { Text } from '~components/Typography';
import { Radio, RadioGroup } from '~components/Radio';

describe('<ChatMessage/>', () => {
it('should render last message correctly', () => {
const { container } = renderWithSSR(
<ChatMessage senderType="self" messageType="last">
{' '}
This is a demo message{' '}
</ChatMessage>,
);
expect(container).toMatchSnapshot();
});
it('should render last message correctly', () => {
const { container } = renderWithSSR(
<ChatMessage messageType="default" senderType="self">
{' '}
This is another demo message{' '}
</ChatMessage>,
);
expect(container).toMatchSnapshot();
});
it('should render last message correctly', () => {
const { container } = renderWithSSR(
<ChatMessage
senderType="other"
leading={<RayIcon size="xlarge" color="surface.icon.onSea.onSubtle" />}
>
{' '}
This is another demo message{' '}
</ChatMessage>,
);
expect(container).toMatchSnapshot();
});
it('should render last message correctly', () => {
const { container } = renderWithSSR(
<ChatMessage
senderType="other"
leading={<RayIcon size="xlarge" color="surface.icon.onSea.onSubtle" />}
loadingText="Analyzing your response..."
>
<Card>
<CardBody>
<Box display="flex" gap="8px" flexDirection="column">
<Text variant="body" size="medium">
Where do you want to collect payments?
</Text>
<RadioGroup>
<Radio value="website">Website</Radio>
<Radio value="android">Android App</Radio>
<Radio value="ios">iOS App</Radio>
</RadioGroup>
</Box>
</CardBody>
</Card>
</ChatMessage>,
);
expect(container).toMatchSnapshot();
});
it('it should fire onClick event when user clicks on message button', () => {
const onClick = jest.fn();
const { getByText } = renderWithSSR(
<ChatMessage
senderType="self"
messageType="last"
validationState="error"
errorText="Message not sent. Tap to retry."
onClick={onClick}
>
Can you help me with the docs?
</ChatMessage>,
);
const message = getByText('Can you help me with the docs?');
fireEvent.click(message);
expect(onClick).toHaveBeenCalled();
});
it('should render loading message correctly when loadingText is passed as prop and children is Card component', () => {
const { getByText } = renderWithSSR(
<ChatMessage
senderType="other"
leading={<RayIcon size="xlarge" color="surface.icon.onSea.onSubtle" />}
isLoading={true}
loadingText="Analyzing your response..."
>
<Card>
<CardBody>
<Box display="flex" gap="8px" flexDirection="column">
<Text variant="body" size="medium">
Where do you want to collect payments?
</Text>
<RadioGroup>
<Radio value="website">Website</Radio>
<Radio value="android">Android App</Radio>
<Radio value="ios">iOS App</Radio>
</RadioGroup>
</Box>
</CardBody>
</Card>
</ChatMessage>,
);
const loadingTextElement = getByText('Analyzing your response...');
expect(loadingTextElement).toBeInTheDocument();
});
it('should render footer actions correctly when footerActions prop is passed', () => {
const { getByText } = renderWithSSR(
<ChatMessage
senderType="other"
footerActions={
<Box>
<Box key={1}>Action 1</Box>,<Box key={2}>Action 2</Box>,<Box key={3}>Action 3</Box>,
</Box>
}
>
Can you help me with the docs?
</ChatMessage>,
);
const action1 = getByText('Action 1');
const action2 = getByText('Action 2');
const action3 = getByText('Action 3');
expect(action1).toBeInTheDocument();
expect(action2).toBeInTheDocument();
expect(action3).toBeInTheDocument();
});
});
Loading

0 comments on commit d906838

Please sign in to comment.