chat-application-322 / client / app.js
app.js
Raw
//Global Variables
const profile = {
    username: "Alice"
};

var Service = {
    origin: location.origin,

    getAllRooms: function(){
        return new Promise((resolve, reject) => {
            fetch( `${this.origin}/chat`) 
                .then( response => {
                    if(response.ok){
                        return response.json();
                    } else {
                        // Reject promise with client side error 
                        return response.text().then(clientErrorMessage => {
                            throw new Error(clientErrorMessage);
                        });
                    }
                })
                .then(data => resolve(data))
                // Reject promise with server side error
                .catch(error => reject(error));
        });
    },

    addRoom: function(data) {
        return new Promise((resolve, reject) => {
            fetch( `${this.origin}/chat`, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify(data)
            })
            .then( response => {
                if(!response.ok){
                    return response.text().then(clientErrorMessage => {
                        throw new Error(clientErrorMessage);
                    });
                }
    
                return response.json();
            })
            .then(data => resolve(data))
            .catch(error => reject(error));
        });
    },

    getLastConversation: function(room_id, before){
        return new Promise((resolve, reject) => {
            fetch(`${this.origin}/chat/${room_id}/messages?before=${encodeURIComponent(before)}`) 
                .then( response => {
                    if(response.ok){
                        return response.json();
                    } else {
                        // Reject promise with client side error 
                        return response.text().then(clientErrorMessage => {
                            throw new Error(clientErrorMessage);
                        });
                    }
                })
                .then(data => resolve(data))
                // Reject promise with server side error
                .catch(error => reject(error));
        });
    },

    getProfile: function() {
        return fetch('/profile')
        .then(response => {
            if (!response.ok) {
                throw new Error('Failed to fetch profile');
            }
            return response.json();
        })
        .catch(error => {
            console.error('Error fetching profile:', error);
        });
    }
};

// Removes the contents of the given DOM element (equivalent to elem.innerHTML = '' but faster)
function emptyDOM (elem){
    while (elem.firstChild) elem.removeChild(elem.firstChild);
}

// Creates a DOM element from the given HTML string
function createDOM (htmlString){
    let template = document.createElement('template');
    template.innerHTML = htmlString.trim();
    return template.content.firstChild;
}

class LobbyView {
    constructor(lobby) {
        this.lobby = lobby;
        const lobbyHtml = `
        <div class="content">
            <ul class="room-list">
            </ul>
            <div class="page-control">
                <input type="text" placeholder="Room Title">
                <button>Create room</button>
            </div>
        </div>`;
        this.elem = createDOM(lobbyHtml);
        this.listElem  = this.elem.querySelector("ul.room-list");
        this.inputElem = this.elem.querySelector("input");
        this.buttonElem = this.elem.querySelector("button");
        this.buttonElem.addEventListener('click', () => this.handleInputButton());

        this.lobby.onNewRoom = (room) => {
            const listItemStr = `
            <li>
                <a href="#/chat/${room._id}">
                    <img class="room-icon" src="${room.image}" alt="${room.name} - Room Icon">
                    ${room.name}
                </a>
            </li>`
            const listItem = createDOM(listItemStr);
            this.listElem.appendChild(listItem);
        };

        this.redrawList();
    }

    handleInputButton() {
        console.log(this.inputElem)
        const roomName = this.inputElem.value.trim();

        if(roomName){
            Service.addRoom({name: roomName}).then(newRoom => {
                this.lobby.addRoom(newRoom.id, newRoom.name, newRoom.image, []);
            });
            this.inputElem.value = '';
            this.redrawList();
        }
    }

    redrawList() {
        emptyDOM(this.listElem);

        for (const roomId in this.lobby.rooms) {
            const room = this.lobby.rooms[roomId];

            const listItemStr = `
            <li>
                <a href="#/chat/${roomId}">
                    <img class="room-icon" src="${room.image}" alt="${room.name} - Room Icon">
                    ${room.name}
                </a>
            </li>`

            const listItem = createDOM(listItemStr);
            this.listElem.appendChild(listItem);
        }
    }
}

class ChatView  {
    constructor(socket) {
        this.socket = socket;
        const chatHtml = `
        <div class="content">
            <h4 class="room-name">Everyone in CPEN322</h4>
            <div class="message-list">
                <div class="message">
                    <span class="message-user">Maddy</span>
                    <span class="message-text">Hello Everyone</span>
                </div>
                <div class="message my-message">
                    <span class="message-user">Leo</span>
                    <span class="message-text">Hi</span>
                </div>
            </div>
            <div class="page-control">
                <textarea name="send-message" cols="30" rows="1"></textarea>
                <button>Send</button>
            </div>
        </div>`;

        this.elem = createDOM(chatHtml);
        this.titleElem = this.elem.querySelector("h4");
        this.chatElem = this.elem.querySelector("div.message-list");
        this.chatElem.addEventListener('wheel', (event) => this.handleScroll(event));
        this.inputElem = this.elem.querySelector("textarea");
        this.inputElem.addEventListener('keyup', (event) => this.handleSendMessage(event));
        this.buttonElem = this.elem.querySelector("button");
        this.buttonElem.addEventListener('click', () => this.sendMessage());
        this.room = null;
    }

    sendMessage(){
        const textMessage = this.inputElem.value.trim();
        this.room.addMessage(profile.username, textMessage);

        // Construct message for the server
        const sendMessage = JSON.stringify({
            roomId: this.room.id,
            username: profile.username,
            text: textMessage
        });

        // Send message to server
        this.socket.send(sendMessage);

        this.inputElem.value = '';
    }

    handleSendMessage(event){
        if(event.key == 'Enter' && !event.shiftKey){
            event.preventDefault(); // Don't use enter for new line
            this.sendMessage();
        }
    }

    handleScroll(event){
        if(this.room && this.room.canLoadConversation && event.deltaY < 0 && this.chatElem.scrollTop === 0){
            this.room.getLastConversation.next();
        }
    }

    setRoom(room){
        this.room = room;
        this.titleElem.textContent = room.name;

        emptyDOM(this.chatElem);

        // Add each message to the chat dynamically
        for(let i = 0; i < this.room.messages.length; i++){
            this.addMessageToChat(this.room.messages[i]);
        }

        this.room.onNewMessage = (message) => {
            this.addMessageToChat(message);
        };

        this.room.onInappropriateMessage = (originalMessage, moderatedMessage) => {
            this.removeMyInappropriateMessageFromChat(originalMessage);
            this.addMessageToChat(moderatedMessage);
        };

        this.room.onSendMessageBan = (originalMessage, shouldRemoveLastMessage) => {
            if (shouldRemoveLastMessage) {
                this.removeMyInappropriateMessageFromChat(originalMessage);
            }
            this.disableMessaging();
        }

        this.room.onFetchConversation = (conversation) => {
            const scrollHeightBefore = this.chatElem.scrollHeight;

            for(let i = conversation.messages.length - 1; i >= 0 ; i--){
                this.addMessageToChatOnFetch(conversation.messages[i]);
            }

            const scrollHeightAfter = this.chatElem.scrollHeight;
            this.chatElem.scrollTop = scrollHeightAfter - scrollHeightBefore;
        };

        // Fake scroll event to load conversation
        this.handleScroll({deltaY: -1}); 
    }

    createMessageDiv(message){
        const messageDiv = document.createElement('div');

        // Determine whose message it is
        messageDiv.className = 'message' + (message.username === profile.username ? ' my-message' : '');

        // Construct user span for messages
        const messageUserSpan = document.createElement('span');
        messageUserSpan.className = 'message-user';
        messageUserSpan.textContent = message.username;
        messageDiv.appendChild(messageUserSpan);

        // Construct user text span for messages
        const messageTextSpan = document.createElement('span');
        messageTextSpan.className = 'message-text';
        messageTextSpan.textContent = this.sanitizeText(message.text);
        messageDiv.appendChild(messageTextSpan);

        return messageDiv;
    }

    sanitizeText(text){
        return text.replace(/</g, '').replace(/>/g, '');
    }
    
    // Add message at to top of the chat
    addMessageToChatOnFetch(message){
        const messageDiv = this.createMessageDiv(message);
        this.chatElem.prepend(messageDiv);
    }

    // Add message at the bottom of the chat
    addMessageToChat(message){
        const messageDiv = this.createMessageDiv(message);
        this.chatElem.appendChild(messageDiv);
    }

    removeMyInappropriateMessageFromChat(originalMessage){
        const allMessages = document.querySelectorAll('div.my-message');

        const filteredDivs = Array.from(allMessages).filter(div => div.textContent.includes(originalMessage.text));

        if(filteredDivs.length > 0){
            this.chatElem.removeChild(filteredDivs[filteredDivs.length - 1]);
        }
    }

    disableMessaging(){
        this.inputElem.disabled = true;
        this.buttonElem.disabled = true;
    }
}

class ProfileView  {
    constructor() {
        const profileHtml = `
        <div class="content">
            <div class="profile-form">
                <div class="form-field">
                    <label>Profile Picture</label>
                    <img class="profile-icon"  src="assets/profile-picture.png" alt="Profile Picture - icon">
                    <input type="file">
                </div>
                <div class="form-field">
                    <label>Username</label>
                    <input type="text">
                </div>
                <div class="form-field">
                    <label>Password</label>
                    <input type="password">
                </div>
                <div class="form-field">
                    <label>About</label>
                    <textarea id="bio" placeholder="Tell other users about yourself!"></textarea>
                </div>
            </div>
            <div class="page-control">
                <button>Save</button>
            </div>
        </div>`;

        this.elem = createDOM(profileHtml);
    }
}

class Room {
    constructor(id, name, image='assets/everyone-icon.png', messages=[]) {
        this.id = id;
        this.name = name;
        this.image = image;
        this.messages = messages;
        this.createdAt = Date.now();
        this.canLoadConversation = true;
        this.getLastConversation = makeConversationLoader(this);
    }

    updateInappropriateMessage(originalMessage, moderatedMessage){
        const index = this.messages.findIndex(message => message.text === originalMessage.text && message.username === originalMessage.username);
    
        if(index !== -1){
            this.messages[index] = moderatedMessage;
            if(typeof this.onInappropriateMessage === "function"){
                this.onInappropriateMessage(originalMessage, moderatedMessage);
            }
        }
    }

    removeLastMessageAndDisableChat(message, shouldRemoveLastMessage){
        if(typeof this.onSendMessageBan === "function"){
            this.onSendMessageBan(message, shouldRemoveLastMessage);
        }
    }

    addMessage(username, text){
        if(!text.trim()){
            return;
        }

        const message = {
            username: username,
            text: text
        }

        this.messages.push(message);

        if (typeof this.onNewMessage === 'function') {
            this.onNewMessage(message);
        }
    }

    addConversation(conversation){
        // Make sure conversation is not empty & has the array messages
        if(!conversation || !Array.isArray(conversation.messages) || conversation.messages.length === 0 ){
            return;
        }

        const orderedConversation = conversation.messages.concat(this.messages);
        orderedConversation.sort((a,b) => new Date(a.timestamp) - new Date(b.timestamp));
        this.messages = orderedConversation;

        if(typeof this.onFetchConversation === "function"){
            this.onFetchConversation(conversation);
        }
    }
}

class Lobby {
    constructor() {
        this.rooms = {}
    }

    getRoom(roomId) {
        return this.rooms[roomId];
    }

    addRoom(id, name, image, messages) {
        const newRoom = new Room(id, name, image, messages);
        this.rooms[id] = newRoom;
        if (typeof this.onNewRoom === 'function') {
            this.onNewRoom(newRoom);
        }
    }
}

function* makeConversationLoader(room){
    var lastConversationTimestamp = room.createdAt;
    let loadMoreData = true;
    
    while(loadMoreData){
        room.canLoadConversation = false;
        if(!room.canLoadConversation){
            try {
                const before = lastConversationTimestamp;
                const data = yield new Promise((resolve,reject) => {
                    Service.getLastConversation(room.id, before)
                        .then(data => {
                            if(data && data.messages && data.messages.length > 0 ){
                                lastConversationTimestamp = data.timestamp;
                                room.addConversation(data);
                                room.canLoadConversation = true;
                                resolve(data);
                            } else {
                                loadMoreData = false;
                                resolve(null);
                            }
                        })
                        .catch(error => {
                            room.canLoadConversation = false;
                            reject(error);
                        });
                });
            } catch(error) {
                console.error("Error:", error);
            }
        }
    }
}


function main () {
    const lobby = new Lobby();
    const lobbyView = new LobbyView(lobby);
    const socket = new WebSocket("ws://localhost:8000");
    const chatView = new ChatView(socket);
    const profileView = new ProfileView();

    socket.addEventListener('message', function(event) {
        const messageData = JSON.parse(event.data);
        const room = lobby.getRoom(messageData.roomId);

        switch(messageData.type){
            case 'chat':
                const { username, text} = messageData;
                room.addMessage(username, text);
                break;
            case 'inappropriate-alert':
                alert(messageData.alert);
                const {originalMessage, moderatedMessage} = messageData;
                room.updateInappropriateMessage(originalMessage, moderatedMessage);
                break;
            case 'restricted-alert':
                alert(messageData.alert);
                const message = messageData.originalMessage;
                const shouldRemoveLastMessage = messageData.shouldRemoveLastMessage;
                room.removeLastMessageAndDisableChat(message, shouldRemoveLastMessage);
                break; 
        }
        
    });

    Service.getProfile().then(profileData => {
        if (profileData && profileData.username) {
            profile.username = profileData.username;
        }
    }).catch(error => {
        console.error("Error fetching profile:", error);
    });

    const refreshLobby = () => {
        Service.getAllRooms().then(returnedRooms => {
            returnedRooms.forEach(roomDetails => {
                if(lobby.rooms[roomDetails._id]) {
                    // Room already exists so update information
                    lobby.rooms[roomDetails._id].name = roomDetails.name;
                    lobby.rooms[roomDetails._id].image = roomDetails.image;
                } else {
                    // Create new room
                    lobby.addRoom(roomDetails._id,roomDetails.name, roomDetails.image, roomDetails.messages);
                }
            });
            lobbyView.redrawList();
        })
        .catch(error => {
            console.error("Error while refreshing lobby: ", error);
        });
    }

    refreshLobby();

    // Refresh the lobby every 5 seconds
    setInterval(refreshLobby, 5000);

    const renderRoute = () => {
        const pageView = document.getElementById("page-view")
        emptyDOM(pageView);

        const hash = window.location.hash.substring(2); // Removing '#' from the hash
        var pageContent;
        if (hash === "") {
            pageContent = lobbyView.elem;
        } else if (hash.includes("chat")) {
            const roomID = hash.split('/')[1];
            const room = lobby.getRoom(roomID);

            // Check that room exists
            if(room){
                chatView.setRoom(room);
                pageContent = chatView.elem;
            } else {
                // If room doesn't exist just go to lobby
                pageContent = lobbyView.elem;
            }
        } else if (hash.includes("profile")) {
            pageContent = profileView.elem;
        }
        pageView.appendChild(pageContent);
    }
    window.addEventListener('popstate', renderRoute);
    renderRoute()
    cpen322.export(arguments.callee, { renderRoute, lobbyView, chatView, profileView, lobby, refreshLobby, socket });
}

window.addEventListener('load', main);