npx create-next-app@latest --js ui一路选NO,然后进入UI目录,在项目中添加Tailwind CSS:
npm install -D tailwindcss postcss autoprefixer npx tailwindcss init -p现在,修改Tailwind配置文件:ui/tailwind.config.js
module.exports = { content: [ "./pages/**/*.{js,ts,jsx,tsx}", "./components/**/*.{js,ts,jsx,tsx}", ], theme: { extend: {}, }, plugins: [], }我们会修改ui/package.json配置文件,将Node.js应用程序导出为静态HTML页面,以便我们可以使用Actix Web通过文件服务器访问它们:
{ "name": "ui", "version": "0.1.0", "private": true, "scripts": { "dev": "next dev", "build": "next build && next export -o ../static", "start": "next start", "lint": "next lint" }, "dependencies": { "@headlessui/react": "^1.7.4", "next": "13.0.4", "react": "18.2.0", "react-dom": "18.2.0" }, "devDependencies": { "autoprefixer": "^10.4.13", "postcss": "^8.4.19", "tailwindcss": "^3.2.4" } }接下来,将Tailwind CSS工具导入到ui/styles/global.css文件中:
@tailwind base; @tailwind components; @tailwind utilities;现在,让我们为客户端应用程序创建一些组件。
function getShortName(full_name = '') { if (full_name.includes(" ")) { const names = full_name.split(" "); return `${names[0].charAt(0)}${names[1].charAt(0)}`.toUpperCase() } return `${full_name.slice(0,2)}`.toUpperCase() } export default function Avatar({ children, color = '' }) { return ( <div className='bg-blue-500 w-[45px] h-[45px] flex items-center justify-center rounded-full' style={{backgroundColor: color}}> <span className='font-bold text-sm text-white'>{getShortName(children)}</span> </div> ) }登录组件
import { useState } from "react"; async function createAccount({ username, phone }) { try { // 堆代码 duidaima.com const url = "http://localhost:8080/users/create"; let result = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, phone }) }); return result.json(); } catch (e) { return Promise.reject(e); } } async function signIn({ phone }) { try { const url = "http://localhost:8080/users/phone/" + phone; let result = await fetch(url); return result.json(); } catch (e) { return Promise.reject(e); } } export default function Login({ show, setAuth }) { const [isShowSigIn, setShowSignIn] = useState(false); const showSignIn = () => { setShowSignIn(prev => !prev) } const FormCreateUsername = ({ setAuth }) => { const onCreateUsername = async (e) => { e.preventDefault(); let username = e.target.username.value; let phone = e.target.phone.value; if (username === "" || phone === "") { return; } let res = await createAccount({ username, phone }); if (res === null) { alert("Failed to create account"); return; } setAuth(res) } return ( <form action="" className="mt-4 space-y-2" onSubmit={onCreateUsername}> <div> <label className="text-sm font-light">Username</label> <input required type="text" name="username" placeholder="John Doe" className="w-full px-4 py-2 mt-2 border rounded-md focus:outline-none focus:ring-1 focus:ring-blue-600" /> </div> <div> <label className="text-sm font-light">Phone</label> <input required type="text" name="phone" placeholder="+1111..." className="w-full px-4 py-2 mt-2 border rounded-md focus:outline-none focus:ring-1 focus:ring-blue-600" /> </div> <div className="flex items-baseline justify-between"> <button type="submit" className="px-6 py-2 mt-4 text-white bg-violet-600 rounded-lg hover:bg-violet-700 w-full">Submit</button> </div> <div className="pt-2 space-y-2 text-center"> <p className="text-base text-gray-700">Already have a username? <button onClick={showSignIn} className="text-violet-700 font-light">Sign In</button></p> </div> </form> ) } const FormSignIn = ({ setAuth }) => { const onSignIn = async (e) => { e.preventDefault(); let phone = e.target.phone.value; if (phone === "") { return; } let res = await signIn({ phone }); if (res === null) { alert("Failed to create account"); return; } if (!res.id) { alert(`Phone number not found ${phone}`); return; } setAuth(res) } return ( <form action="" className="mt-4 space-y-2" onSubmit={onSignIn}> <div> <label className="text-sm font-light">Phone</label> <input required type="text" name="phone" placeholder="+1111..." className="w-full px-4 py-2 mt-2 border rounded-md focus:outline-none focus:ring-1 focus:ring-blue-600" /> </div> <div className="flex items-baseline justify-between"> <button type="submit" className="px-6 py-2 mt-4 text-white bg-violet-600 rounded-lg hover:bg-violet-700 w-full">Submit</button> </div> <div className="pt-2 space-y-2 text-center"> <p className="text-base text-gray-700">Don't have username? <button onClick={showSignIn} className="text-violet-700 font-light">Create</button></p> </div> </form> ) } return ( <div className={`${show ? '' : 'hidden'} bg-gradient-to-b from-orange-400 to-rose-400`}> <div className="flex items-center justify-center min-h-screen"> <div className="px-8 py-6 mt-4 text-left bg-white max-w-[400px] w-full rounded-xl shadow-lg"> <h3 className="text-xl text-slate-800 font-semibold">{isShowSigIn ? 'Log in with your phone.' : 'Create your account.'}</h3> {isShowSigIn ? <FormSignIn setAuth={setAuth} /> : <FormCreateUsername setAuth={setAuth} />} </div> </div> </div> ) }聊天室组件
import React, { useState, useEffect } from "react"; import Avatar from "./avatar"; // 堆代码 duidaima.com async function getRooms() { try { const url = "http://localhost:8080/rooms"; let result = await fetch(url); return result.json(); } catch (e) { console.log(e); return Promise.resolve(null); } } function ChatListItem({ onSelect, room, userId, index, selectedItem }) { const { users, created_at, last_message } = room; const active = index == selectedItem; const date = new Date(created_at); const ampm = date.getHours() >= 12 ? 'PM' : 'AM'; const time = `${date.getHours()}:${date.getMinutes()} ${ampm}` const name = users?.filter(user => user.id != userId).map(user => user.username)[0]; return ( <div onClick={() => onSelect(index, {})} className={`${active ? 'bg-[#FDF9F0] border border-[#DEAB6C]' : 'bg-[#FAF9FE] border border-[#FAF9FE]'} p-2 rounded-[10px] shadow-sm cursor-pointer`} > <div className='flex justify-between items-center gap-3'> <div className='flex gap-3 items-center w-full'> <Avatar>{name}</Avatar> <div className="w-full max-w-[150px]"> <h3 className='font-semibold text-sm text-gray-700'>{name}</h3> <p className='font-light text-xs text-gray-600 truncate'>{last_message}</p> </div> </div> <div className='text-gray-400 min-w-[55px]'> <span className='text-xs'>{time}</span> </div> </div> </div> ) } export default function ChatList({ onChatChange, userId }) { const [data, setData] = useState([]) const [isLoading, setLoading] = useState(false) const [selectedItem, setSelectedItem] = useState(-1); useEffect(() => { setLoading(true) getRooms() .then((data) => { setData(data) setLoading(false) }) }, []) const onSelectedChat = (idx, item) => { setSelectedItem(idx) let mapUsers = new Map(); item.users.forEach(el => { mapUsers.set(el.id, el); }); const users = { get: (id) => { return mapUsers.get(id).username; }, get_target_user: (id) => { return item.users.filter(el => el.id != id).map(el => el.username).join("") } } onChatChange({ ...item.room, users }) } return ( <div className="overflow-hidden space-y-3"> {isLoading && <p>Loading chat lists.</p>} { data.map((item, index) => { return <ChatListItem onSelect={(idx) => onSelectedChat(idx, item)} room={{ ...item.room, users: item.users }} index={index} key={item.room.id} userId={userId} selectedItem={selectedItem} /> }) } </div> ) }对话组件
import React, { useEffect, useRef } from "react"; import Avatar from "./avatar" function ConversationItem({ right, content, username }) { if (right) { return ( <div className='w-full flex justify-end'> <div className='flex gap-3 justify-end'> <div className='max-w-[65%] bg-violet-500 p-3 text-sm rounded-xl rounded-br-none'> <p className='text-white'>{content}</p> </div> <div className='mt-auto'> <Avatar>{username}</Avatar> </div> </div> </div> ) } return ( <div className='flex gap-3 w-full'> <div className='mt-auto'> <Avatar color='rgb(245 158 11)'>{username}</Avatar> </div> <div className='max-w-[65%] bg-gray-200 p-3 text-sm rounded-xl rounded-bl-none'> <p>{content}</p> </div> </div> ) } export default function Conversation({ data, auth, users }) { const ref = useRef(null); useEffect(() => { ref.current?.scrollTo(0, ref.current.scrollHeight) }, [data]); return ( <div className='p-4 space-y-4 overflow-auto' ref={ref}> { data.map(item => { return <ConversationItem right={item.user_id === auth.id} content={item.content} username={users.get(item.user_id)} key={item.id} /> }) } </div> ) }现在让我们编写与WebSocket服务器和REST API服务器交互所需的hook。
import { useEffect, useRef } from "react"; export default function useWebsocket(onMessage) { const ws = useRef(null); useEffect(() => { if (ws.current !== null) return; const wsUri = 'ws://localhost:8080/ws'; ws.current = new WebSocket(wsUri); ws.current.onopen = () => console.log("ws opened"); ws.current.onclose = () => console.log("ws closed"); const wsCurrent = ws.current; return () => { wsCurrent.close(); }; }, []); useEffect(() => { if (!ws.current) return; ws.current.onmessage = e => { onMessage(e.data) }; }, []); const sendMessage = (msg) => { if (!ws.current) return; ws.current.send(msg); } return sendMessage; }useLocalStorage Hook
import { useEffect, useState } from "react"; export default function useLocalStorage(key, defaultValue) { const [storedValue, setStoredValue] = useState(defaultValue); const setValue = (value) => { try { const valueToStore = value instanceof Function ? value(storedValue) : value; setStoredValue(valueToStore); if (typeof window !== "undefined") { window.localStorage.setItem(key, JSON.stringify(valueToStore)); } } catch (error) { } }; useEffect(() => { try { const item = window.localStorage.getItem(key); let data = item ? JSON.parse(item) : defaultValue; setStoredValue(data) } catch (error) {} }, []) return [storedValue, setValue]; }useConversation Hook
import { useEffect, useState } from "react"; const fetchRoomData = async (room_id) => { if (!room_id) return; const url = `http://localhost:8080/conversations/${room_id}`; try { let resp = await fetch(url).then(res => res.json()); return resp; } catch (e) { console.log(e); } } export default function useConversations(room_id) { const [isLoading, setIsLoading] = useState(true); const [messages, setMessages] = useState([]); const updateMessages = (resp = []) => { setIsLoading(false); setMessages(resp) } const fetchConversations = (id) => { setIsLoading(true) fetchRoomData(id).then(updateMessages) } useEffect(() => fetchConversations(room_id), []); return [isLoading, messages, setMessages, fetchConversations]; }
import Head from 'next/head' import React, { useEffect, useState } from 'react' import Avatar from '../components/avatar' import ChatList from '../components/room' import Conversation from '../components/conversation' import Login from '../components/login' import useConversations from '../libs/useConversation' import useLocalStorage from '../libs/useLocalStorage' import useWebsocket from '../libs/useWebsocket'现在,让我们为聊天页面设置状态:
export default function Home() { const [room, setSelectedRoom] = useState(null); const [isTyping, setIsTyping] = useState(false); const [showLogIn, setShowLogIn] = useState(false); const [auth, setAuthUser] = useLocalStorage("user", false); const [isLoading, messages, setMessages, fetchConversations] = useConversations(""); ... }下面的函数将处理所有进出WebSocket服务器的消息:
export default function Home() { ... const handleTyping = (mode) => { if (mode === "IN") { setIsTyping(true) } else { setIsTyping(false) } } const handleMessage = (msg, userId) => { setMessages(prev => { const item = { content: msg, user_id: userId }; return [...prev, item]; }) } const onMessage = (data) => { try { let messageData = JSON.parse(data); switch (messageData.chat_type) { case "TYPING": { handleTyping(messageData.value[0]); return; } case "TEXT": { handleMessage(messageData.value[0], messageData.user_id); return; } } } catch (e) { console.log(e); } } const sendMessage = useWebsocket(onMessage) const updateFocus = () => { const data = { id: 0, chat_type: "TYPING", value: ["IN"], room_id: room.id, user_id: auth.id } sendMessage(JSON.stringify(data)) } const onFocusChange = () => { const data = { id: 0, chat_type: "TYPING", value: ["OUT"], room_id: room.id, user_id: auth.id } sendMessage(JSON.stringify(data)) } const submitMessage = (e) => { e.preventDefault(); let message = e.target.message.value; if (message === "") { return; } if (!room.id) { alert("Please select chat room!") return } const data = { id: 0, chat_type: "TEXT", value: [message], room_id: room.id, user_id: auth.id } sendMessage(JSON.stringify(data)) e.target.message.value = ""; handleMessage(message, auth.id); onFocusChange(); } ...... }handleTyping:更新状态以显示键入指示器
export default function Home() { ...... const updateMessages = (data) => { if (!data.id) return; fetchConversations(data.id) setSelectedRoom(data) } const signOut = () => { window.localStorage.removeItem("user"); setAuthUser(false); } useEffect(() => setShowLogIn(!auth), [auth]) ...... }现在,让我们将所有数据显示给客户端:
export default function Home() { ...... return ( <div> <Head> <title>Rust with react chat app</title> <meta name="description" content="Rust with react chat app" /> <link rel="icon" href="/favicon.ico" /> </Head> <Login show={showLogIn} setAuth={setAuthUser} /> <div className={`${!auth && 'hidden'} bg-gradient-to-b from-orange-400 to-rose-400 h-screen p-12`}> <main className='flex w-full max-w-[1020px] h-[700px] mx-auto bg-[#FAF9FE] rounded-[25px] backdrop-opacity-30 opacity-95'> <aside className='bg-[#F0EEF5] w-[325px] h-[700px] rounded-l-[25px] p-4 overflow-auto relative'> <ChatList onChatChange={updateMessages} userId={auth.id} /> <button onClick={signOut} className='text-xs w-full max-w-[295px] p-3 rounded-[10px] bg-violet-200 font-semibold text-violet-600 text-center absolute bottom-4'>LOG OUT</button> </aside> {room?.id && (<section className='rounded-r-[25px] w-full max-w-[690px] grid grid-rows-[80px_minmax(450px,_1fr)_65px]'> <div className='rounded-tr-[25px] w-ful'> <div className='flex gap-3 p-3 items-center'> <Avatar color='rgb(245 158 11)'>{room.users.get_target_user(auth.id)}</Avatar> <div> <p className='font-semibold text-gray-600 text-base'>{room.users.get_target_user(auth.id)}</p> <div className='text-xs text-gray-400'>{isTyping ? "Typing..." : "10:15 AM"}</div> </div> </div> <hr className='bg-[#F0EEF5]' /> </div> {(isLoading && room.id) && <p className="px-4 text-slate-500">Loading conversation...</p>} <Conversation data={messages} auth={auth} users={room.users} /> <div className='w-full'> <form onSubmit={submitMessage} className='flex gap-2 items-center rounded-full border border-violet-500 bg-violet-200 p-1 m-2'> <input onBlur={onFocusChange} onFocus={updateFocus} name="message" className='p-2 placeholder-gray-600 text-sm w-full rounded-full bg-violet-200 focus:outline-none' placeholder='Type your message here...' /> <button type='submit' className='bg-violet-500 rounded-full py-2 px-6 font-semibold text-white text-sm'>Sent</button> </form> </div> </section>)} </main> </div> </div> ) ...... }运行聊天应用
cargo run Finished dev [unoptimized + debuginfo] target(s) in 1.16s Running `target/debug/rust-react-chat` Server running at http://127.0.0.1:8080/启动Web端
npm run dev - ready started server on 0.0.0.0:3000, url: http://localhost:3000在浏览器中输入:http://localhost:3000