이번에는 참가자목록 및 이미지 업로드 기능을 추가했습니다.
채팅방에 유저가 접속하면, 서버에서 해당 유저를 Map에 저장하여 클라이언트측으로 배열 형태로 넘겨주는 방식으로 구현했습니다.
//server 코드
//app.ts
const chatUserListMap: Map<string, string> = new Map();
io.on("connection", (socket: Socket) => {
socket.on("userJoin", ({ userName }: { userName: string }) => {
chatUserListMap.set(socket.id, userName);
const chatUserListArray = Array.from(chatUserListMap, (entrty) => {
return { userId: entrty[0], userName: entrty[1] };
});
io.emit("userList", chatUserListArray);
});
}
- 유저의 socket.id를 key, 닉네임을 value형태로 Map에 저장
- 클라이언트측으로 넘겨주기 위해 배열로 변환 후 chatUserListArray에 저장
- io.emit("userList")를 통해 클라이언트측에 전달
서버에서 유저 리스트를 받은 후, 모달 형식으로 화면에 구현했습니다.
//Client 코드
//ChatMenu.tsx
<aside onClick={onMenuClose}>
<div>
<span>참가자 목록</span>
{chatUserList.length > 0 && (
<>
<li className={styles.myName}>{`${userName} (나)`}</li>
{chatUserList
.filter((item: ChatUserList) => item.userName !== userName)
.map((item: ChatUserList) => {
return <li key={item.userId}>{item.userName}</li>;
})}
</>
)}
</div>
</aside>
- 서버에서 받은 chatUserList중, 본인의 이름을 filter메서드를 활용하여 제거해줍니다.
- 본인의 이름은 참가자 리스트 최상단에 표시해줍니다.
전 이번 프로젝트에서는 db를 사용하지않기 때문에 이미지 업로드 과정은 다음과 같이 구성했습니다.
//server 코드
//app.ts
const upload = multer({
storage: multer.diskStorage({
destination: function (req, file, cb) {
cb(null, __dirname + `/upload/chat`);
},
filename: function (req, file, cb) {
const ext = path.extname(file.originalname);
cb(null, path.basename(file.originalname, ext) + "-" + Date.now() + ext);
},
}),
});
app.post("/upload", upload.single("chatImage"), (req: Request, res: Response) => {
const imagePath: string = req.file ? `http://localhost:8080/upload/chat/${req.file?.filename}` : "";
const { userName } = req.body;
io.emit("message", { userName, imagePath });
res.status(200).send({
status: 200,
message: "ok",
});
});
- multer라이브러리를 통해 "/upload/chat"경로로 이미지를 저장합니다.
- 클라이언트에서 "/upload"경로로 post요청이 들어오면 이미지를 저장하고 io.emit("message")을 통해 이미지 경로를 다시 전달합니다.
//Client 코드
//index.ts
const onImgSubmit = async (event: ChangeEvent<HTMLInputElement>) => {
try {
if (event.currentTarget.files) {
const form = new FormData();
form.append("chatImage", event.currentTarget.files[0]);
form.append("userName", userName);
const res = await axios.post("http://localhost:8080/upload", form);
if (res.status !== 200) {
throw new Error("이미지 전송 오류");
}
}
} catch (error) {
console.log(error);
}
};
//ChatInput.tsx
export default function ChatInputBox({ onImgSubmit, message, setMessage, messageEnterKeyDown }: Props) {
return (
<div className={styles.chatInputBox}>
<label>
<input type="file" accept="image/*" onChange={onImgSubmit} />
<Image width={20} height={20} alt="파일첨부 아이콘" src={"/images/plus.png"} />
</label>
<input
className={styles.chatInput}
type="text"
placeholder="메세지를 입력해주세요"
value={message}
onChange={(e) => setMessage(e.currentTarget.value)}
onKeyDown={messageEnterKeyDown}
/>
</div>
);
}
- index.ts의 onImgSubmit함수를 생성하여 ChatInput.tsx에 Props으로 전달해줍니다.
//ChatContent.tsx
export default function ChatContent({ chatMessage, onChatLeaveClick, scrollRef, onMenuOpen, userName }: Props) {
return (
<main className={styles.chatMessageBox}>
<header>
<Image width={20} height={20} alt="뒤로가기 아이콘" src={"/images/back.png"} onClick={onChatLeaveClick} />
<Image width={20} height={20} alt="메뉴 아이콘" src={"/images/more.png"} onClick={onMenuOpen} />
</header>
{chatMessage.map((item: ChatMessage, index: number) => {
return (
<li key={index} className={item.userName === userName ? styles.myMessage : styles.otherMessage}>
<span>{item.userName}</span>
{item.message && <p>{item.message}</p>}
{item.imagePath && <Image width={200} height={200} alt="채팅 이미지" src={item.imagePath || ""} />}
</li>
);
})}
<div ref={scrollRef} />
</main>
);
}
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
images: {
domains: ["localhost"],
},
};
module.exports = nextConfig;
- io.on("message")를 통해 전달받은 이미지를 ChatContent.tsx의 next/image 컴포넌트를 활용하여 보여줍니다.
- next/image에 외부 이미지를 활용하기 위해선, next.config.js에서 외부 이미지 경로를 설정해줘야 합니다.
- 따라서 domains:["localhost"]와 같이 외부에서 받아오는 이미지 경로를 저장합니다.
한글의 경우 onKeyDown를 통해 서버로 메세지를 전송하면,
마지막 글자가 한번 더 전송되는 오류를 발견했습니다.
구글링을 통해 확인한 결과,
한글 입력 시 글자 아래 검은 밑줄이 있는 상황에서 키보드 이벤트가 발생하면,
이벤트헨들러가 두번 호출되어 발생되는 문제라 판단되었습니다.
해당 오류를 해결하기 위해선 키보드 이벤트의 isComposing메서드를 활용할 수 있습니다.
MDN URL
isComposing가 false인 경우는 이벤트세션이 종료되는것으로 판단되므로,
false인 경우에만 메세지를 전송하면 해당 오류를 해결 가능합니다.
const messageEnterKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter" && !event.shiftKey && event.nativeEvent.isComposing === false) {
event.preventDefault();
onMessageSubmit();
}
};
전체소스코드는 아래의 저장소에서 확인 가능합니다.
깃허브 저장소 이동