Skip to content

Commit

Permalink
[AC-3468] feat: file message support (#355)
Browse files Browse the repository at this point in the history
## Changes
- Added file message support

ticket: [AC-3468]

## Additional Notes
- figma:
https://www.figma.com/design/qvfSo6jeK4t4a9jcidEjZO/2407_Support-image-%26-text-in-Widget_WIP?node-id=1-57&node-type=CANVAS&m=dev
- prd:
https://sendbird.atlassian.net/wiki/spaces/AC/pages/2504884225/PRD+Image+file+support

## Checklist
Before requesting a code review, please check the following:
- [x] **[Required]** CI has passed all checks.
- [ ] **[Required]** A self-review has been conducted to ensure there
are no minor mistakes.
- [ ] **[Required]** Unnecessary comments/debugging code have been
removed.
- [ ] **[Required]** All requirements specified in the ticket have been
accurately implemented.
- [ ] Ensure the ticket has been updated with the sprint, status, and
story points.


[AC-3468]:
https://sendbird.atlassian.net/browse/AC-3468?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
  • Loading branch information
bang9 authored Sep 11, 2024
1 parent d989951 commit 8181ba9
Show file tree
Hide file tree
Showing 27 changed files with 458 additions and 88 deletions.
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,5 +82,8 @@
"workspaces": [
"packages/*"
],
"packageManager": "[email protected]"
"packageManager": "[email protected]",
"resolutions": {
"@sendbird/chat": "file:./sendbird-chat-4.14.2.tgz"
}
}
Binary file added sendbird-chat-4.14.2.tgz
Binary file not shown.
10 changes: 9 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import ChatAiWidget, { ChatAiWidgetProps } from './components/widget/ChatAiWidget';

const App = (props: ChatAiWidgetProps) => {
return <ChatAiWidget {...props} />;
return (
<ChatAiWidget
{...props}
applicationId={'B13D7DE1-7F2B-4343-A896-E812E2BBC67A'}
botId={'onboarding_bot'}
apiHost={'https://api-no2.sendbirdtest.com'}
wsHost={'wss://ws-no2.sendbirdtest.com'}
/>
);
};

export default App;
10 changes: 2 additions & 8 deletions src/components/BotMessageFeedback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import { BaseMessage, Feedback, FeedbackRating } from '@sendbird/chat/message';
import { useReducer } from 'react';

import FeedbackIconButton from '@uikit/ui/FeedbackIconButton';
import MessageFeedbackFailedModal from '@uikit/ui/MessageFeedbackFailedModal';
import MessageFeedbackModal from '@uikit/ui/MessageFeedbackModal';
import MobileFeedbackMenu from '@uikit/ui/MobileFeedbackMenu';

import { AlertModal } from './ui/AlertModal';
import { elementIds } from '../const';
import { useConstantState } from '../context/ConstantContext';
import { Icon } from '../foundation/components/Icon';
Expand Down Expand Up @@ -137,13 +137,7 @@ function BotMessageFeedback({ message }: { message: BaseMessage }) {
}
{
// error modal
!!state.errorText && (
<MessageFeedbackFailedModal
text={state.errorText}
rootElementId={elementIds.widgetWindow}
onCancel={() => setState({ errorText: '' })}
/>
)
!!state.errorText && <AlertModal message={state.errorText} onClose={() => setState({ errorText: '' })} />
}
</>
);
Expand Down
5 changes: 2 additions & 3 deletions src/components/BotMessageWithBodyInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,15 +63,14 @@ export default function BotMessageWithBodyInput(props: Props) {
const nonChainedMessage = chainTop == null && chainBottom == null;
const displaySender = nonChainedMessage || chainTop;
const displayProfileImage = nonChainedMessage || chainBottom;
const { profileUrl, nickname } = botStudioEditProps?.botInfo ?? {};
const botProfileUrl = profileUrl ?? botUser?.profileUrl;
const { nickname } = botStudioEditProps?.botInfo ?? {};
const botNickname = nickname ?? botUser?.nickname;

return (
<Root>
{displayProfileImage ? (
<div style={{ paddingBottom: profilePaddingBottom }}>
<BotProfileImage size={28} profileUrl={botProfileUrl} />
<BotProfileImage size={28} />
</div>
) : (
<EmptyImageContainer />
Expand Down
10 changes: 8 additions & 2 deletions src/components/BotProfileImage.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { styled } from '@linaria/react';
import { useTheme } from 'styled-components';

import { useChatContext } from './chat/context/ChatProvider';
import { getColorBasedOnSaturation } from '../colors';
import { useConstantState } from '../context/ConstantContext';
import BotProfileIcon from '../icons/bot-profile-image-small.svg';

const Container = styled.span<{ backgroundColor: string; size: number }>`
Expand All @@ -23,13 +25,17 @@ const Icon = styled(BotProfileIcon)<{ fill: string }>`
`;

type Props = {
profileUrl?: string;
size: number;
};

function BotProfileImage({ size, profileUrl }: Props) {
function BotProfileImage({ size }: Props) {
const theme = useTheme();

const { botStudioEditProps } = useConstantState();
const { botUser } = useChatContext();
const { botInfo } = botStudioEditProps ?? {};
const profileUrl = botInfo?.profileUrl ?? botUser?.profileUrl;

if (profileUrl) {
return <img src={profileUrl} style={{ borderRadius: '50%', width: size, height: size }} alt={'bot profile'} />;
}
Expand Down
16 changes: 12 additions & 4 deletions src/components/CustomMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import CustomTypingIndicatorBubble from './CustomTypingIndicatorBubble';
import FileMessage from './FileMessage';
import { CarouselMessage } from './messages/CarouselMessage';
import FormMessage from './messages/FormMessage';
import { OutgoingFileMessage } from './messages/OutgoingFileMessage';
import ParsedBotMessageBody from './ParsedBotMessageBody';
import UserMessageWithBodyInput from './UserMessageWithBodyInput';
import { useConstantState } from '../context/ConstantContext';
Expand All @@ -32,13 +33,11 @@ type Props = {
export default function CustomMessage(props: Props) {
const { botUser } = useChatContext();
const { message, activeSpinnerId } = props;
const { replacementTextList, enableEmojiFeedback, botStudioEditProps = {} } = useConstantState();
const { replacementTextList, enableEmojiFeedback } = useConstantState();
const { userId: currentUserId } = useWidgetSession();
const { botInfo } = botStudioEditProps;
const getCarouselItems = useCarouselItems(message);

const botUserId = botUser?.userId;
const botProfileUrl = botInfo?.profileUrl ?? botUser?.profileUrl ?? '';
const isWaitingForBotReply = activeSpinnerId === message.messageId && !!botUser;

const shouldRenderFeedback = () => {
Expand Down Expand Up @@ -75,6 +74,15 @@ export default function CustomMessage(props: Props) {
</div>
);
}

if (message.isFileMessage()) {
return (
<div>
<OutgoingFileMessage message={message} />
{isWaitingForBotReply && <CustomTypingIndicatorBubble />}
</div>
);
}
}

// Sent by bot user
Expand All @@ -95,7 +103,7 @@ export default function CustomMessage(props: Props) {
<BotMessageWithBodyInput
wideContainer={isVideoMessage(message)}
{...props}
bodyComponent={<FileMessage message={message} profileUrl={botProfileUrl} />}
bodyComponent={<FileMessage message={message} />}
createdAt={message.createdAt}
messageFeedback={renderFeedbackButtons()}
/>
Expand Down
10 changes: 1 addition & 9 deletions src/components/CustomTypingIndicatorBubble.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,10 @@
import BotProfileImage from './BotProfileImage';
import { useChatContext } from './chat/context/ChatProvider';
import { useConstantState } from '../context/ConstantContext';
import { TypingBubble } from '../foundation/components/TypingBubble';

function CustomTypingIndicatorBubble() {
const { botStudioEditProps = {} } = useConstantState();
const { botUser } = useChatContext();

const botInfo = botStudioEditProps.botInfo;
const botProfileUrl = botInfo?.profileUrl ?? botUser?.profileUrl;

return (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 8, marginTop: 16 }}>
<BotProfileImage size={28} profileUrl={botProfileUrl} />
<BotProfileImage size={28} />
<TypingBubble />
</div>
);
Expand Down
14 changes: 3 additions & 11 deletions src/components/FileMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,17 @@ import '../css/index.css';
import { FileMessage as ChatFileMessage } from '@sendbird/chat/message';
import { useState } from 'react';

import FileViewer from '@uikit/modules/GroupChannel/components/FileViewer';
import { isImageMessage, isVideoMessage } from '@uikit/utils';

import BotProfileImage from './BotProfileImage';
import { useChatContext } from './chat/context/ChatProvider';
import { FileViewer } from './ui/FileViewer';

type Props = {
message: ChatFileMessage;
profileUrl: string;
};

export default function FileMessage(props: Props) {
const { message, profileUrl } = props;
const { message } = props;
const { scrollSource } = useChatContext();
const [showFileViewer, setShowFileViewer] = useState(false);

Expand Down Expand Up @@ -42,13 +40,7 @@ export default function FileMessage(props: Props) {
onClick={() => setShowFileViewer(true)}
/>
)}
{showFileViewer && (
<FileViewer
message={message}
onCancel={() => setShowFileViewer(false)}
profile={<BotProfileImage size={32} profileUrl={profileUrl} />}
/>
)}
{showFileViewer && <FileViewer message={message} onClose={() => setShowFileViewer(false)} />}
</div>
);
}
10 changes: 10 additions & 0 deletions src/components/FormInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,11 @@ const Chip = styled.div<ChipProps>`
outline: 'none',
'box-shadow': `0 0 0 1px ${theme.borderColor.formChip.focus}`,
},
'&:active': {
color: theme.textColor.formChip.selected,
backgroundColor: theme.bgColor.formChip.selected,
border: `1px solid ${theme.borderColor.formChip.selected}`,
},
};
}
case 'selected': {
Expand All @@ -133,6 +138,11 @@ const Chip = styled.div<ChipProps>`
outline: 'none',
'box-shadow': `0 0 0 1px ${theme.borderColor.formChip.focus}`,
},
'&:active': {
color: theme.textColor.formChip.selected,
backgroundColor: theme.bgColor.formChip.selected,
border: `1px solid ${theme.borderColor.formChip.selected}`,
},
};
}
case 'submittedDefault': {
Expand Down
17 changes: 13 additions & 4 deletions src/components/chat/hooks/useWidgetChatHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import type { FileMessageCreateParams, UserMessageCreateParams } from '@sendbird
import { useRef } from 'react';

import { useConstantState } from '../../../context/ConstantContext';
import { getImageAspectRatioMetaArray } from '../../../utils/getImageAspectRatio';

export interface WidgetChatHandlers {
onBeforeSendMessage: <T extends UserMessageCreateParams | FileMessageCreateParams>(params: T) => T;
onBeforeSendMessage: <T extends UserMessageCreateParams | FileMessageCreateParams>(params: T) => Promise<T>;
onAfterSendMessage: () => void;
}

Expand All @@ -14,11 +15,19 @@ export const useWidgetChatHandlers = (params: { onScrollToBottom: () => void })
aiAttributesRef.current = botStudioEditProps?.aiAttributes;

return {
onBeforeSendMessage: <T extends UserMessageCreateParams | FileMessageCreateParams>(params: T) => {
onBeforeSendMessage: async <T extends UserMessageCreateParams | FileMessageCreateParams>(params: T) => {
const metaArray = await getImageAspectRatioMetaArray(params);
if (aiAttributesRef.current) {
return { ...params, data: JSON.stringify({ ai_attrs: aiAttributesRef.current }) };
return {
...params,
metaArrays: metaArray ? [metaArray] : undefined,
data: JSON.stringify({ ai_attrs: aiAttributesRef.current }),
};
} else {
return params;
return {
...params,
metaArrays: metaArray ? [metaArray] : undefined,
};
}
},
onAfterSendMessage: params.onScrollToBottom,
Expand Down
3 changes: 1 addition & 2 deletions src/components/chat/ui/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ export const ChatHeader = ({ fullscreen }: Props) => {

const { botInfo } = botStudioEditProps ?? {};
const botNickname = botInfo?.nickname ?? botUser?.nickname;
const profileUrl = botInfo?.profileUrl ?? botUser?.profileUrl;
const buttonSize = isMobileView ? 24 : 16;
const isExpandableMode = !fullscreen && !isMobileView;

Expand All @@ -43,7 +42,7 @@ export const ChatHeader = ({ fullscreen }: Props) => {
return (
<div className={container}>
<div style={{ marginRight: 6 }}>
<BotProfileImage size={34} profileUrl={profileUrl} />
<BotProfileImage size={34} />
</div>
<div className={headerCenter}>
<Label type={'h2'} color={'onbackground1'} className={titleInline}>
Expand Down
52 changes: 43 additions & 9 deletions src/components/chat/ui/ChatInput.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import { css } from '@linaria/core';
import { useRef } from 'react';
import { useRef, useState } from 'react';

import useSendbirdStateContext from '@uikit/hooks/useSendbirdStateContext';
import MessageInputWrapperView from '@uikit/modules/GroupChannel/components/MessageInputWrapper/MessageInputWrapperView';

import { themedColors } from '../../../foundation/colors/css';
import { useBlockWhileBotResponding } from '../../../hooks/useBlockWhileBotResponding';
import { isIOSMobile } from '../../../utils';
import { AlertModal } from '../../ui/AlertModal';
import { useChatContext } from '../context/ChatProvider';

// TODO: Remove UIKit
export const ChatInput = () => {
const { channel, botUser, dataSource, handlers } = useChatContext();

const ref = useRef<HTMLDivElement>(null);
const [limitError, setLimitError] = useState(false);

const { config } = useSendbirdStateContext();
const isMessageInputDisabled = useBlockWhileBotResponding({
Expand All @@ -28,14 +31,23 @@ export const ChatInput = () => {
messageInputRef={ref}
currentChannel={channel as any}
messages={dataSource.messages}
sendUserMessage={(params) => {
const processedParams = handlers.onBeforeSendMessage(params);
dataSource.sendUserMessage(processedParams, handlers.onAfterSendMessage).then(handlers.onAfterSendMessage);
sendUserMessage={async (params) => {
const processedParams = await handlers.onBeforeSendMessage(params);
const message = await dataSource.sendUserMessage(processedParams, () => handlers.onAfterSendMessage());
handlers.onAfterSendMessage();
return message;
}}
sendFileMessage={() => {
throw new Error('Not implemented');
sendFileMessage={async (params) => {
const processedParams = await handlers.onBeforeSendMessage(params);
const message = await dataSource.sendFileMessage(processedParams, () => handlers.onAfterSendMessage());
handlers.onAfterSendMessage();
return message;
}}
onFileLimitError={() => setLimitError(true)}
/>
{limitError && (
<AlertModal message={"You can't upload more than one image"} onClose={() => setLimitError(false)} />
)}
</div>
);
};
Expand All @@ -54,13 +66,21 @@ const container = css`
padding: 12px 16px;
}
.sendbird-message-input--area {
background-color: ${themedColors.bg2};
}
.sendbird-message-input {
display: flex;
align-items: center;
.sendbird-message-input-text-field {
padding: 8px 16px;
height: 40px;
max-height: 116px;
min-height: 36px;
max-height: 100px;
height: 36px;
overflow-y: auto;
padding-top: 8px;
padding-bottom: 8px;
padding-inline-start: 16px;
border-radius: 20px;
// Not to zoom in on mobile set font-size to 16px which blocks the zooming on iOS
// @link: https://weblog.west-wind.com/posts/2023/Apr/17/Preventing-iOS-Safari-Textbox-Zooming
Expand All @@ -81,6 +101,20 @@ const container = css`
bottom: unset;
background-color: transparent;
}
.sendbird-message-input--attach {
right: unset;
bottom: unset;
inset-inline-end: 12px;
inset-block-end: 2px;
& .sendbird-iconbutton__inner {
height: 16px;
}
&:hover {
path {
fill: ${themedColors.oncontent_inverse1};
}
}
}
.sendbird-message-input--placeholder {
position: absolute;
top: 50%;
Expand Down
Loading

0 comments on commit 8181ba9

Please sign in to comment.