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:

  1. Group chat, private chat
  2. Message list, friends list
  3. 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)
  4. Default emoticons sent, pictures sent

Plus, it uses some new HTML5 apis,

  1. Desktop message notification (via Notification)
  2. 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:

  1. The dialogId conversation Id
  2. Page number of pages
  3. 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:

  1. Denied Denied authorization notification
  2. Granted Allows authorization notification
  3. 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:

  1. 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
  2. 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
  3. 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
  4. 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