Skip to content

Commit

Permalink
feat: add useChatLogger hook (#2793)
Browse files Browse the repository at this point in the history
* feat: add useChatLogger hook

* chore: adjust Chat type annotation

* chore: add missing peer/dev deps
  • Loading branch information
jb-twilio committed Nov 17, 2022
1 parent bd6f229 commit 80cb7ce
Show file tree
Hide file tree
Showing 17 changed files with 484 additions and 8 deletions.
6 changes: 6 additions & 0 deletions .changeset/little-plums-turn.md
@@ -0,0 +1,6 @@
---
'@twilio-paste/chat-log': minor
'@twilio-paste/core': minor
---

[ChatLog]: add useChatLogger hook
2 changes: 2 additions & 0 deletions packages/paste-codemods/tools/.cache/mappings.json
Expand Up @@ -33,10 +33,12 @@
"ChatBubble": "@twilio-paste/core/chat-log",
"ChatEvent": "@twilio-paste/core/chat-log",
"ChatLog": "@twilio-paste/core/chat-log",
"ChatLogger": "@twilio-paste/core/chat-log",
"ChatMessage": "@twilio-paste/core/chat-log",
"ChatMessageMeta": "@twilio-paste/core/chat-log",
"ChatMessageMetaItem": "@twilio-paste/core/chat-log",
"ComposerAttachmentCard": "@twilio-paste/core/chat-log",
"useChatLogger": "@twilio-paste/core/chat-log",
"Checkbox": "@twilio-paste/core/checkbox",
"CheckboxDisclaimer": "@twilio-paste/core/checkbox",
"CheckboxGroup": "@twilio-paste/core/checkbox",
Expand Down
@@ -0,0 +1,35 @@
import * as React from 'react';
import {screen, render} from '@testing-library/react';

import {ChatLogger, ChatMessage, ChatBubble} from '../src';
import type {Chat} from '../src/useChatLogger';

const chats: Chat[] = [
{
id: 'uid1',
variant: 'inbound',
content: (
<ChatMessage variant="inbound">
<ChatBubble>hi</ChatBubble>
</ChatMessage>
),
},
{
id: 'uid2',
variant: 'outbound',
content: (
<ChatMessage variant="outbound">
<ChatBubble>hello</ChatBubble>
</ChatMessage>
),
},
];

describe('ChatLogger', () => {
it('should render', () => {
render(<ChatLogger chats={chats} />);
expect(screen.getByRole('log')).toBeDefined();
expect(screen.getByRole('list')).toBeDefined();
expect(screen.getAllByRole('listitem')).toHaveLength(2);
});
});
@@ -0,0 +1,88 @@
import * as React from 'react';
import {renderHook, act} from '@testing-library/react-hooks';

import {useChatLogger, ChatBubble, ChatMessage} from '../src';

const chat = {
id: 'custom-id-123',
variant: 'inbound',
content: (
<ChatMessage variant="inbound">
<ChatBubble>hi</ChatBubble>
</ChatMessage>
),
} as const;

describe('useChatLogger', () => {
it('returns expected result with defaults', () => {
const {result} = renderHook(() => useChatLogger());

expect(result.current).toMatchObject({
chats: [],
pop: expect.any(Function),
push: expect.any(Function),
});
});

it('returns expected result with initialization', () => {
const {result} = renderHook(() => useChatLogger(chat));

expect(result.current.chats).toHaveLength(1);
expect(result.current.pop).toBeInstanceOf(Function);
expect(result.current.push).toBeInstanceOf(Function);
expect(result.current.chats[0]).toMatchObject(chat);
});

describe('push', () => {
it('pushes new chats with an id', () => {
const {result} = renderHook(() => useChatLogger());
expect(result.current.chats).toHaveLength(0);

act(() => {
result.current.push(chat);
});

expect(result.current.chats).toHaveLength(1);
expect(result.current.chats[0]).toMatchObject(chat);
});

it('pushes new chats without an id', () => {
const {result} = renderHook(() => useChatLogger());
expect(result.current.chats).toHaveLength(0);

act(() => {
const chatWithoutCustomId = {...chat, id: undefined};
result.current.push(chatWithoutCustomId);
});

expect(result.current.chats).toHaveLength(1);
expect(result.current.chats[0]).toMatchObject({
id: expect.stringMatching(/^uid/),
});
});
});

describe('pop', () => {
it('pops chats with an id', () => {
const {result} = renderHook(() => useChatLogger(chat));
expect(result.current.chats).toHaveLength(1);

act(() => {
result.current.pop(chat.id);
});

expect(result.current.chats).toHaveLength(0);
});

it('pops chats without an id', () => {
const {result} = renderHook(() => useChatLogger(chat));
expect(result.current.chats).toHaveLength(1);

act(() => {
result.current.pop();
});

expect(result.current.chats).toHaveLength(0);
});
});
});
2 changes: 2 additions & 0 deletions packages/paste-core/components/chat-log/package.json
Expand Up @@ -26,6 +26,7 @@
},
"peerDependencies": {
"@twilio-paste/anchor": "^9.0.0",
"@twilio-paste/animation-library": "^0.3.9",
"@twilio-paste/box": "^7.0.0",
"@twilio-paste/button": "^11.0.0",
"@twilio-paste/customization": "^5.0.0",
Expand All @@ -43,6 +44,7 @@
},
"devDependencies": {
"@twilio-paste/anchor": "^9.0.1",
"@twilio-paste/animation-library": "^0.3.9",
"@twilio-paste/box": "^7.1.1",
"@twilio-paste/button": "^11.1.4",
"@twilio-paste/customization": "^5.0.1",
Expand Down
4 changes: 2 additions & 2 deletions packages/paste-core/components/chat-log/src/ChatBookend.tsx
Expand Up @@ -13,8 +13,8 @@ const ChatBookend = React.forwardRef<HTMLDivElement, ChatBookendProps>(
return (
<Box
{...safelySpreadBoxProps(props)}
as="li"
listStyleType="none"
as="div"
role="listitem"
color="colorTextWeak"
element={element}
textAlign="center"
Expand Down
4 changes: 2 additions & 2 deletions packages/paste-core/components/chat-log/src/ChatEvent.tsx
Expand Up @@ -13,8 +13,8 @@ const ChatEvent = React.forwardRef<HTMLDivElement, ChatEventProps>(
return (
<Box
{...safelySpreadBoxProps(props)}
as="li"
listStyleType="none"
as="div"
role="listitem"
color="colorTextWeak"
element={element}
textAlign="center"
Expand Down
2 changes: 1 addition & 1 deletion packages/paste-core/components/chat-log/src/ChatLog.tsx
Expand Up @@ -11,7 +11,7 @@ export interface ChatLogProps {
const ChatLog = React.forwardRef<HTMLDivElement, ChatLogProps>(({children, element = 'CHAT_LOG', ...props}, ref) => {
return (
<Box role="log" padding="space70" element={element} ref={ref} {...safelySpreadBoxProps(props)}>
<Box as="ul" margin="space0" padding="space0">
<Box as="div" role="list" margin="space0" padding="space0" overflowX="hidden">
{children}
</Box>
</Box>
Expand Down
48 changes: 48 additions & 0 deletions packages/paste-core/components/chat-log/src/ChatLogger.tsx
@@ -0,0 +1,48 @@
import * as React from 'react';
import {Box} from '@twilio-paste/box';
import {useTransition, animated, useReducedMotion} from '@twilio-paste/animation-library';

import {ChatLog} from './ChatLog';
import type {Chat} from './useChatLogger';

const AnimatedChat = animated(Box);
type StyleProps = React.ComponentProps<typeof AnimatedChat>['style'];

export interface ChatLoggerProps {
chats: Chat[];
children?: never;
}

const buildTransitionX = (chat: Chat): number => {
if (chat.variant === 'inbound') return -100;
if (chat.variant === 'outbound') return 100;
return 0;
};

const ChatLogger: React.FC<ChatLoggerProps> = ({chats}) => {
const transitions = useTransition(chats, {
keys: (chat: Chat) => chat.id,
from: (chat: Chat): StyleProps => ({opacity: 0, x: buildTransitionX(chat)}),
enter: {opacity: 1, x: 0},
leave: (chat: Chat): StyleProps => ({opacity: 0, x: buildTransitionX(chat)}),
config: {
mass: 1,
tension: 150,
friction: 20,
},
});

const animatedChats = useReducedMotion()
? chats.map((chat) => React.cloneElement(chat.content, {key: chat.id}))
: transitions((styles: StyleProps, chat: Chat, {key}: {key: string}) => (
<AnimatedChat as="div" style={styles} key={key}>
{chat.content}
</AnimatedChat>
));

return <ChatLog>{animatedChats}</ChatLog>;
};

ChatLogger.displayName = 'ChatLogger';

export {ChatLogger};
3 changes: 1 addition & 2 deletions packages/paste-core/components/chat-log/src/ChatMessage.tsx
Expand Up @@ -31,8 +31,7 @@ export const ChatMessage = React.forwardRef<HTMLDivElement, ChatMessageProps>(
return (
<MessageVariantContext.Provider value={variant}>
<Box
as="li"
listStyleType="none"
role="listitem"
marginBottom="space80"
display="flex"
flexDirection="column"
Expand Down
2 changes: 2 additions & 0 deletions packages/paste-core/components/chat-log/src/index.tsx
Expand Up @@ -10,4 +10,6 @@ export * from './ChatAttachmentDescription';
export * from './ChatBookend';
export * from './ChatBookendItem';
export * from './ChatEvent';
export * from './ChatLogger';
export * from './useChatLogger';
export type {MessageVariants} from './MessageVariantContext';
45 changes: 45 additions & 0 deletions packages/paste-core/components/chat-log/src/useChatLogger.ts
@@ -0,0 +1,45 @@
import * as React from 'react';
import {uid} from '@twilio-paste/uid-library';

import type {MessageVariants} from './MessageVariantContext';

type PushChat = (chat: PartialIDChat) => void;
type PopChat = (id?: string) => void;

export type Chat = {
id: string;
variant?: MessageVariants;
content: React.ReactElement;
};

export type PartialIDChat = Omit<Chat, 'id'> & Partial<Pick<Chat, 'id'>>;

export type UseChatLogger = (...initialChats: PartialIDChat[]) => {
chats: Chat[];
push: PushChat;
pop: PopChat;
};

const chatWithId = (chat: PartialIDChat): Chat => ({...chat, id: chat.id || uid(chat.content)});

export const useChatLogger: UseChatLogger = (...initialChats) => {
const parsedInitialChats = React.useMemo(() => initialChats.map(chatWithId), [initialChats]);

const [chats, setChats] = React.useState<Chat[]>(parsedInitialChats);

const push: PushChat = React.useCallback(
(next) => {
setChats((prev) => prev.concat(chatWithId(next)));
},
[setChats]
);

const pop: PopChat = React.useCallback(
(id) => {
setChats((prev) => (id ? prev.filter((chat) => chat.id !== id) : prev.slice(0, -1)));
},
[setChats]
);

return {push, pop, chats};
};

0 comments on commit 80cb7ce

Please sign in to comment.