» React创建在线聊天软件 Web Chat App 前端 » 2. 开发 » 2.4 一对一聊天

一对一聊天

联系人列表

选择一个随机 emoji,src/components/login-form.jsx:

@@ -29,7 +29,8 @@ const emojis = [
   "🦄", // Unicorn
 ];
 const LoginForm = (props) => {
-  const [emoji, setEmoji] = useState("");
+  const rndEmoji = emojis[Math.floor(emojis.length * Math.random())];
+  const [emoji, setEmoji] = useState(rndEmoji);
   const [name, setName] = useState("");
   const login = () => {
     if (!emoji) {

调整 src/components/contact.jsx:

@@ -4,9 +4,14 @@ import clsx from "clsx";
 
 const Contact = (props) => {
   return (
-    <div className={clsx("contact", { offline: props.isOffline })}>
+    <div
+      className={clsx("contact", { offline: props.isOffline })}
+      onClick={props.onClick}
+    >
       <div className="name truncate">{props.username}</div>
-      <div className="last-message truncate">{props.message}</div>
+      <div className="last-message truncate">
+        {props.message || "[no messages]"}
+      </div>
     </div>
   );
 };

调整 src/components/Contact.css:

@@ -4,6 +4,7 @@
   padding: 6px 10px;
   border-bottom: solid 1px var(--theme-color);
   text-align: left;
+  cursor: pointer;
 }
 
 .name {

从 socket.io 服务端接收实时联系人列表。

更新 src/App.js:

@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import "./App.css";
import Contact from "./components/contact";
import Message from "./components/message";
-import TitleBar from "./components/ttile-bar";
+import TitleBar from "./components/title-bar";
import LoginForm from "./components/login-form";
import clsx from "clsx";
import io from "socket.io-client";
@@ -11,6 +11,8 @@ function App() {
  const [logged, setLogged] = useState(false);
  const [user, setUser] = useState({ emoji: "", name: "" });
  const [conn, setConn] = useState(null);
+  const [contacts, setContacts] = useState([]);
+  const [pickedContact, setPickedContact] = useState(null);
  const messages = [
    {
      username: "Alice",
@@ -29,31 +31,19 @@ function App() {
      message: "Sure",
    },
  ];
-  const contacts = [
-    {
-      username: "🦁 Alice",
-      message: "Sure",
-    },
-    {
-      username: "♣ Cindy",
-      message: "Where r u",
-    },
-    {
-      username: "🐯 Doug Smith",
-      message: "Hi",
-    },
-    {
-      username: "🐴 Emily",
-      message: "How's your assignment? I didn't do much yesterday",
-    },
-  ];
  useEffect(() => {
+    if (!user.name) return;
    const socket = io("http://localhost:4000");
    socket.on("error", (error) => {
      console.error("Socket error:", error);
    });
+    socket.on("contacts", (serialUsers) => {
+      const users = new Map(serialUsers);
+      setContacts([...users.values()].filter((e) => e.sid !== socket.id));
+    });
+    socket.emit("user-join", user);
    setConn(socket);
-  }, []);
+  }, [user]);

  const login = (emoji, name) => {
    setUser({ emoji, name });
@@ -76,30 +66,42 @@ function App() {
            <div className="contacts">
              {contacts.map((e) => (
                <Contact
-                  username={e.username}
+                  key={e.sid}
+                  username={e.emoji + " " + e.name}
                  message={e.message}
-                  isOffline={e.username.includes("Emily")}
+                  onClick={() => {
+                    setPickedContact(e);
+                  }}
                />
              ))}
            </div>
            <div className="main">
-              <TitleBar username={contacts[0].username} />
-              <div className="messages">
-                {messages.map((e) => (
-                  <Message
-                    username={e.username}
-                    message={e.message}
-                    isSelf={e.username === "Bob"}
+              {pickedContact ? (
+                <>
+                  <TitleBar
+                    username={pickedContact.emoji + " " + pickedContact.name}
                  />
-                ))}
-              </div>
-              <div className="edit">
-                <textarea className="edit-box" placeholder="Type here" />
-                <div className="buttons">
-                  <button className="send-btn">Send</button>
-                  <span className="tip">Ctrl+Enter to send</span>
-                </div>
-              </div>
+                  <div className="messages">
+                    {messages.map((e) => (
+                      <Message
+                        key={e.message}
+                        username={e.username}
+                        message={e.message}
+                        isSelf={e.username === "Bob"}
+                      />
+                    ))}
+                  </div>
+                  <div className="edit">
+                    <textarea className="edit-box" placeholder="Type here" />
+                    <div className="buttons">
+                      <button className="send-btn">Send</button>
+                      <span className="tip">Ctrl+Enter to send</span>
+                    </div>
+                  </div>
+                </>
+              ) : (
+                <div className="brand">Literank</div>
+              )}
            </div>
          </div>
          <div className="status">

调整风格,src/App.css:

@@ -60,11 +60,13 @@ body {

.contacts {
  flex: 1;
+  overflow-y: auto;
+  max-height: var(--max-h);
}

.main {
  flex: 4;
-  height: 60vh;
+  height: var(--max-h);
  background-color: whitesmoke;
  border-top-right-radius: 0.5rem;
  border-bottom-right-radius: 0.5rem;
@@ -72,6 +74,16 @@ body {
  flex-direction: column;
}

+.brand {
+  color: rgba(0, 0, 0, 0.08);
+  user-select: none;
+  font-size: 3em;
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
.main .messages {
  flex: 6;
  padding: 10px;

添加新的变量,src/vars.css:

@@ -3,4 +3,5 @@
  --text-color: #ffffff;
  --theme-color: #005b41;
  --secondary-color: #232d3f;
+  --max-h: 60vh;
}

修复风格问题

调整 src/components/login-form.jsx:

@@ -77,7 +77,7 @@ const LoginForm = (props) => {
           />
         </div>
       </div>
-      <div className="buttons">
+      <div className="login-buttons">
         <button className="send-btn" onClick={login}>
           Login
         </button>

调整 src/components/Login.css:

@@ -35,6 +35,6 @@
   text-align: center;
 }
 
-.buttons {
+.login-buttons {
   margin-top: 2em;
 }

聊天消息

限制消息气泡大小,src/components/Message.css:

@@ -7,6 +7,8 @@

.message {
  width: max-content;
+  margin-bottom: 0.2em;
+  max-width: 25em;
}

.self-side {

修改 src/App.js:

diff --git a/src/App.js b/src/App.js
index ed953c2..1faa05a 100644
--- a/src/App.js
+++ b/src/App.js
@@ -1,4 +1,4 @@
-import { useEffect, useState } from "react";
+import { useEffect, useRef, useState } from "react";
import "./App.css";
import Contact from "./components/contact";
import Message from "./components/message";
@@ -12,25 +12,12 @@ function App() {
  const [user, setUser] = useState({ emoji: "", name: "" });
  const [conn, setConn] = useState(null);
  const [contacts, setContacts] = useState([]);
+  const [contactMessages, setContactMessages] = useState({});
  const [pickedContact, setPickedContact] = useState(null);
-  const messages = [
-    {
-      username: "Alice",
-      message: "Hello there",
-    },
-    {
-      username: "Bob",
-      message: "How's everything?",
-    },
-    {
-      username: "Alice",
-      message: "Great! Wanna have a drink?",
-    },
-    {
-      username: "Bob",
-      message: "Sure",
-    },
-  ];
+  const [typedContent, setTypedContent] = useState("");
+  const [messages, setMessages] = useState([]);
+  const resultEndRef = useRef(null);
+
  useEffect(() => {
    if (!user.name) return;
    const socket = io("http://localhost:4000");
@@ -41,14 +28,38 @@ function App() {
      const users = new Map(serialUsers);
      setContacts([...users.values()].filter((e) => e.sid !== socket.id));
    });
+    socket.on("chat", (data) => {
+      const { from, msg } = data;
+      setMessages((m) => [...m, { name: "", message: msg, isSelf: false }]);
+      setContactMessages((m) => {
+        return { ...m, [from]: msg };
+      });
+    });
    socket.emit("user-join", user);
    setConn(socket);
  }, [user]);

+  useEffect(() => {
+    if (messages.length > 0)
+      resultEndRef.current?.scrollIntoView({ behavior: "smooth" });
+  }, [messages]);
+
  const login = (emoji, name) => {
    setUser({ emoji, name });
    setLogged(true);
  };
+  const chat = (toSid, message) => {
+    if (!conn) {
+      return;
+    }
+    setMessages((m) => [...m, { name: user.name, message, isSelf: true }]);
+    conn.emit("chat", { to: toSid, from: conn.id, msg: message });
+    setContactMessages((m) => {
+      return { ...m, [toSid]: message };
+    });
+    setTypedContent("");
+  };
  return (
    <div className="app">
      <h1 className={clsx("app-name", { "center-name": !logged })}>
@@ -68,9 +79,10 @@ function App() {
                <Contact
                  key={e.sid}
                  username={e.emoji + " " + e.name}
-                  message={e.message}
+                  message={contactMessages[e.sid] || ""}
                  onClick={() => {
                    setPickedContact(e);
+                    setMessages([]);
                  }}
                />
              ))}
@@ -82,19 +94,37 @@ function App() {
                    username={pickedContact.emoji + " " + pickedContact.name}
                  />
                  <div className="messages">
-                    {messages.map((e) => (
+                    {messages.map((e, i) => (
                      <Message
-                        key={e.message}
-                        username={e.username}
+                        key={i}
+                        username={e.isSelf ? user.name : pickedContact.name}
                        message={e.message}
-                        isSelf={e.username === "Bob"}
+                        isSelf={e.isSelf}
                      />
                    ))}
+                    <div ref={resultEndRef}></div>
                  </div>
                  <div className="edit">
-                    <textarea className="edit-box" placeholder="Type here" />
+                    <textarea
+                      className="edit-box"
+                      placeholder="Type here"
+                      value={typedContent}
+                      onChange={(e) => setTypedContent(e.target.value)}
+                      onKeyUp={(e) => {
+                        if (e.ctrlKey && e.key === "Enter") {
+                          chat(pickedContact.sid, typedContent);
+                        }
+                      }}
+                    />
                    <div className="buttons">
-                      <button className="send-btn">Send</button>
+                      <button
+                        className="send-btn"
+                        onClick={() => {
+                          chat(pickedContact.sid, typedContent);
+                        }}
+                      >
+                        Send
+                      </button>
                      <span className="tip">Ctrl+Enter to send</span>
                    </div>
                  </div>
  • 使用 socket.on("chat", ...) 接收服务端的聊天消息。
  • 使用 socket.emit("chat", ...) 发送聊天消息到服务端。
  • 添加点击处理逻辑到 Send 按钮和 Ctrl+Enter 按键事件。
  • 添加 2 个 React State 来辅助渲染消息。

调整风格,src/App.css:

@@ -87,6 +87,7 @@ body {
 .main .messages {
   flex: 6;
   padding: 10px;
+  overflow-y: auto;
 }
 
 .main .edit {

现在你可以和朋友聊天啦。尝试进行一次对话:

Chat

多个对话

更新 src/App.js:

@@ -15,7 +15,6 @@ function App() {
   const [contactMessages, setContactMessages] = useState({});
   const [pickedContact, setPickedContact] = useState(null);
   const [typedContent, setTypedContent] = useState("");
-  const [messages, setMessages] = useState([]);
   const resultEndRef = useRef(null);
 
   useEffect(() => {
@@ -30,9 +29,11 @@ function App() {
     });
     socket.on("chat", (data) => {
       const { from, msg } = data;
-      setMessages((m) => [...m, { name: "", message: msg, isSelf: false }]);
-      setContactMessages((m) => {
-        return { ...m, [from]: msg };
+      const entry = { name: "", message: msg, isSelf: false };
+      setContactMessages((cm) => {
+        const oldMessages = cm[from] || [];
+        const newMessages = [...oldMessages, entry];
+        return { ...cm, [from]: newMessages };
       });
     });
     socket.emit("user-join", user);
@@ -40,25 +41,38 @@ function App() {
   }, [user]);
 
   useEffect(() => {
+    if (!pickedContact) return;
+    const messages = contactMessages[pickedContact.sid] || [];
     if (messages.length > 0)
       resultEndRef.current?.scrollIntoView({ behavior: "smooth" });
-  }, [messages]);
+  }, [contactMessages, pickedContact]);
 
   const login = (emoji, name) => {
     setUser({ emoji, name });
     setLogged(true);
   };
   const chat = (toSid, message) => {
-    if (!conn) {
+    if (!conn || !message.trim()) {
       return;
     }
-    setMessages((m) => [...m, { name: user.name, message, isSelf: true }]);
+    const entry = { name: user.name, message, isSelf: true };
     conn.emit("chat", { to: toSid, from: conn.id, msg: message });
-    setContactMessages((m) => {
-      return { ...m, [toSid]: message };
+    setContactMessages((cm) => {
+      const oldMessages = cm[toSid] || [];
+      const newMessages = [...oldMessages, entry];
+      return { ...cm, [toSid]: newMessages };
     });
     setTypedContent("");
   };
+  const lastMessage = (messages) => {
+    if (!messages) {
+      return "";
+    }
+    if (messages.length > 0) {
+      return messages[messages.length - 1].message;
+    }
+    return "";
+  };
   return (
     <div className="app">
       <h1 className={clsx("app-name", { "center-name": !logged })}>
@@ -78,10 +92,9 @@ function App() {
                 <Contact
                   key={e.sid}
                   username={e.emoji + " " + e.name}
-                  message={contactMessages[e.sid] || ""}
+                  message={lastMessage(contactMessages[e.sid])}
                   onClick={() => {
                     setPickedContact(e);
-                    setMessages([]);
                   }}
                 />
               ))}
@@ -93,7 +106,7 @@ function App() {
                     username={pickedContact.emoji + " " + pickedContact.name}
                   />
                   <div className="messages">
-                    {messages.map((e, i) => (
+                    {(contactMessages[pickedContact.sid] || []).map((e, i) => (
                       <Message
                         key={i}
                         username={e.isSelf ? user.name : pickedContact.name}

使用 contactMessages state 来保存所有对话消息。

上页下页