Feathers.js 实战项目:聊天应用 - 完整的实时通信系统
发布时间:2024-07-05
作者:一介布衣
标签:Feathers.js, 聊天应用, 实时通信, WebSocket, 项目实战
前言
经过前面十几篇文章的学习,相信大家对 Feathers.js 已经有了深入的了解。今天咱们来做一个完整的实战项目 - 构建一个功能丰富的聊天应用。说实话,聊天应用是学习实时通信最好的项目,它涵盖了用户管理、消息处理、文件上传、实时同步等各个方面。
我记得第一次做聊天应用的时候,觉得很简单:不就是发消息、收消息嘛。后来才发现,要做一个好用的聊天应用,需要考虑的东西太多了:消息状态、离线消息、文件传输、表情包、群聊管理、消息搜索等等。用 Feathers.js 来实现,正好可以体验它在实时应用方面的强大能力。
今天我就带大家从零开始,构建一个功能完整的聊天应用。
项目需求分析
核心功能
用户系统:
- 用户注册和登录
- 个人资料管理
- 在线状态显示
- 用户搜索和添加好友
聊天功能:
- 一对一私聊
- 群聊创建和管理
- 消息发送和接收
- 消息状态(已发送、已送达、已读)
- 离线消息处理
消息类型:
- 文本消息
- 图片和文件
- 表情包
- 语音消息
- 位置分享
高级功能:
- 消息搜索
- 聊天记录导出
- 消息撤回
- @提醒功能
- 消息加密
数据模型设计
1. 用户模型
javascript
// src/models/users.model.js
module.exports = function (app) {
const mongoClient = app.get('mongoClient');
mongoClient.then(db => {
const collection = db.collection('users');
collection.createIndex({ email: 1 }, { unique: true });
collection.createIndex({ username: 1 }, { unique: true });
collection.createIndex({ 'status.isOnline': 1 });
});
return mongoClient;
};
// 用户文档结构
const userSchema = {
_id: ObjectId,
email: String,
username: String,
password: String, // 加密后的密码
profile: {
displayName: String,
avatar: String,
bio: String,
phone: String
},
status: {
isOnline: Boolean,
lastSeen: Date,
currentActivity: String // 'typing', 'idle', 'away'
},
settings: {
notifications: {
sound: Boolean,
desktop: Boolean,
mobile: Boolean
},
privacy: {
showLastSeen: Boolean,
showOnlineStatus: Boolean,
allowStrangerMessages: Boolean
},
theme: String // 'light', 'dark', 'auto'
},
contacts: [{
userId: ObjectId,
addedAt: Date,
nickname: String,
isBlocked: Boolean
}],
createdAt: Date,
updatedAt: Date
};
2. 聊天室模型
javascript
// src/models/rooms.model.js
const roomSchema = {
_id: ObjectId,
name: String, // 群聊名称,私聊为空
description: String,
avatar: String,
type: String, // 'private', 'group', 'channel'
// 成员管理
members: [{
userId: ObjectId,
role: String, // 'owner', 'admin', 'member'
joinedAt: Date,
nickname: String,
permissions: [String] // 'send_messages', 'add_members', 'delete_messages'
}],
// 聊天室设置
settings: {
isPublic: Boolean,
allowInvites: Boolean,
maxMembers: Number,
messageRetention: Number, // 消息保留天数
allowFileUpload: Boolean,
allowVoiceMessages: Boolean
},
// 统计信息
stats: {
messageCount: Number,
memberCount: Number,
lastActivity: Date
},
// 最后一条消息
lastMessage: {
messageId: ObjectId,
content: String,
senderId: ObjectId,
timestamp: Date,
type: String
},
createdAt: Date,
updatedAt: Date,
createdBy: ObjectId
};
3. 消息模型
javascript
// src/models/messages.model.js
const messageSchema = {
_id: ObjectId,
roomId: ObjectId,
senderId: ObjectId,
// 消息内容
content: String,
type: String, // 'text', 'image', 'file', 'voice', 'location', 'system'
// 媒体文件信息
media: {
url: String,
filename: String,
size: Number,
mimeType: String,
duration: Number, // 语音消息时长
thumbnail: String // 图片缩略图
},
// 位置信息
location: {
latitude: Number,
longitude: Number,
address: String
},
// 回复消息
replyTo: {
messageId: ObjectId,
content: String,
senderId: ObjectId
},
// 转发信息
forwarded: {
originalMessageId: ObjectId,
originalSenderId: ObjectId,
forwardCount: Number
},
// 消息状态
status: {
sent: Date,
delivered: [{ userId: ObjectId, timestamp: Date }],
read: [{ userId: ObjectId, timestamp: Date }]
},
// 编辑历史
editHistory: [{
content: String,
editedAt: Date
}],
// 反应表情
reactions: [{
userId: ObjectId,
emoji: String,
timestamp: Date
}],
// 提醒用户
mentions: [ObjectId],
// 系统标记
isDeleted: Boolean,
deletedAt: Date,
deletedBy: ObjectId,
createdAt: Date,
updatedAt: Date
};
核心服务实现
1. 用户服务
javascript
// src/services/users/users.class.js
const { MongoDBService } = require('@feathersjs/mongodb');
const { ObjectId } = require('mongodb');
class UsersService extends MongoDBService {
constructor(options, app) {
super(options, app);
this.app = app;
}
async create(data, params) {
const userData = {
...data,
status: {
isOnline: false,
lastSeen: new Date(),
currentActivity: 'offline'
},
settings: {
notifications: {
sound: true,
desktop: true,
mobile: true
},
privacy: {
showLastSeen: true,
showOnlineStatus: true,
allowStrangerMessages: true
},
theme: 'light'
},
contacts: [],
createdAt: new Date(),
updatedAt: new Date()
};
return super.create(userData, params);
}
// 更新在线状态
async updateOnlineStatus(userId, isOnline, activity = 'online') {
const updateData = {
'status.isOnline': isOnline,
'status.lastSeen': new Date(),
'status.currentActivity': activity,
updatedAt: new Date()
};
await super.patch(userId, updateData);
// 广播状态变化给联系人
const user = await this.get(userId);
const contactIds = user.contacts.map(contact => contact.userId);
this.app.channel('authenticated').send({
type: 'user-status-changed',
userId: userId,
isOnline: isOnline,
activity: activity,
lastSeen: updateData['status.lastSeen']
});
return { success: true };
}
// 添加联系人
async addContact(userId, contactId, params) {
// 检查是否已经是联系人
const user = await this.get(userId);
const existingContact = user.contacts.find(
contact => contact.userId.toString() === contactId
);
if (existingContact) {
throw new Error('用户已经在联系人列表中');
}
// 添加联系人
await super.patch(userId, {
$push: {
contacts: {
userId: new ObjectId(contactId),
addedAt: new Date(),
nickname: '',
isBlocked: false
}
}
});
// 创建私聊房间
const room = await this.app.service('rooms').create({
type: 'private',
members: [
{
userId: new ObjectId(userId),
role: 'member',
joinedAt: new Date()
},
{
userId: new ObjectId(contactId),
role: 'member',
joinedAt: new Date()
}
],
settings: {
isPublic: false,
allowInvites: false,
maxMembers: 2
}
});
return { success: true, roomId: room._id };
}
// 搜索用户
async searchUsers(query, params) {
const { user } = params;
const pipeline = [
{
$match: {
$and: [
{ _id: { $ne: new ObjectId(user._id) } },
{
$or: [
{ username: { $regex: query, $options: 'i' } },
{ 'profile.displayName': { $regex: query, $options: 'i' } },
{ email: { $regex: query, $options: 'i' } }
]
}
]
}
},
{
$project: {
username: 1,
'profile.displayName': 1,
'profile.avatar': 1,
'status.isOnline': 1,
'status.lastSeen': 1
}
},
{ $limit: 20 }
];
const users = await this.Model.aggregate(pipeline).toArray();
return {
total: users.length,
data: users
};
}
// 获取联系人列表
async getContacts(userId, params) {
const user = await this.get(userId);
const contactIds = user.contacts
.filter(contact => !contact.isBlocked)
.map(contact => contact.userId);
if (contactIds.length === 0) {
return { total: 0, data: [] };
}
const contacts = await this.find({
query: {
_id: { $in: contactIds },
$select: [
'username',
'profile.displayName',
'profile.avatar',
'status.isOnline',
'status.lastSeen',
'status.currentActivity'
]
}
});
return contacts;
}
}
module.exports = UsersService;
2. 聊天室服务
javascript
// src/services/rooms/rooms.class.js
const { MongoDBService } = require('@feathersjs/mongodb');
const { ObjectId } = require('mongodb');
class RoomsService extends MongoDBService {
constructor(options, app) {
super(options, app);
this.app = app;
}
async find(params) {
const { user } = params;
// 只返回用户参与的聊天室
const pipeline = [
{
$match: {
'members.userId': new ObjectId(user._id)
}
},
{
$addFields: {
// 添加用户在此房间的角色
userRole: {
$arrayElemAt: [
{
$filter: {
input: '$members',
cond: { $eq: ['$$this.userId', new ObjectId(user._id)] }
}
},
0
]
}
}
},
{
$lookup: {
from: 'users',
localField: 'members.userId',
foreignField: '_id',
as: 'memberDetails',
pipeline: [
{
$project: {
username: 1,
'profile.displayName': 1,
'profile.avatar': 1,
'status.isOnline': 1
}
}
]
}
},
{
$addFields: {
// 为私聊生成显示名称
displayName: {
$cond: {
if: { $eq: ['$type', 'private'] },
then: {
$let: {
vars: {
otherMember: {
$arrayElemAt: [
{
$filter: {
input: '$memberDetails',
cond: { $ne: ['$$this._id', new ObjectId(user._id)] }
}
},
0
]
}
},
in: {
$ifNull: [
'$$otherMember.profile.displayName',
'$$otherMember.username'
]
}
}
},
else: '$name'
}
},
// 为私聊生成头像
displayAvatar: {
$cond: {
if: { $eq: ['$type', 'private'] },
then: {
$let: {
vars: {
otherMember: {
$arrayElemAt: [
{
$filter: {
input: '$memberDetails',
cond: { $ne: ['$$this._id', new ObjectId(user._id)] }
}
},
0
]
}
},
in: '$$otherMember.profile.avatar'
}
},
else: '$avatar'
}
}
}
},
{
$sort: { 'stats.lastActivity': -1 }
},
{
$project: {
memberDetails: 0 // 清理临时字段
}
}
];
const rooms = await this.Model.aggregate(pipeline).toArray();
return {
total: rooms.length,
data: rooms
};
}
async create(data, params) {
const { user } = params;
const roomData = {
...data,
stats: {
messageCount: 0,
memberCount: data.members ? data.members.length : 0,
lastActivity: new Date()
},
createdAt: new Date(),
updatedAt: new Date(),
createdBy: new ObjectId(user._id)
};
const room = await super.create(roomData, params);
// 让所有成员加入实时频道
room.members.forEach(member => {
this.app.channel(`user-${member.userId}`).join(
this.app.channel(`room-${room._id}`)
);
});
return room;
}
// 添加成员
async addMember(roomId, userId, params) {
const { user } = params;
const room = await this.get(roomId);
// 检查权限
const userMember = room.members.find(
member => member.userId.toString() === user._id
);
if (!userMember || !['owner', 'admin'].includes(userMember.role)) {
throw new Error('无权限添加成员');
}
// 检查是否已经是成员
const existingMember = room.members.find(
member => member.userId.toString() === userId
);
if (existingMember) {
throw new Error('用户已经是群成员');
}
// 检查群成员上限
if (room.members.length >= room.settings.maxMembers) {
throw new Error('群成员已达上限');
}
// 添加成员
await super.patch(roomId, {
$push: {
members: {
userId: new ObjectId(userId),
role: 'member',
joinedAt: new Date(),
permissions: ['send_messages']
}
},
$inc: { 'stats.memberCount': 1 },
updatedAt: new Date()
});
// 发送系统消息
await this.app.service('messages').create({
roomId: new ObjectId(roomId),
senderId: new ObjectId(user._id),
type: 'system',
content: `${user.profile?.displayName || user.username} 邀请了新成员加入群聊`,
createdAt: new Date()
});
// 让新成员加入实时频道
this.app.channel(`user-${userId}`).join(
this.app.channel(`room-${roomId}`)
);
return { success: true };
}
// 离开群聊
async leaveRoom(roomId, params) {
const { user } = params;
const room = await this.get(roomId);
const memberIndex = room.members.findIndex(
member => member.userId.toString() === user._id
);
if (memberIndex === -1) {
throw new Error('您不是群成员');
}
const member = room.members[memberIndex];
// 如果是群主,需要转让群主权限
if (member.role === 'owner' && room.members.length > 1) {
// 找到下一个管理员或成员作为新群主
const newOwner = room.members.find(m =>
m.userId.toString() !== user._id && m.role === 'admin'
) || room.members.find(m =>
m.userId.toString() !== user._id
);
if (newOwner) {
await super.patch(roomId, {
$set: {
'members.$[owner].role': 'owner'
}
}, {
arrayFilters: [{ 'owner.userId': newOwner.userId }]
});
}
}
// 移除成员
await super.patch(roomId, {
$pull: { members: { userId: new ObjectId(user._id) } },
$inc: { 'stats.memberCount': -1 },
updatedAt: new Date()
});
// 如果是最后一个成员,删除群聊
if (room.members.length === 1) {
await super.remove(roomId);
} else {
// 发送系统消息
await this.app.service('messages').create({
roomId: new ObjectId(roomId),
senderId: new ObjectId(user._id),
type: 'system',
content: `${user.profile?.displayName || user.username} 离开了群聊`,
createdAt: new Date()
});
}
// 离开实时频道
this.app.channel(`user-${user._id}`).leave(
this.app.channel(`room-${roomId}`)
);
return { success: true };
}
// 更新最后消息
async updateLastMessage(roomId, message) {
await super.patch(roomId, {
lastMessage: {
messageId: message._id,
content: message.content,
senderId: message.senderId,
timestamp: message.createdAt,
type: message.type
},
'stats.lastActivity': new Date(),
updatedAt: new Date()
});
}
}
module.exports = RoomsService;
3. 消息服务
javascript
// src/services/messages/messages.class.js
const { MongoDBService } = require('@feathersjs/mongodb');
const { ObjectId } = require('mongodb');
class MessagesService extends MongoDBService {
constructor(options, app) {
super(options, app);
this.app = app;
}
async find(params) {
const { query = {} } = params;
const { roomId } = query;
if (!roomId) {
throw new Error('roomId 是必需的');
}
// 验证用户是否有权限访问此聊天室
const room = await this.app.service('rooms').get(roomId);
const isMember = room.members.some(
member => member.userId.toString() === params.user._id
);
if (!isMember) {
throw new Error('无权限访问此聊天室');
}
const pipeline = [
{
$match: {
roomId: new ObjectId(roomId),
isDeleted: { $ne: true }
}
},
{
$lookup: {
from: 'users',
localField: 'senderId',
foreignField: '_id',
as: 'sender',
pipeline: [
{
$project: {
username: 1,
'profile.displayName': 1,
'profile.avatar': 1
}
}
]
}
},
{
$addFields: {
sender: { $arrayElemAt: ['$sender', 0] }
}
},
{
$sort: { createdAt: -1 }
}
];
// 分页
if (query.$skip) {
pipeline.push({ $skip: parseInt(query.$skip) });
}
if (query.$limit) {
pipeline.push({ $limit: parseInt(query.$limit) });
}
const messages = await this.Model.aggregate(pipeline).toArray();
return {
total: messages.length,
data: messages.reverse() // 按时间正序返回
};
}
async create(data, params) {
const { user } = params;
// 验证用户是否有权限发送消息
const room = await this.app.service('rooms').get(data.roomId);
const member = room.members.find(
m => m.userId.toString() === user._id
);
if (!member) {
throw new Error('您不是此聊天室的成员');
}
if (!member.permissions.includes('send_messages')) {
throw new Error('您没有发送消息的权限');
}
const messageData = {
...data,
senderId: new ObjectId(user._id),
roomId: new ObjectId(data.roomId),
status: {
sent: new Date(),
delivered: [],
read: []
},
reactions: [],
editHistory: [],
isDeleted: false,
createdAt: new Date(),
updatedAt: new Date()
};
// 处理回复消息
if (data.replyTo) {
const originalMessage = await this.get(data.replyTo.messageId);
messageData.replyTo = {
messageId: new ObjectId(data.replyTo.messageId),
content: originalMessage.content.substring(0, 100),
senderId: originalMessage.senderId
};
}
// 处理提醒
if (data.mentions && data.mentions.length > 0) {
messageData.mentions = data.mentions.map(id => new ObjectId(id));
}
const message = await super.create(messageData, params);
// 更新聊天室统计
await this.app.service('rooms').patch(data.roomId, {
$inc: { 'stats.messageCount': 1 }
});
// 更新聊天室最后消息
await this.app.service('rooms').updateLastMessage(data.roomId, message);
// 标记消息为已送达(给在线用户)
await this.markAsDelivered(message._id, room.members);
return message;
}
async patch(id, data, params) {
const { user } = params;
const message = await this.get(id);
// 只有发送者可以编辑消息
if (message.senderId.toString() !== user._id) {
throw new Error('只能编辑自己的消息');
}
// 检查编辑时间限制(15分钟内)
const editTimeLimit = 15 * 60 * 1000;
if (Date.now() - new Date(message.createdAt).getTime() > editTimeLimit) {
throw new Error('消息编辑时间已过期');
}
const updateData = {
...data,
updatedAt: new Date()
};
// 保存编辑历史
if (data.content && data.content !== message.content) {
updateData.$push = {
editHistory: {
content: message.content,
editedAt: new Date()
}
};
}
return super.patch(id, updateData, params);
}
async remove(id, params) {
const { user } = params;
const message = await this.get(id);
// 检查权限
const room = await this.app.service('rooms').get(message.roomId);
const member = room.members.find(
m => m.userId.toString() === user._id
);
const canDelete = message.senderId.toString() === user._id ||
['owner', 'admin'].includes(member?.role);
if (!canDelete) {
throw new Error('无权限删除此消息');
}
// 软删除
await super.patch(id, {
isDeleted: true,
deletedAt: new Date(),
deletedBy: new ObjectId(user._id)
});
return { success: true };
}
// 标记消息为已读
async markAsRead(messageIds, params) {
const { user } = params;
if (!Array.isArray(messageIds)) {
messageIds = [messageIds];
}
const readTimestamp = new Date();
await this.Model.updateMany(
{
_id: { $in: messageIds.map(id => new ObjectId(id)) },
'status.read.userId': { $ne: new ObjectId(user._id) }
},
{
$push: {
'status.read': {
userId: new ObjectId(user._id),
timestamp: readTimestamp
}
}
}
);
return { success: true, readAt: readTimestamp };
}
// 标记消息为已送达
async markAsDelivered(messageId, members) {
const onlineMembers = await this.getOnlineMembers(members);
if (onlineMembers.length === 0) return;
const deliveredUpdates = onlineMembers.map(member => ({
userId: member.userId,
timestamp: new Date()
}));
await super.patch(messageId, {
$push: {
'status.delivered': { $each: deliveredUpdates }
}
});
}
// 获取在线成员
async getOnlineMembers(members) {
const memberIds = members.map(m => m.userId);
const onlineUsers = await this.app.service('users').find({
query: {
_id: { $in: memberIds },
'status.isOnline': true,
$select: ['_id']
}
});
return onlineUsers.data.map(user => ({ userId: user._id }));
}
// 添加反应
async addReaction(messageId, emoji, params) {
const { user } = params;
// 检查是否已经有相同反应
const message = await this.get(messageId);
const existingReaction = message.reactions.find(
r => r.userId.toString() === user._id && r.emoji === emoji
);
if (existingReaction) {
// 移除反应
await super.patch(messageId, {
$pull: {
reactions: {
userId: new ObjectId(user._id),
emoji: emoji
}
}
});
} else {
// 添加反应
await super.patch(messageId, {
$push: {
reactions: {
userId: new ObjectId(user._id),
emoji: emoji,
timestamp: new Date()
}
}
});
}
return { success: true };
}
// 搜索消息
async searchMessages(query, roomId, params) {
const { user } = params;
// 验证权限
const room = await this.app.service('rooms').get(roomId);
const isMember = room.members.some(
member => member.userId.toString() === user._id
);
if (!isMember) {
throw new Error('无权限搜索此聊天室消息');
}
const pipeline = [
{
$match: {
roomId: new ObjectId(roomId),
isDeleted: { $ne: true },
$text: { $search: query }
}
},
{
$lookup: {
from: 'users',
localField: 'senderId',
foreignField: '_id',
as: 'sender',
pipeline: [
{
$project: {
username: 1,
'profile.displayName': 1,
'profile.avatar': 1
}
}
]
}
},
{
$addFields: {
sender: { $arrayElemAt: ['$sender', 0] },
score: { $meta: 'textScore' }
}
},
{
$sort: { score: { $meta: 'textScore' } }
},
{ $limit: 50 }
];
const messages = await this.Model.aggregate(pipeline).toArray();
return {
total: messages.length,
data: messages
};
}
}
module.exports = MessagesService;
实时功能配置
1. 频道管理
javascript
// src/channels.js
module.exports = function(app) {
if(typeof app.channel !== 'function') {
return;
}
app.on('connection', connection => {
app.channel('anonymous').join(connection);
});
app.on('login', (authResult, { connection }) => {
const { user } = authResult;
if(connection) {
app.channel('anonymous').leave(connection);
app.channel('authenticated').join(connection);
app.channel(`user-${user._id}`).join(connection);
// 更新用户在线状态
app.service('users').updateOnlineStatus(user._id, true, 'online');
// 加入用户参与的聊天室频道
app.service('rooms').find({ user }).then(rooms => {
rooms.data.forEach(room => {
app.channel(`room-${room._id}`).join(connection);
});
});
}
});
app.on('disconnect', connection => {
if (connection.user) {
// 更新用户离线状态
app.service('users').updateOnlineStatus(
connection.user._id,
false,
'offline'
);
}
});
// 消息事件发布
app.service('messages').publish('created', (data, hook) => {
return app.channel(`room-${data.roomId}`);
});
app.service('messages').publish('patched', (data, hook) => {
return app.channel(`room-${data.roomId}`);
});
// 用户状态事件发布
app.service('users').publish('patched', (data, hook) => {
if (hook.data['status.isOnline'] !== undefined) {
return app.channel('authenticated');
}
return [];
});
// 聊天室事件发布
app.service('rooms').publish((data, hook) => {
return app.channel(`room-${data._id}`);
});
};
前端集成示例
1. 聊天组件
javascript
// client/components/ChatRoom.js
import React, { useState, useEffect, useRef } from 'react';
import client from '../feathers-client';
const ChatRoom = ({ roomId, currentUser }) => {
const [messages, setMessages] = useState([]);
const [newMessage, setNewMessage] = useState('');
const [isTyping, setIsTyping] = useState(false);
const [onlineUsers, setOnlineUsers] = useState([]);
const messagesEndRef = useRef(null);
useEffect(() => {
loadMessages();
setupEventListeners();
return () => {
cleanup();
};
}, [roomId]);
const loadMessages = async () => {
try {
const result = await client.service('messages').find({
query: {
roomId,
$limit: 50,
$sort: { createdAt: -1 }
}
});
setMessages(result.data.reverse());
scrollToBottom();
} catch (error) {
console.error('加载消息失败:', error);
}
};
const setupEventListeners = () => {
const messagesService = client.service('messages');
messagesService.on('created', handleNewMessage);
messagesService.on('patched', handleMessageUpdate);
client.on('user-status-changed', handleUserStatusChange);
client.on('user-typing', handleUserTyping);
};
const cleanup = () => {
const messagesService = client.service('messages');
messagesService.off('created', handleNewMessage);
messagesService.off('patched', handleMessageUpdate);
client.off('user-status-changed', handleUserStatusChange);
client.off('user-typing', handleUserTyping);
};
const handleNewMessage = (message) => {
if (message.roomId === roomId) {
setMessages(prev => [...prev, message]);
scrollToBottom();
// 标记为已读
if (message.senderId !== currentUser._id) {
markAsRead([message._id]);
}
}
};
const handleMessageUpdate = (message) => {
if (message.roomId === roomId) {
setMessages(prev =>
prev.map(msg => msg._id === message._id ? message : msg)
);
}
};
const handleUserStatusChange = (data) => {
setOnlineUsers(prev => {
const updated = prev.filter(user => user.userId !== data.userId);
if (data.isOnline) {
updated.push({
userId: data.userId,
activity: data.activity,
lastSeen: data.lastSeen
});
}
return updated;
});
};
const handleUserTyping = (data) => {
if (data.roomId === roomId && data.userId !== currentUser._id) {
setIsTyping(data.isTyping);
if (data.isTyping) {
setTimeout(() => setIsTyping(false), 3000);
}
}
};
const sendMessage = async () => {
if (!newMessage.trim()) return;
try {
await client.service('messages').create({
roomId,
content: newMessage,
type: 'text'
});
setNewMessage('');
stopTyping();
} catch (error) {
console.error('发送消息失败:', error);
}
};
const markAsRead = async (messageIds) => {
try {
await client.service('messages').markAsRead(messageIds);
} catch (error) {
console.error('标记已读失败:', error);
}
};
const startTyping = () => {
client.emit('user-typing', {
roomId,
userId: currentUser._id,
isTyping: true
});
};
const stopTyping = () => {
client.emit('user-typing', {
roomId,
userId: currentUser._id,
isTyping: false
});
};
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
const handleKeyPress = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
const handleInputChange = (e) => {
setNewMessage(e.target.value);
if (e.target.value.trim() && !isTyping) {
startTyping();
} else if (!e.target.value.trim() && isTyping) {
stopTyping();
}
};
return (
<div className="chat-room">
<div className="messages-container">
{messages.map(message => (
<MessageItem
key={message._id}
message={message}
currentUser={currentUser}
onReaction={(emoji) => addReaction(message._id, emoji)}
/>
))}
{isTyping && (
<div className="typing-indicator">
有人正在输入...
</div>
)}
<div ref={messagesEndRef} />
</div>
<div className="message-input">
<textarea
value={newMessage}
onChange={handleInputChange}
onKeyPress={handleKeyPress}
placeholder="输入消息..."
rows={1}
/>
<button onClick={sendMessage} disabled={!newMessage.trim()}>
发送
</button>
</div>
</div>
);
};
export default ChatRoom;
总结
通过这个聊天应用项目,我们实现了:
✅ 完整的用户系统:
- 用户注册登录
- 在线状态管理
- 联系人管理
✅ 强大的聊天功能:
- 私聊和群聊
- 多种消息类型
- 消息状态跟踪
✅ 实时通信:
- WebSocket 连接管理
- 实时消息同步
- 在线状态广播
✅ 高级特性:
- 消息搜索
- 反应表情
- 消息编辑和撤回
这个项目展示了 Feathers.js 在构建实时应用方面的强大能力,是学习和实践的绝佳案例。
相关文章推荐:
有问题欢迎留言讨论,我会及时回复大家!