这节来实现下发送表情、图片、文件。
这种 emoji 表情:
有现成的库 emoji-mart:
可以试试他的 demo:
我们直接用就行:
安装下:
npm install --save @emoji-mart/data @emoji-mart/react
<Popover content={<EmojiPicker data={data} onEmojiSelect={(emoji: any) => {
setInputText((inputText) => inputText + emoji.native)
}} />} title="Title" trigger="click">
表情
</Popover>
这样,就可以发表情了:
然后来实现发送图片:
这个和之前的上传图片没啥大的区别。
加一个 Modal:
src/pages/Chat/UploadImageModal.tsx
import { Modal } from "antd";
import { ImageUpload } from "./ImageUpload";
import { useState } from "react";
interface UploadImageModalProps {
isOpen: boolean;
handleClose: (imageSrc?: string) => void
}
export function UploadImageModal(props: UploadImageModalProps) {
const [imgSrc, setImgSrc] = useState<string>('');
return <Modal
title="上传图片"
open={props.isOpen}
onOk={() => {
props.handleClose(imgSrc)
setImgSrc('')
}}
onCancel={() => props.handleClose()}
okText={'确认'}
cancelText={'取消'}
>
<ImageUpload value={imgSrc} onChange={(value: string) => {
setImgSrc(value)
}}/>
</Modal>
}
Modal 里只包含上传组件,关闭弹窗的时候把 imgSrc 通过参数返回。
这个上传组件和之前的差不多:
src/pages/Chat/ImageUpload.tsx
import { InboxOutlined } from "@ant-design/icons";
import { message } from "antd";
import Dragger, { DraggerProps } from "antd/es/upload/Dragger";
import axios from "axios";
import { presignedUrl } from "../../interfaces";
interface ImageUploadProps {
value?: string;
onChange?: Function
}
let onChange: Function;
const props: DraggerProps = {
name: 'file',
action: async (file) => {
const res = await presignedUrl(file.name);
return res.data;
},
async customRequest(options) {
const { onSuccess, file, action } = options;
const res = await axios.put(action, file);
onSuccess!(res.data);
},
onChange(info) {
const { status } = info.file;
if (status === 'done') {
onChange('http://localhost:9000/chat-room/' + info.file.name);
message.success(`${info.file.name} 文件上传成功`);
} else if (status === 'error') {
message.error(`${info.file.name} 文件上传失败`);
}
}
};
const dragger = <Dragger {...props}>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text">点击或拖拽文件到这个区域来上传</p>
</Dragger>
export function ImageUpload(props: ImageUploadProps) {
onChange = props.onChange!
return props?.value ? <div>
<img src={props.value} alt="图片" width="100" height="100"/>
{dragger}
</div>: <div>
{dragger}
</div>
}
和之前上传头像的逻辑一样。
然后在 Chat 组件里用一下:
const [isUploadImageModalOpen, setUploadImageModalOpen] = useState(false);
setUploadImageModalOpen(true);
<UploadImageModal isOpen={isUploadImageModalOpen} handleClose={(imgSrc) => {
setUploadImageModalOpen(false);
console.log(imgSrc);
}} />
测试下:
上传成功,点击确认按钮,在控制台打印了图片 url。
我们把这个 url 作为消息发送就好了。
首先在 sendMessage 方法加一个 type 参数,可以指定 image、text、file,默认是 text。
然后上传完图片之后,调用 sendMessage 方法,type 为 image:
另外,消息展示的时候也要根据类型做下处理:
import { Button, Input, message, Popover } from "antd";
import { useEffect, useRef, useState } from "react";
import { io, Socket } from "socket.io-client";
import './index.scss';
import { chatHistoryList, chatroomList } from "../../interfaces";
import { UserInfo } from "../UpdateInfo";
import TextArea from "antd/es/input/TextArea";
import { useLocation } from "react-router-dom";
import EmojiPicker from "@emoji-mart/react";
import data from '@emoji-mart/data'
import { UploadImageModal } from "./UploadImageModal";
interface JoinRoomPayload {
chatroomId: number
userId: number
}
interface SendMessagePayload {
sendUserId: number;
chatroomId: number;
message: Message
}
type MessageType = 'image' | 'text' | 'file';
interface Message {
type: MessageType
content: string
}
type Reply = {
type: 'sendMessage'
userId: number
message: ChatHistory
} | {
type: 'joinRoom'
userId: number
}
interface Chatroom {
id: number;
name: string;
createTime: Date;
}
interface ChatHistory {
id: number
content: string
type: number
chatroomId: number
senderId: number
createTime: Date,
sender: UserInfo
}
interface User {
id: number;
email: string;
headPic: string;
nickName: string;
username: string;
createTime: Date;
}
export function getUserInfo(): User {
return JSON.parse(localStorage.getItem('userInfo')!);
}
export function Chat() {
const socketRef = useRef<Socket>();
const [roomId, setChatroomId] = useState<number>();
const userInfo = getUserInfo();
const [isUploadImageModalOpen, setUploadImageModalOpen] = useState(false);
useEffect(() => {
if(!roomId) {
return;
}
const socket = socketRef.current = io('http://localhost:3005');
socket.on('connect', function() {
const payload: JoinRoomPayload = {
chatroomId: roomId,
userId: userInfo.id
}
socket.emit('joinRoom', payload);
socket.on('message', (reply: Reply) => {
if(reply.type === 'sendMessage') {
setChatHistory((chatHistory) => {
return chatHistory ? [...chatHistory, reply.message] : [reply.message]
});
setTimeout(() => {
document.getElementById('bottom-bar')?.scrollIntoView({block: 'end'});
}, 300);
}
});
});
return () => {
socket.disconnect();
}
}, [roomId]);
function sendMessage(value: string, type: MessageType = 'text') {
if(!value) {
return;
}
if(!roomId) {
return;
}
const payload: SendMessagePayload = {
sendUserId: userInfo.id,
chatroomId: roomId,
message: {
type,
content: value
}
}
socketRef.current?.emit('sendMessage', payload);
}
const [roomList, setRoomList] = useState<Array<Chatroom>>();
async function queryChatroomList() {
try{
const res = await chatroomList();
if(res.status === 201 || res.status === 200) {
setRoomList(res.data.map((item: Chatroom) => {
return {
...item,
key: item.id
}
}));
}
} catch(e: any){
console.log(e);
message.error(e.response?.data?.message || '系统繁忙,请稍后再试');
}
}
useEffect(() => {
queryChatroomList();
}, []);
useEffect(() => {
setTimeout(() => {
document.getElementById('bottom-bar')?.scrollIntoView({block: 'end'});
}, 300);
}, [roomId])
const [chatHistory, setChatHistory] = useState<Array<ChatHistory>>();
async function queryChatHistoryList(chatroomId: number) {
try{
const res = await chatHistoryList(chatroomId);
if(res.status === 201 || res.status === 200) {
setChatHistory(res.data.map((item: Chatroom) => {
return {
...item,
key: item.id
}
}));
}
} catch(e: any){
message.error(e.response?.data?.message || '系统繁忙,请稍后再试');
}
}
const [inputText, setInputText] = useState('');
const location = useLocation();
useEffect(() => {
if(location.state?.chatroomId) {
setChatroomId(location.state?.chatroomId);
queryChatHistoryList(location.state?.chatroomId);
}
}, [location.state?.chatroomId]);
return <div id="chat-container">
<div className="chat-room-list">
{
roomList?.map(item => {
return <div className={`chat-room-item ${item.id === roomId ? 'selected' : ''}`} key={item.id} data-id={item.id} onClick={() => {
queryChatHistoryList(item.id);
setChatroomId(item.id);
}}>{item.name}</div>
})
}
</div>
<div className="message-list">
{chatHistory?.map(item => {
return <div className={`message-item ${item.senderId === userInfo.id ? 'from-me' : ''}`} key={item.id} data-id={item.id}>
<div className="message-sender">
<img src={item.sender.headPic} />
<span className="sender-nickname">{item.sender.nickName}</span>
</div>
<div className="message-content">
{
item.type === 0
? item.content
: item.type === 1
? <img src={item.content} style={{maxWidth: 200}}/>
: item.content
}
</div>
</div>
})}
<div id="bottom-bar" key='bottom-bar'></div>
</div>
<div className="message-input">
<div className="message-type">
<div className="message-type-item" key={1}>
<Popover content={<EmojiPicker data={data} onEmojiSelect={(emoji: any) => {
setInputText((inputText) => inputText + emoji.native)
}} />} title="Title" trigger="click">
表情
</Popover>
</div>
<div className="message-type-item" key={2} onClick={() => {
setUploadImageModalOpen(true);
}}>图片</div>
<div className="message-type-item" key={3}>文件</div>
</div>
<div className="message-input-area">
<TextArea className="message-input-box" value={inputText} onChange={(e) => {
setInputText(e.target.value)
}}/>
<Button className="message-send-btn" type="primary" onClick={() => {
sendMessage(inputText)
setInputText('');
}}>发送</Button>
</div>
</div>
<UploadImageModal isOpen={isUploadImageModalOpen} handleClose={(imgSrc) => {
setUploadImageModalOpen(false);
if(imgSrc) {
sendMessage(imgSrc, 'image')
}
}} />
</div>
}
测试下:
这样,发送图片功能就完成了。
发送文件其实和这个差不多,只是展示的方式不同。
可以图片上传的逻辑,稍微改造下:
把 ImageUpload 组件改名为 FileUpload 组件,加一个 type 参数:
type 为 file 的时候,预览部分直接展示 value:
import { InboxOutlined } from "@ant-design/icons";
import { message } from "antd";
import Dragger, { DraggerProps } from "antd/es/upload/Dragger";
import axios from "axios";
import { presignedUrl } from "../../interfaces";
interface FileUploadProps {
value?: string;
onChange?: Function
type: 'image' | 'file'
}
let onChange: Function;
const props: DraggerProps = {
name: 'file',
action: async (file) => {
const res = await presignedUrl(file.name);
return res.data;
},
async customRequest(options) {
const { onSuccess, file, action } = options;
const res = await axios.put(action, file);
onSuccess!(res.data);
},
onChange(info) {
const { status } = info.file;
if (status === 'done') {
onChange('http://localhost:9000/chat-room/' + info.file.name);
message.success(`${info.file.name} 文件上传成功`);
} else if (status === 'error') {
message.error(`${info.file.name} 文件上传失败`);
}
}
};
const dragger = <Dragger {...props}>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text">点击或拖拽文件到这个区域来上传</p>
</Dragger>
export function FileUpload(props: FileUploadProps) {
onChange = props.onChange!
return props?.value ? <div>
{
props.type === 'image'
? <img src={props.value} alt="图片" width="100" height="100"/>
: props.value
}
{dragger}
</div>: <div>
{dragger}
</div>
}
然后 UploadImageModal 改名为 UploadModal
也增加一个 type 参数,展示不同文案。
import { Modal } from "antd";
import { FileUpload } from "./FileUpload";
import { useState } from "react";
interface UploadModalProps {
isOpen: boolean;
handleClose: (fileUrl?: string) => void
type: 'image' | 'file'
}
export function UploadModal(props: UploadModalProps) {
const [fileUrl, setFileUrl] = useState<string>('');
return <Modal
title={`上传${props.type === 'image' ? '图片' : '文件'}`}
open={props.isOpen}
onOk={() => {
props.handleClose(fileUrl)
setFileUrl('')
}}
onCancel={() => props.handleClose()}
okText={'确认'}
cancelText={'取消'}
>
<FileUpload value={fileUrl} type={props.type} onChange={(value: string) => {
setFileUrl(value)
}}/>
</Modal>
}
最后,在 Chat/index.tsx 里引入修改后的组件:
加一个 uploadType 的参数,点击图片、文件按钮会设置不同的 type。
然后发送消息的时候,根据 type 来发:
然后改下展示的内容:
文件就直接展示路径好了。
import { Button, Input, message, Popover } from "antd";
import { useEffect, useRef, useState } from "react";
import { io, Socket } from "socket.io-client";
import './index.scss';
import { chatHistoryList, chatroomList } from "../../interfaces";
import { UserInfo } from "../UpdateInfo";
import TextArea from "antd/es/input/TextArea";
import { useLocation } from "react-router-dom";
import EmojiPicker from "@emoji-mart/react";
import data from '@emoji-mart/data'
import { UploadModal } from "./UploadModal";
interface JoinRoomPayload {
chatroomId: number
userId: number
}
interface SendMessagePayload {
sendUserId: number;
chatroomId: number;
message: Message
}
type MessageType = 'image' | 'text' | 'file';
interface Message {
type: MessageType
content: string
}
type Reply = {
type: 'sendMessage'
userId: number
message: ChatHistory
} | {
type: 'joinRoom'
userId: number
}
interface Chatroom {
id: number;
name: string;
createTime: Date;
}
interface ChatHistory {
id: number
content: string
type: number
chatroomId: number
senderId: number
createTime: Date,
sender: UserInfo
}
interface User {
id: number;
email: string;
headPic: string;
nickName: string;
username: string;
createTime: Date;
}
export function getUserInfo(): User {
return JSON.parse(localStorage.getItem('userInfo')!);
}
export function Chat() {
const socketRef = useRef<Socket>();
const [roomId, setChatroomId] = useState<number>();
const userInfo = getUserInfo();
const [isUploadModalOpen, setUploadModalOpen] = useState(false);
useEffect(() => {
if(!roomId) {
return;
}
const socket = socketRef.current = io('http://localhost:3005');
socket.on('connect', function() {
const payload: JoinRoomPayload = {
chatroomId: roomId,
userId: userInfo.id
}
socket.emit('joinRoom', payload);
socket.on('message', (reply: Reply) => {
if(reply.type === 'sendMessage') {
setChatHistory((chatHistory) => {
return chatHistory ? [...chatHistory, reply.message] : [reply.message]
});
setTimeout(() => {
document.getElementById('bottom-bar')?.scrollIntoView({block: 'end'});
}, 300);
}
});
});
return () => {
socket.disconnect();
}
}, [roomId]);
function sendMessage(value: string, type: MessageType = 'text') {
if(!value) {
return;
}
if(!roomId) {
return;
}
const payload: SendMessagePayload = {
sendUserId: userInfo.id,
chatroomId: roomId,
message: {
type,
content: value
}
}
socketRef.current?.emit('sendMessage', payload);
}
const [roomList, setRoomList] = useState<Array<Chatroom>>();
async function queryChatroomList() {
try{
const res = await chatroomList();
if(res.status === 201 || res.status === 200) {
setRoomList(res.data.map((item: Chatroom) => {
return {
...item,
key: item.id
}
}));
}
} catch(e: any){
console.log(e);
message.error(e.response?.data?.message || '系统繁忙,请稍后再试');
}
}
useEffect(() => {
queryChatroomList();
}, []);
useEffect(() => {
setTimeout(() => {
document.getElementById('bottom-bar')?.scrollIntoView({block: 'end'});
}, 300);
}, [roomId])
const [chatHistory, setChatHistory] = useState<Array<ChatHistory>>();
async function queryChatHistoryList(chatroomId: number) {
try{
const res = await chatHistoryList(chatroomId);
if(res.status === 201 || res.status === 200) {
setChatHistory(res.data.map((item: Chatroom) => {
return {
...item,
key: item.id
}
}));
}
} catch(e: any){
message.error(e.response?.data?.message || '系统繁忙,请稍后再试');
}
}
const [inputText, setInputText] = useState('');
const location = useLocation();
useEffect(() => {
if(location.state?.chatroomId) {
setChatroomId(location.state?.chatroomId);
queryChatHistoryList(location.state?.chatroomId);
}
}, [location.state?.chatroomId]);
const [uploadType, setUploadType] = useState<'image' | 'file'>('image');
return <div id="chat-container">
<div className="chat-room-list">
{
roomList?.map(item => {
return <div className={`chat-room-item ${item.id === roomId ? 'selected' : ''}`} key={item.id} data-id={item.id} onClick={() => {
queryChatHistoryList(item.id);
setChatroomId(item.id);
}}>{item.name}</div>
})
}
</div>
<div className="message-list">
{chatHistory?.map(item => {
return <div className={`message-item ${item.senderId === userInfo.id ? 'from-me' : ''}`} key={item.id} data-id={item.id}>
<div className="message-sender">
<img src={item.sender.headPic} />
<span className="sender-nickname">{item.sender.nickName}</span>
</div>
<div className="message-content">
{
item.type === 0
? item.content
: item.type === 1
? <img src={item.content} style={{maxWidth: 200}}/>
: <div>item.content</div>
}
</div>
</div>
})}
<div id="bottom-bar" key='bottom-bar'></div>
</div>
<div className="message-input">
<div className="message-type">
<div className="message-type-item" key={1}>
<Popover content={<EmojiPicker data={data} onEmojiSelect={(emoji: any) => {
setInputText((inputText) => inputText + emoji.native)
}} />} title="Title" trigger="click">
表情
</Popover>
</div>
<div className="message-type-item" key={2} onClick={() => {
setUploadType('image');
setUploadModalOpen(true);
}}>图片</div>
<div className="message-type-item" key={3}onClick={() => {
setUploadType('file');
setUploadModalOpen(true);
}}>文件</div>
</div>
<div className="message-input-area">
<TextArea className="message-input-box" value={inputText} onChange={(e) => {
setInputText(e.target.value)
}}/>
<Button className="message-send-btn" type="primary" onClick={() => {
sendMessage(inputText)
setInputText('');
}}>发送</Button>
</div>
</div>
<UploadModal isOpen={isUploadModalOpen} type={uploadType} handleClose={(fileUrl) => {
setUploadModalOpen(false);
if(fileUrl) {
sendMessage(fileUrl, uploadType)
}
}} />
</div>
}
试下效果:
发送图片:
发送文件:
都没问题。
当时我们服务端没支持 file,改一下:
const map = {
text: 0,
image: 1,
file: 2
}
const history = await this.chatHistoryService.add(payload.chatroomId, {
content: payload.message.content,
type: map[payload.message.type],
chatroomId: payload.chatroomId,
senderId: payload.sendUserId
});
然后在前端支持下文件下载:
<a download href={item.content}>{item.content}</a>
现在,就可以发送和下载文件了:
整体测试下:
发送表情:
发送图片:
发送文件:
都没问题。
总结
这节我们实现了发送表情、图片、文件。
表情用 emoji-mart 这个包实现。
图片就是之前的上传图片,只是上传完把 url 作为消息发过去,设置下 type 为 image。
文件也是一样上传,上传完把 url 作为消息发过去,设置 type 为 file。
然后展示 image 和 file 的时候分别作为图片展示,以及支持下载。
这样,发送表情、图片、文件的功能就完成了。