preface
This is a DEMO! This is a prototype of the Fiora project but a completely different technology stack DEMO! This is a junior 🐕 in order to find an internship and write DEMO!
Project experience and source code
Everyone can give me this poor big brother a star🐎
- Address for the project: http://47.106.13.104/ (tebrows, server and domain name filing are pending, and CDN is unavailable. By the way, please use the desktop chrome access, no mobile adaptation!!
- Front-end project source: github.com/Angellikefa…
- Back-end project source: github.com/Angellikefa…
On the prototype
Let you see the official joke, because I do not have any talent in design, so the project with one of the Fiora project theme as the project prototype design and development.
-
Home Page (Visitor Status)
-
Login screen
-
Contact Information screen
About the Technology Stack
- Front-end stack: Vue, Vuex, Vue Router, Element UI
- Back-end stack: Nestjs, TypeORM, socket.io
- Database: MySql
- Development language: ES6 and TypeScript hybrid development is the main front-end, and All in TypeScript backend
About the function
Due to the limited time and energy (full stack for a while, always full stack…) , the project only realized some basic functions:
- Group chat, private chat
- Message list, friends list
- Friend add (I seem to have forgotten the friend search function… But it can be added by clicking on the profile picture in a group chat)
- Default emoticons sent, pictures sent
Plus, it uses some new HTML5 apis,
- Desktop message notification (via Notification)
- Web screenshots (implemented via Canvas, production version uses kscreenshot plugin)
About Socket Connections
On the client side, based on socket. IO-client, each user gets a Socket instance in which socketId is the unique identifier.
import client from 'socket.io-client';
// Returns a new Socket instance for the namespace specified by the pathname in the URL
app.socket = client(socketUrl);
// For registered users, we also need to carry query parameters to identify the user
app.socket = client(socketUrl,{
query: {
userId
}
});
Copy the code
The server handles socket connections
/** * This function is called when a new connection is established, and the logged-in user updates his socketId and joins all group chats * @param socket */
@UseFilters(new CustomWsExceptionFilter())
async handleConnection(socket: Socket) {
const socketId = socket.id;
// Get details about the handshake, including the query parameter object and the client IP
const {query,address as clentIp} = socket.handshake;
if(query.userId) {
const userId = query.userId;
await this.websocketService.updateSocketMes(socketId,clientIp,userId);
await this.websocketService.userJoinRooms(socket,userId); }}Copy the code
About user login and registration
Except for socket communication, other interfaces do not involve the Websocket protocol.
User registration
Users need to provide a username and password to register. The password is salted through Bcrypt
export async function genSaltPassword(pass: string) :Promise<string> {
const salt = await bcrypt.genSalt(saltRounds);
const hash = await bcrypt.hash(pass,salt);
return hash;
}
Copy the code
After successful registration (the user name is not repeated), you will get a random avatar and join the default group
// Get a random avatar
const randomAvatarUrl = await this.randomAvatarService.getRandomAvatarUrl();
// Join the default group
const defaultGroup = await this.groupService.getDefaultGroup();
await this.groupRelationshipService.createGroupRelationship({
user_id: userBasicMes.user_id,
group_id: defaultGroup.group_id,
top_if: 0,
group_member_type: 'ordinary'
})
Copy the code
The user login
Users can log in by account and password or automatically log in by token stored on the client
Log in through account secret
When you log in using the account secret, the server verifies the account secret
async validatePassword(userName: string, password: string) :Promise<boolean> {
// Verify that the user exists (by user name) and that the password matches
return this.userService.checkUserExistence({ user_name: userName }) &&
this.userService.checkUserPassword(userName, password);
}
Copy the code
For password matches, Bcrypt compares the password given by the user against the salted password stored in the database at registration
if(user) {
saltPassword = user.user_saltPassword;
return await bcrypt.compare(password,saltPassword);
}
Copy the code
When the authentication is successful, the server generates a JWT and returns it to the client for subsequent automatic login processing
/ / import JWT - simple
import * as jwt from "jwt-simple";
async genJWT(userName: string) {
const userId = (await this.userService.getUserBasicMes({user_name: userName})).user_id;
const payload = {
userId,
enviroment: 'web'.// Set the expiration time of JWT
expires: Date.now() + jwtConfig.tokenExpiresTime
};
// Use the default encoding algorithm (HS256) for encoding
const token = jwt.encode(payload,jwtConfig.jwtSecret);
return token;
}
Copy the code
Finally, update the user’s login status, socketId, and other information
await this.userService.updateUser({
user_id
},{
user_lastLogin_time: datetime,
user_state: 1,
user_socketId,
user_preferSetting_id
})
Copy the code
Automatic login via JWT
For automatic login through JWT, the server needs to verify the validity of JWT
async validateToken(accessToken: string) :Promise<boolean> {
// Decode the token
const decodedToken = this.decodeToken(accessToken);
// Token expiration processing
if(decodedToken.expires < Date.now()) {
throw new HttpException("Token has expired, please log in again",HttpStatus.FORBIDDEN);
}
if(this.userService.checkUserExistence({user_id: decodedToken.usrId})) {
return true;
}
else {
throw new HttpException("Invalid Token, this is an illegal login",HttpStatus.FORBIDDEN); }}Copy the code
After the authentication is successful, the user information is updated and the user login is successful!
About the news
User sends message
The client emits a message event through the socket instance to send a message
socket.emit("message",{
messageFromId,
messageToId,
messageType, // Message type (private or group)
messageContent,
messageContentType // Message content type (text,img...)
},res= > {
/ /... Callback after successful sending
})
// Failed to send a message
socket.once('messageException'.err= > {
const {message} = err;
showMessage(app,'warning',message,3000.true);
})
Copy the code
The server listens for the event at the WebSocket gateway
@UseFilters(new CustomWsExceptionFilter())
@SubscribeMessage('message')
async sendMessage(@ConnectedSocket() socket: Socket, @MessageBody() mes: any) {
return await this.chatingMessageService.sendMessage(mes,socket);
}
Copy the code
The message is then persisted to the database and sent to the target (user or group chat)
// Persist messages
messageId = await this.messageService.createMessage(mes);
// Send a message to a group chat using the target group chat name (unique)
socket.to(groupName).emit('groupMemberMessage',{
messageFromUser,
messageTarget: messageTarget,
messageId,
messageContent,
messageContentType,
messageType,
messageCreatedTime
});
// Send private chat messages through the target user's socketId
socket.to(targetSocketId).emit('messageFromFriend',{
messageFromUser,
messageTarget: messageTarget,
messageId,
messageContent,
messageContentType,
messageType,
messageCreatedTime
});
Copy the code
User receiving messages
The client receives private and group chat messages by listening to messageFromFriend and groupMemberMessage events respectively
socket.on('messageFromFriend', resolveReceiveMes)
socket.on('groupMemberMessage', resolveReceiveMes)
Copy the code
After receiving a message, the message is added to the corresponding message list.
// If the message does not exist, create a dialog box
if(! store.state.messageMap[dialogTargetId]) {await store.dispatch('resolveMessageMap',{
dialogId,
dialogTargetId,
page: 1.limit: 50})}// If it exists, the message is pushed directly into the message list
else store.commit('addNewMessage',{
dialogId,dialogTargetId,messageContentMes
})
Copy the code
In addition, if the user has enabled desktop message notification, the message is displayed by getting the content type of the message sent (in a regular manner)
if(store.state.notification) {
// ...
if (/image\/(gif|jpeg|jpg|png|svg)/g.test(message.messageContentType)) {
notificationContent = ` ` [images]
}
else {
notificationContent = resolveEmoji(message,'messageContent');
}
if(messageType === 0) {
notificationTile = ` friends${notifiFromName}I have sent you a new message: ';
notificationAvatar = notifiFromAvatar;
}
else {
notificationTile = ` group${notifiTargetName}Add a member information ';
notificationAvatar = notifiTargetAvatar;
notificationContent = `${notifiFromName}: ` + notificationContent;
}
createNotification(notificationTile,{
body: notificationContent,
icon: notificationAvatar
})
}
Copy the code
Get historical messages
The client retrieves the history message by paging. The getHistoryMessages interface accepts the following three parameters:
- The dialogId conversation Id
- Page number of pages
- Limit Number of pages per page
In order not to re-request the history message each time the dialog is switched, the client globally sets up a messageMap hash table in Vuex to store the received message data
Create a list of messages in messageMap with the dialogId key * @param param0 * @param option */
async resolveMessageMap({commit},option) {
const {dialogId,dialogTargetId,page,limit,app} = option;
const messages = await historyMessages(dialogId,page,limit);
// Page greater than 1 ensures that there are no more historical messages when a conversation has no information
if((! messages || messages.length ===0) && page > 1) {
noMoreMessage(app);
}
commit('setMessageMap',{dialogTargetId,dialogId,messages});
}
Copy the code
In addition, the client uses an infinite slide up mode to get more history messages
// When scrollTop equals scrollheight-clientheight, the slider slides over the top of the dialog box and loads more history messages
if(Math.floor(element.scrollTop)+1 < element.scrollHeight - element.clientHeight) {
// getHistoryMessages
}
// In order to prevent some fast boys from triggering events multiple times before the data is returned, there is a scroll bar with a 5px bounce and a shake control
element.scrollTop = 5;
import * as _ from "lodash";
_.debounce();
Copy the code
ScrollTop and scrollHight are shown below:
Talk lists and contact lists
Dialogue list
The time of the last message in the dialog list item is displayed in a different format depending on the interval between the actual time and the current time
export function resolveTime(time,option) {
const mesDatetime = new Date(time); // Get the current timestamp
const {year: curYear, month: curMonth, date: curDate} = getDatetimeMes(new Date());
const {
year: mesYear,
month: mesMonth,
date: mesDate
} = getDatetimeMes(mesDatetime);
// If the message is today, only the time is returned, for example: 15:00
if(curYear === mesYear && curMonth === mesMonth && curDate === mesDate) {
return mesDatetime.toTimeString().slice(0.5);
}
else if(curYear === mesYear && curMonth === mesMonth) {
switch (curDate - mesDate) {
case 1 :
return 'yesterday' + option; // Return only "yesterday"
case 2:
return 'the day before yesterday + option; // The day before yesterday, only return "the day before yesterday"
default: return mesDatetime.toLocaleDateString().slice(5) + option; // Return the date, for example 2/1}}else if(curYear === mesYear){
return mesDatetime.toLocaleDateString().slice(5) + option; // If the message is from a previous year, return the year, month and day, for example, 2019/2/1
}
else {
returnmesDatetime.toLocaleDateString() + option; }}Copy the code
Contact list
The contact list is classified by the uppercase letter of the contact. If the initial letter is A number or other non-[A-Z] letters, the contact list is placed in the # category.
// introduce cnchar to get the first letter
var cnchar = require('cnchar');
/** * Sort by first letter (# if not 26 uppercase letters) * @param allFriendsMes */
classifyFriendsByChar(allFriendsMes: Array<UserBasicMes>) {
const friendsCharMap: Map<string.Array<UserBasicMes>> = new Map();
allFriendsMes.forEach(friendMes= > {
const firstLetter: string = cnchar.spell(friendMes.user_name,'array'.'first') [0].toLocaleUpperCase();
const pattern = /[A-Z]/g;
if(pattern.test(firstLetter)) {
// If the first letter is [a-z], add it to the key (array) with that letter as the key name
this.solveCharMap(friendsCharMap,firstLetter,friendMes);
}
else {
// otherwise add to the key (array) with the key name "#"
this.solveCharMap(friendsCharMap,The '#',friendMes); }})const friendsList: {[char: string]: UserBasicMes[]} = {};
friendsCharMap.forEach((friendsMes,char) = >{
// Sort the friends in the array (of the same category) by Unicode
this.sortFriends(friendsMes);
friendsList[char] = friendsMes;
})
return friendsList;
}
Copy the code
About Sending pictures
Images and screenshots are uploaded to the server as a static resource before being sent, and the URL of the static resource is persisted and sent as a message.
Front-end file upload
The front-end uploads files through the File upload component of Element UI. Before uploading, we need to make sure that the uploaded file is an image and the size is as expected:
beforeImageUpload(file) {
const imagepattern = /image\/(gif|jpeg|jpg|png|svg)/g;
const isJPG = imagepattern.test(file.type);
const isLt2M = file.size / 1024 / 1024 < 2;
if(! isJPG) { showMessage(this.'error'.'You can only upload images.'.0.true)}else if(! isLt2M) { showMessage(this.'error'.'Upload image size cannot exceed 2MB! '.0.true)}else {
this.$store.commit('setImageLoading'.true); }}Copy the code
Back-end file processing
First we need to set up a static resource server, so that we can get static resources through the WAY of URL
// Set this directory to a static resource directory
app.useStaticAssets(join(__dirname,'.. /public/'.'static'), {
prefix: '/static/'});Copy the code
The back end uses the FileInterceptor() decorator and the @uploadedFile () decorator to get file objects and hand them to the provider for processing
@Post('upload')
@UseInterceptors(FileInterceptor('file'))
async uploadFile(@UploadedFile() file) {
return await this.uploadService.getUrl(file);
}
Copy the code
Once we get the file object, we need to write the file stream to the static resource directory,
async saveFile(file): Promise<string> {
const datetime: number = Date.now();
const fileName: string = datetime+file.originalname;
// File buffer
const fileBuffer: Buffer = file.buffer;
const filePath: string = join(__dirname,'.. /.. /public/'.'static',fileName);
const staticFilePath: string = staticDirPath + fileName;
// Create a write stream
const writeFile: WriteStream = createWriteStream(filePath);
return await new Promise((resolve,reject) = > {
writeFile.write(fileBuffer,(error: Error) = > {
if(error) {
throw new HttpException('File upload failed',HttpStatus.FORBIDDEN); } resolve(staticFilePath); }); })}Copy the code
About desktop message notifications
Check whether the current browser supports Notification by checking whether there is a global Notification attribute in the browser global object Window
export function supportNotification() :boolean {
return ("Notification" in window);
}
Copy the code
If so, we can get whether authorization Notification is currently allowed from notification. permission. There are three possibilities:
- Denied Denied authorization notification
- Granted Allows authorization notification
- Default: The browser behaves the same as if denied, because the user’s choice is not known
For less degree to bother the user, we won’t this pop-up to select the user that denied authorization Notification, only when the permission for the default still, we will have a Notification. The requestPermission () to request to the user access permissions
if (Notification.permission === 'defalut') {
Notification.requestPermission(function (permission) {
// Notifications can be sent to users if they agree
if (permission === "granted") {
var notification = new Notification("Hi there!"); }}); }Copy the code
About Web Screenshots
In the development version, the realization of Web screenshot function mainly relies on html2Canvas and Canvas. The main ideas are as follows:
-
Two Canvasses are set, in which the original page is at the bottom layer, the middle layer reads the DOM of the current page and applies the style through HTML2Canvas, so as to generate a canvas picture, and the upper layer is used for the realization of screenshot effect
const middleCanvas = await html2canvas(document.body); const topCanvas = document.createElement("canvas"); // Set the width of the top canvas to the width of the body const {offsetWidth,offsetHeight} = document.body; topCanvas.width = offsetWidth; topCanvas.height = offsetHeight; Copy the code
-
Achieve screenshot effect, listen to the mouse press, move, and release
// Mouse down event, get the mouse down position as the initial position of the screenshot onMousedown(e) { const {clipState} = this.$store.state; if(! clipState)return; const {offsetX,offsetY} = e; this.start = { startX: offsetX, startY: offsetY } } // The mouse movement event is used to generate the screenshot area onMousemove(e) { if(!this.start) return; const {start,clipArea} = this; this.fillClipArea(start.startX,start.startY,e.offsetX-start.startX,e.offsetY-start.startY); } // Release the mouse, the screenshot ends, and convert the canvas into a picture for the next operation onMouseup(e) { this.canvasToClipImage(this.bodyCanvas); this.start = null; } Copy the code
-
FillClipArea (generate screenshot area) function implementation
fillClipArea(x,y,w,h) { // Get the Drawing Canvas interface object const ctx = this.topcanvas.getContext('2d'); if(! ctx)return; ctx.fillStyle = 'rgba (0,0,0,0.6)'; // Set the fill color for the screenshot area ctx.strokeStyle="green"; // Set the outline of the screenshot area const width = document.body.offsetWidth; const height = document.body.offsetHeight; // Every time you move a position, you need to erase the previous drawing and redraw it (otherwise you won't get the desired effect) ctx.clearRect(0.0,width,height); // Start creating a new path ctx.beginPath(); // Create mask layer ctx.globalCompositeOperation = "source-over"; ctx.fillRect(0.0,width,height); / / frame ctx.globalCompositeOperation = 'destination-out'; ctx.fillRect(x,y,w,h); / / stroke ctx.globalCompositeOperation = "source-over"; // Move the starting point of the path to the (x,y) coordinates ctx.moveTo(x,y); // Draw a rectangle // the lineto method uses a straight lineto connect the end of the child path to the x, y coordinates (not really drawn). ctx.lineTo(x+w,y); ctx.lineTo(x+w,y+h); ctx.lineTo(x,y+h); ctx.lineTo(x,y); // Draw the existing path ctx.stroke(); // Draw a straight path from the current point to the starting point. If the graph is already closed or there is only one point, this method does nothing. ctx.closePath(); this.clipArea = { x, y, w, h } } Copy the code
-
CanvasToClipImage (Canvas to screenshot) function implementation
canvasToClipImage(canvas) { if(! canvas)return; // Create a new canvas to draw on the canvas data const newCanvas = document.createElement("canvas"); const {x,y,w,h} = this.clipArea; newCanvas.width = w; newCanvas.height = h; // Get the drawing interface object of the mid-layer canvas (i.e. the canvas whose body is drawn using HTML2Canvas) const canvasCtx = this.middleCanvas.getContext('2d'); const newCanvasCtx = newCanvas.getContext('2d'); // Get the image data of the screenshot area (it is worth noting that it will get the pixel data hidden in the area, so the screenshot is not very good) const imageData = canvasCtx.getImageData(0.0,w,h); // Draw the image data to the newly created canvas newCanvasCtx.putImageData(imageData,0.0); // Convert this canvas to a data URI const dataUrl = newCanvas.toDataURL("image/png"); console.log(dataUrl); //this.downloadImg(dataUrl); } Copy the code