+
+ `)
+ }
+
+ constructor() {
+ super({
+ attrTypes: AuthedUser.attrTypes,
+ template: AuthedUser.template
+ });
+
+ // render component
+ this.render();
+ }
+
+ // call on attributes changed
+ attributeChangedCallback(attrName, oldValue, newValue) {
+ if (oldValue === newValue)
+ return;
+
+ // re-render component
+ this.render();
+ }
+
+ setUser(user) {
+ this._user = user;
+
+ this.setAttribute("id", user.id);
+ this.setAttribute("name", user.name);
+ this.setAttribute("username", user.username);
+ this.setAttribute("avatar", user.avatar);
+ }
+
+ /**
+ * reflect the hidden attr on HTML tag
+ * @param value
+ */
+ set hidden(value) {
+ if (value)
+ this.setAttribute("hidden", '');
+ else
+ this.removeAttribute("hidden")
+ }
+
+ get hidden() {
+ return this.hasAttribute("hidden")
+ }
+
+ /**
+ * render component according to template and attributes
+ */
+ render() {
+
+ // check the existence of avatar
+ // fetch first char of title to show if avatar not passed
+ if (!this.getAttribute("avatar")) {
+ // put first char of title when avatar not passed
+ const name = (this.getAttribute("name") || "").toUpperCase();
+ this.shadowRoot.querySelector(".char-avatar").innerText = name.substr(0, 1);
+ }
+
+ // loop over attributes and set all
+ for (let attr of this.attributes) {
+ const target = this.shadowRoot.getElementById(attr.name);
+ if (!target)
+ continue;
+
+ switch (attr.name) {
+ case "username":
+ case "name":
+ target.innerText = attr.value;
+ break;
+ case "avatar":
+ target.src = attr.value;
+ break;
+ }
+
+ }
+ }
+}
+
+// define auth-user tag name
+customElements.define(AuthedUser.tagName, AuthedUser);
diff --git a/Chat Application/components/chat-box.js b/Chat Application/components/chat-box.js
new file mode 100644
index 0000000..72e2acd
--- /dev/null
+++ b/Chat Application/components/chat-box.js
@@ -0,0 +1,511 @@
+class ChatBox extends Component {
+
+ /**
+ * define attributes types
+ * @returns {Object}
+ */
+ static get attrTypes() {
+ return {
+ hidden: {
+ type: "boolean",
+ observe: true
+ },
+ };
+ }
+
+ /**
+ * generate observed attributes array from attr types object
+ */
+ static get observedAttributes() {
+ return super.getObservedAttrs(ChatBox.attrTypes);
+ }
+
+ /**
+ * generate tag-name from component class name
+ * @returns {string}
+ */
+ static get tagName() {
+ return super.generateTagName(ChatBox.name);
+ }
+
+ /**
+ * styles of component
+ * @returns {string}
+ */
+ static get style() {
+ return (``)
+ }
+
+ /**
+ * html template of component
+ * @returns {string}
+ */
+ static get template() {
+ return (`
+
+ ${ChatBox.style}
+
+
+
+
+
+
+
+
+
+
+
Hi there! \n Select a chat to start messaging.
+
This app is one of the projects that developed under name
+
+ practical front-end projects for educational purposes.
+ This project developed with Web Components without any third-party libs.
+
+
+ `)
+ }
+
+ constructor() {
+ super({
+ attrTypes: ChatBox.attrTypes,
+ template: ChatBox.template
+ });
+
+ this._chatList = this.shadowRoot.getElementById("chat-list");
+ this._newMessageBox = this.shadowRoot.querySelector("new-message");
+ this._scrollToBottomBtn = this.shadowRoot.getElementById("scroll-to-bottom");
+ this._activeChatElm = this.shadowRoot.querySelector("active-chat");
+ }
+
+ // call on mounting
+ onMount() {
+ this.initListeners();
+ }
+
+ // call on un-mounting
+ onUnmount() {
+ this.removeListeners();
+ }
+
+ // call on attributes changed
+ attributeChangedCallback(attrName, oldValue, newValue) {
+ if (oldValue === newValue)
+ return;
+
+ if (attrName === "readonly")
+ this.checkNewMessageBoxVisibility();
+ }
+
+ /**
+ * reflect the readOnly attr on HTML tag
+ * @param value
+ */
+ set readOnly(value) {
+ if (value) {
+ this.setAttribute('readonly', '');
+
+ } else {
+ this.removeAttribute('readonly');
+ }
+ }
+
+ get readOnly() {
+ return this.hasAttribute('readonly');
+ }
+
+ /**
+ * use this to set active chat of chatBox
+ * @param chat
+ */
+ setActiveChat(chat) {
+ // if chat is not valid do nothing
+ if (!chat || !chat.id)
+ return;
+
+ this._activeChat = chat;
+
+ // make chatBox visibility visible anyway
+ // clear the chats lis
+ // and clear the lastMessage flag
+ this.hidden = false;
+ this._chatList.innerHTML = '';
+ this.lastMessage = null;
+
+ // render the chatBox header with activeChat
+ this.renderChatBoxHeader();
+ }
+
+ /**
+ * active chat getter
+ * @returns {*|{id}}
+ */
+ get activeChat() {
+ return this._activeChat;
+ }
+
+ /**
+ * reflect the hidden attr on HTML tag
+ * @param value
+ */
+ set hidden(value) {
+ if (value) {
+ this.setAttribute("hidden", '');
+ } else {
+ this.removeAttribute("hidden");
+ }
+ }
+
+ get hidden() {
+ return this.hasAttribute("hidden")
+ }
+
+ /**
+ * Initialize required listeners
+ */
+ initListeners() {
+ // listen for user sign-in
+ this.on(APP_EVENTS.USER_SIGN_IN, this._userSignIn.bind(this));
+ // listen for new messages that send from authed user
+ this._newMessageBox.on(APP_EVENTS.AUTHED_USER_NEW_MESSAGE, this._onAuthedMessageReceive.bind(this));
+
+ // control the visibility and the behavior of scrollToBottom button in chats list
+ this._chatList.addEventListener("scroll", this.checkScrollToBottomBtnVisibility.bind(this));
+ this._scrollToBottomBtn.addEventListener("click", this.scrollToEnd.bind(this));
+
+ this._activeChatElm.on(APP_EVENTS.CHAT_BOX_BACK_CLICKED, this._onBackBtnClicked.bind(this));
+ }
+
+ /**
+ * remove added listeners
+ */
+ removeListeners() {
+ this._newMessageBox.off(APP_EVENTS.AUTHED_USER_NEW_MESSAGE, this._onAuthedMessageReceive.bind(this));
+ this.off(APP_EVENTS.USER_SIGN_IN, this._userSignIn.bind(this));
+ this._scrollToBottomBtn.removeEventListener("click", this.scrollToEnd.bind(this));
+ this._chatList.removeEventListener("scroll", this.checkScrollToBottomBtnVisibility.bind(this));
+ }
+
+ /**
+ * Render message object and add to chats-box
+ * @param sender
+ * @param text
+ * @param audio
+ * @param time
+ * @param forceScrollToEnd
+ */
+ renderMessage({sender, text, audio, time}, forceScrollToEnd = false) {
+ // if the message is invalid do nothing
+ if (!sender || (!text && !audio) || !time || !(time instanceof Date))
+ return;
+
+ const isFromAuthedUser = sender === this._authedUserId;
+ const isSameSender = this.lastMessage && this.lastMessage.sender === sender;
+
+ // get time string to show in message bubble
+ const timeToShow = `${time.getHours()}:${time.getMinutes()}`;
+
+ // create component element and set attributes
+ const msg = document.createElement("chat-message");
+
+ if (text)
+ msg.text = text;
+
+ if (audio)
+ msg.audio = audio;
+
+ msg.setAttribute("position", isFromAuthedUser ? "right" : "left");
+ msg.setAttribute("sender", sender);
+ msg.setTimeObject(time);
+ msg.setAttribute("time", timeToShow);
+ msg.setAttribute("title", time.toLocaleString());
+ msg.isLastInGroup = true;
+
+ // check if the message is the last one that sender has been sent
+ // it's just for styling bubbles
+ if (this.lastMessage && isSameSender)
+ this.lastMessage.isLastInGroup = false;
+
+ // render the day date and add to chat-box if needs
+ if (!this.lastMessage || this._isMessageForDifferentDay(time)) {
+ this._appendDateToChatList(time);
+ }
+
+ // check if the user not scrolled top
+ // and the last message in chatBox is observable
+ const isLastMessageInView = this._chatList.scrollTop >= this._chatList.scrollHeight - this._chatList.clientHeight;
+
+ // set lastMessage flat and append createdElement to chatList
+ this.lastMessage = msg;
+ this._chatList.appendChild(msg);
+
+ // clear the newMessage input box if the sender is the authedUser
+ if (isFromAuthedUser)
+ this._newMessageBox.clear();
+
+ if (isLastMessageInView || forceScrollToEnd)
+ this.scrollToEnd();
+
+ // check the viability of scrollToBottom button
+ this.checkScrollToBottomBtnVisibility();
+ }
+
+ /**
+ * fire when a user signed in and set the this._authedUserId
+ * @param detail
+ * @private
+ */
+ _userSignIn({detail}) {
+ this._authedUserId = detail.id;
+ }
+
+ /**
+ * fires when back btn clicked in active-chat component
+ * @private
+ */
+ _onBackBtnClicked() {
+ this.hidden = true;
+
+ // send action to parent
+ this.emit(APP_EVENTS.CHAT_BOX_BACK_CLICKED)
+ }
+
+ /**
+ * fire when a signed user send a message
+ * this will render the message and emit it to parent
+ * @param detail
+ * @private
+ */
+ _onAuthedMessageReceive({detail}) {
+ if (!this.activeChat)
+ return;
+
+ detail.toChat = this.activeChat.id;
+ detail.sender = this._authedUserId;
+ this.renderMessage(detail, true);
+ this.emit(APP_EVENTS.AUTHED_USER_NEW_MESSAGE, detail);
+ }
+
+ /**
+ * compare the time of the last received message with this._lastMessage flag
+ * @param time
+ * @returns {boolean}
+ * @private
+ */
+ _isMessageForDifferentDay(time) {
+ if (!this.lastMessage || !time)
+ return false;
+
+ return time.toDateString() !== this.lastMessage.timeObject.toDateString();
+ }
+
+ /**
+ * this generate the day title and a chat-day element put the time on element
+ * and append it to chat-list
+ * @param time
+ * @private
+ */
+ _appendDateToChatList(time) {
+ const monthNames = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
+ this.assert(time, "Message time not passed");
+
+ let dayTitle = `${monthNames[time.getMonth()]} ${time.getDay()}, ${time.getFullYear()}`;
+ if (time.toDateString() === new Date().toDateString())
+ dayTitle = "Today";
+
+ const dateNode = document.createElement("div");
+ dateNode.classList.add("chat-day");
+ dateNode.innerHTML = `${dayTitle}`;
+
+ this._chatList.appendChild(dateNode);
+ }
+
+ /**
+ * check the scroll height and scrollTop of chat-list
+ * and toggle the visibility of the scrollToBottom button
+ * @param e
+ */
+ checkScrollToBottomBtnVisibility(e) {
+ if (this._chatList.scrollTop + 200 < this._chatList.scrollHeight - this._chatList.clientHeight) {
+ this._scrollToBottomBtn.classList.add("show")
+ } else {
+ this._scrollToBottomBtn.classList.remove("show");
+ }
+ }
+
+ /**
+ * scroll the chat list to the end
+ */
+ scrollToEnd() {
+ this._chatList.scrollTo({
+ top: this._chatList.scrollHeight,
+ })
+ }
+
+ /**
+ * render the activeChat component of the chatBox using this._activeChat
+ */
+ renderChatBoxHeader() {
+ const activeChatNode = this.shadowRoot.querySelector("active-chat");
+ this.assert(activeChatNode, "The active-chat node not found in chat-box");
+
+ activeChatNode.setAttribute("id", this._activeChat.id);
+ activeChatNode.setAttribute("name", this._activeChat.name);
+ activeChatNode.setAttribute("avatar", this._activeChat.avatar || "");
+ if (this._activeChat.online)
+ activeChatNode.setAttribute("online", '');
+ else
+ activeChatNode.removeAttribute("online");
+ }
+
+ /**
+ * check the new message box visibility
+ */
+ checkNewMessageBoxVisibility() {
+
+ // remove newMessageBox component if
+ // the chatBox is readOnly for logged in user
+ if (this.readOnly) {
+ this._newMessageBox.remove();
+ }
+
+ }
+
+}
+
+// define chat-box tag name
+customElements.define(ChatBox.tagName, ChatBox);
diff --git a/Chat Application/components/chat-list-item.js b/Chat Application/components/chat-list-item.js
new file mode 100644
index 0000000..8d8caee
--- /dev/null
+++ b/Chat Application/components/chat-list-item.js
@@ -0,0 +1,378 @@
+class ChatListItem extends Component {
+
+ /**
+ * define attributes types
+ * @returns {Object}
+ */
+ static get attrTypes() {
+ return {
+ id: {
+ type: "string",
+ required: true,
+ },
+ name: {
+ type: "string",
+ required: true,
+ observe: true
+ },
+ desc: {
+ type: "string",
+ observe: true
+ },
+ avatar: {
+ type: "string",
+ observe: true
+ },
+ lastseen: {
+ type: "string",
+ observe: true
+ },
+ unreadcount: {
+ type: "number",
+ observe: true
+ },
+ online: {
+ type: "boolean",
+ observe: true
+ },
+ };
+ }
+
+ /**
+ * generate observed attributes array from attr types object
+ */
+ static get observedAttributes() {
+ return super.getObservedAttrs(ChatListItem.attrTypes);
+ }
+
+ /**
+ * generate tag-name from component class name
+ * @returns {string}
+ */
+ static get tagName() {
+ return super.generateTagName(ChatListItem.name);
+ }
+
+ /**
+ * styles of component
+ * @returns {string}
+ */
+ static get style() {
+ return (``)
+ }
+
+ /**
+ * html template of component
+ * @returns {string}
+ */
+ static get template() {
+ return (`
+
+ ${ChatListItem.style}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `)
+ }
+
+ constructor() {
+ super({
+ attrTypes: ChatListItem.attrTypes,
+ template: ChatListItem.template
+ });
+
+ // render component
+ this.render();
+ }
+
+ // call on mounting
+ onMount() {
+ this.initListeners();
+ }
+
+ // call on un-mounting
+ onUnmount() {
+ this.removeListeners();
+ }
+
+ // call on attributes changed
+ attributeChangedCallback(attrName, oldValue, newValue) {
+ if (oldValue === newValue)
+ return;
+
+ // re-render component
+ this.render();
+ }
+
+ /**
+ * reflect the selected attr on HTML tag
+ * @param value
+ */
+ set selected(value) {
+ if (value) {
+ this.setAttribute('selected', '');
+
+ } else {
+ this.removeAttribute('selected');
+ }
+ }
+
+ get selected() {
+ return this.hasAttribute('selected');
+ }
+
+ // call on attributes changed
+ incrementUnreadCount() {
+ let count = 0;
+ if (this.getAttribute("unreadcount"))
+ count = parseInt(this.getAttribute("unreadcount"));
+
+ this.setAttribute('unreadcount', count + 1);
+ }
+
+ /**
+ * call this when you want to reset the unread messages count
+ */
+ markAllAsRead() {
+ this.setAttribute('unreadcount', '0');
+ }
+
+ /**
+ * reflect the unread attr on HTML tag
+ * unread counter badge only shows on items that have truly unread attr
+ * @param value
+ */
+ set unread(value) {
+ if (value)
+ this.setAttribute('unread', '');
+ else
+ this.removeAttribute('unread');
+ }
+
+ get unread() {
+ return this.hasAttribute('unread');
+ }
+
+ /**
+ * reflect the online attr on HTML tag
+ * @param value
+ */
+ set online(value) {
+ if (value)
+ this.setAttribute('online', '');
+ else
+ this.removeAttribute('online');
+ }
+
+ get online() {
+ return this.hasAttribute('online');
+ }
+
+ /**
+ * Initialize required listeners
+ */
+ initListeners() {
+ this.on("click", this._onClick)
+ }
+
+ /**
+ * remove added listeners
+ */
+ removeListeners() {
+ this.off("click", this._onClick)
+ }
+
+ /**
+ * this method fire when chat-list-item clicked
+ * if the component is not disabled, it emit the
+ * clicked item id to above component (chatList) and
+ * set the selected attr
+ * @param e
+ * @private
+ */
+ _onClick(e) {
+ e.preventDefault();
+ if (this.disabled) {
+ return;
+ }
+
+ // send selected chat-item id to parent component
+ this.emit(APP_EVENTS.CHAT_CLICKED, {id: this.getAttribute("id")});
+ this.selected = true;
+ // reset unread counter of this chat
+ this.markAllAsRead();
+ }
+
+ /**
+ * render component according to template and attributes
+ */
+ render() {
+
+ // remove component if id not passed
+ if (!("id" in this.attributes)) {
+ this.remove()
+ }
+
+ // check the existence of avatar
+ // fetch first char of name to show if avatar not passed
+ if (!this.getAttribute("avatar")) {
+ // put first char of name when avatar not passed
+ const name = (this.getAttribute("name") || "").toUpperCase();
+ this.shadowRoot.querySelector(".char-avatar").innerText = name.substr(0, 1);
+ }
+
+ // loop over attributes and set all
+ for (let attr of this.attributes) {
+ const target = this.shadowRoot.getElementById(attr.name);
+ if (target)
+ target.innerText = attr.value;
+
+ switch (attr.name) {
+ case "avatar":
+ target.src = attr.value;
+ break;
+
+ case "unreadcount":
+ this.unread = parseInt(attr.value) > 0;
+ break;
+
+ }
+
+ }
+ }
+
+}
+
+// define chat-list-item tag name
+customElements.define(ChatListItem.tagName, ChatListItem);
diff --git a/Chat Application/components/chat-message.js b/Chat Application/components/chat-message.js
new file mode 100644
index 0000000..6f9e7d1
--- /dev/null
+++ b/Chat Application/components/chat-message.js
@@ -0,0 +1,388 @@
+class ChatMessage extends Component {
+
+ /**
+ * define attributes types
+ * @returns {Object}
+ */
+ static get attrTypes() {
+ return {
+ sender: {
+ type: "string",
+ observe: true
+ },
+ position: {
+ type: "string",
+ observe: true
+ },
+ lastingroup: {
+ type: "boolean",
+ observe: true
+ },
+ text: {
+ type: "string",
+ observe: true
+ },
+ audio: {
+ type: "string",
+ observe: true
+ },
+ time: {
+ type: "string",
+ observe: true
+ },
+ };
+ }
+
+ /**
+ * generate observed attributes array from attr types object
+ */
+ static get observedAttributes() {
+ return super.getObservedAttrs(ChatMessage.attrTypes);
+ }
+
+ /**
+ * generate tag-name from component class name
+ * @returns {string}
+ */
+ static get tagName() {
+ return super.generateTagName(ChatMessage.name);
+ }
+
+ /**
+ * styles of component
+ * @returns {string}
+ */
+ static get style() {
+ return (``)
+ }
+
+ /**
+ * html template of component
+ * @returns {string}
+ */
+ static get template() {
+ return (`
+
+ ${ChatMessage.style}
+
+
+
+ 00:00
+
+
+
+ `)
+ }
+
+ constructor() {
+ super({
+ attrTypes: ChatMessage.attrTypes,
+ template: ChatMessage.template
+ });
+
+ this._textElement = this.shadowRoot.getElementById("text");
+ this._timeElement = this.shadowRoot.getElementById("time");
+
+ this._audioMessageCtrl = this.shadowRoot.querySelector(".audio-control");
+ this._audioPlayBtn = this.shadowRoot.getElementById("audio-play");
+ this._audioDurationElm = this.shadowRoot.getElementById("audio-duration");
+
+ }
+
+ onMount() {
+ this._audioPlayBtn.addEventListener("click", this._onAudioPlayBtnClick.bind(this))
+ }
+
+ onUnmount() {
+ this._audioPlayBtn.removeEventListener("click", this._onAudioPlayBtnClick.bind(this))
+ }
+
+ // call on attributes changed
+ attributeChangedCallback(attrName, oldValue, newValue) {
+ if (oldValue === newValue)
+ return;
+
+ // re-render component
+ this.render();
+ }
+
+ /**
+ * reflect the text attr on HTML tag
+ * @param value
+ */
+ set text(value) {
+ if (value) {
+ this.setAttribute('text', value);
+ } else {
+ this.removeAttribute('text');
+ }
+ }
+
+ get text() {
+ return this.getAttribute('text');
+ }
+
+ /**
+ * reflect the audio attr on HTML tag
+ * @param audioObj
+ */
+ set audio(audioObj) {
+ if (audioObj) {
+
+ // set received object as _audio and do some process on it and
+ // add some listeners to control the play status or timing
+ this._audio = audioObj;
+ const {audio, duration, audioUrl} = audioObj;
+ this._audioElement = audio || new Audio(audioUrl);
+
+ this._audioDurationElm.innerText = duration;
+
+ const onPause = () => {
+ // handle icon changing for btn
+ this._audioPlayBtn.classList.remove("playing");
+ };
+
+ const onEnded = () => {
+ // reset the duration text and handle icon changing for btn
+ this._audioDurationElm.innerText = duration;
+ this._audioPlayBtn.classList.remove("playing");
+ };
+
+ const onTimeUpdate = () => {
+ // calc duration and update the text when audio is playing
+ const passedTime = Recorder.secToTimeStr(this._audioElement.currentTime);
+ this._audioDurationElm.innerText = `${passedTime} / ${duration}`;
+ };
+
+ const onPlay = () => {
+ this._audioPlayBtn.classList.add("playing");
+ };
+ this._audioElement.onended = onEnded;
+ this._audioElement.onpause = onPause;
+ this._audioElement.onplay = onPlay;
+ this._audioElement.ontimeupdate = onTimeUpdate;
+
+ } else {
+ // set the _audio as null to remove it from component
+ this._audio = null;
+ }
+ }
+
+ get audio() {
+ return this._audio;
+ }
+
+ setTimeObject(value) {
+ this._timeObject = value;
+ }
+
+ get timeObject() {
+ return this._timeObject;
+ }
+
+ /**
+ * reflect the time attr on HTML tag
+ * @param value
+ */
+ set time(value) {
+ if (value) {
+ this.setAttribute('time', value);
+ } else {
+ this.removeAttribute('time');
+ }
+ }
+
+ get time() {
+ return this.getAttribute('time');
+ }
+
+ /**
+ * reflect the sender attr on HTML tag
+ * @param value
+ */
+ set sender(value) {
+ if (value) {
+ this.setAttribute('sender', value);
+ } else {
+ this.removeAttribute('sender');
+ }
+ }
+
+ get sender() {
+ return this.getAttribute('sender');
+ }
+
+ /**
+ * reflect the islastingroup attr on HTML tag
+ * @param value
+ */
+ set isLastInGroup(value) {
+ if (value) {
+ this.setAttribute('lastingroup', '');
+ } else {
+ this.removeAttribute('lastingroup');
+ }
+ }
+
+ get isLastInGroup() {
+ return this.hasAttribute('lastingroup');
+ }
+
+ /**
+ * fire when audio message play btn clicked and toggle the playing status
+ * @private
+ */
+ _onAudioPlayBtnClick() {
+ if (!this._audioElement)
+ return;
+
+ if (this._audioElement.paused) {
+ this._audioElement.play();
+ } else {
+ this._audioElement.pause();
+ }
+ }
+
+ /**
+ * render message by attributes
+ */
+ render() {
+ this._textElement.innerHTML = this.text;
+ this._timeElement.innerHTML = this.time;
+ this._audioMessageCtrl.style.display = this.audio ? "flex" : "none";
+ }
+
+}
+
+// define chat-message tag name
+customElements.define(ChatMessage.tagName, ChatMessage);
diff --git a/Chat Application/components/chats-list.js b/Chat Application/components/chats-list.js
new file mode 100644
index 0000000..4de2f12
--- /dev/null
+++ b/Chat Application/components/chats-list.js
@@ -0,0 +1,322 @@
+class ChatsList extends Component {
+
+ /**
+ * define attributes types
+ * @returns {Object}
+ */
+ static get attrTypes() {
+ return {};
+ }
+
+ /**
+ * generate observed attributes array from attr types object
+ */
+ static get observedAttributes() {
+ return super.getObservedAttrs(ChatsList.attrTypes);
+ }
+
+ /**
+ * generate tag-name from component class name
+ * @returns {string}
+ */
+ static get tagName() {
+ return super.generateTagName(ChatsList.name);
+ }
+
+ /**
+ * styles of component
+ * @returns {string}
+ */
+ static get style() {
+ return (``)
+ }
+
+ /**
+ * html template of component
+ * @returns {string}
+ */
+ static get template() {
+ return (`
+
+ ${ChatsList.style}
+
+
+
+
+
+
+
+ `)
+ }
+
+ constructor() {
+ super({
+ attrTypes: ChatsList.attrTypes,
+ template: ChatsList.template
+ });
+
+ this.chatsWrapper = this.shadowRoot.getElementById("chats-wrapper");
+ this._searchInput = this.shadowRoot.getElementById("search-input");
+ this._searchDebounceFlag = null;
+ this._selectedChat = null;
+
+ }
+
+ // call on mounting
+ onMount() {
+ this.initListeners();
+ }
+
+ // call on un-mounting
+ onUnmount() {
+ this.removeListeners();
+ }
+
+ /**
+ * Initialize required listeners
+ */
+ initListeners() {
+ document.addEventListener("keydown", this._onKeyDown.bind(this));
+ this._searchInput.addEventListener("input", this._onSearch.bind(this));
+ this.on(APP_EVENTS.NEW_MESSAGE_RECEIVE, this._onNewMessageReceive.bind(this));
+ this.on(APP_EVENTS.DESELECT_SELECTED_CHAT, this._onChatDeselect.bind(this));
+ }
+
+ /**
+ * remove added listeners
+ */
+ removeListeners() {
+ document.removeEventListener("keydown", this._onKeyDown.bind(this))
+ }
+
+ /**
+ * Listen document keypress to handle Ctrl + F keys and focus on search box
+ * @param e
+ * @private
+ */
+ _onKeyDown(e) {
+ if (e.ctrlKey && e.key === "f") {
+ e.preventDefault();
+ this._searchInput.focus();
+ }
+ }
+
+ /**
+ * fires on search input change and call render on every 300ms
+ * @param e
+ * @private
+ */
+ _onSearch(e) {
+ if (this._searchDebounceFlag)
+ clearTimeout(this._searchDebounceFlag);
+
+ this._searchDebounceFlag = setTimeout(() => {
+ this.render();
+ }, 300);
+ }
+
+ /**
+ * fires when a new message received, and handle the
+ * position changing of chat that send/receive message
+ * also, handle the unread message count of the chat.
+ * @param detail
+ * @private
+ */
+ _onNewMessageReceive({detail}) {
+ if (!detail.sender)
+ return;
+
+ // actually here we find the target chat and
+ // remove it's element from chats-list DOM,
+ // update the unread messages count,
+ // generate a new element for it and append it
+ // as the first child of chats-list
+ const senderChatIndex = this._chats.findIndex(c => c.id === detail.sender);
+ const senderChat = this._chats.splice(senderChatIndex, 1)[0];
+
+ // update unread messages count
+ senderChat.unreadcount = (+senderChat.unreadcount || 0) + 1;
+
+ // check the selection status of chat to apply it on the new element
+ const alreadySelected = senderChat.elm.selected;
+
+ // remove previous element of chat
+ senderChat.elm.remove();
+
+ // generate new element for updated chat
+ senderChat.elm = this.generateChatListItem(senderChat);
+
+ senderChat.elm.selected = alreadySelected;
+
+ // push the updated chat to the start of the chats array
+ this._chats.unshift(senderChat);
+
+ // append the updated chat to chatsList as the first child in the list
+ this.appendChatToList(senderChat, true);
+ }
+
+ /**
+ * this method call from parent component to set the chats array
+ * every time, it makes the this.chatsWrapper empty and calls the render method
+ * @param chats
+ */
+ setChats(chats) {
+ this._chats = chats;
+ this.chatsWrapper.innerHTML = '';
+ this.render();
+ }
+
+ /**
+ * this method generate chat-list-item component for chat object
+ * @param chat
+ * @returns {HTMLElement}
+ */
+ generateChatListItem(chat) {
+ const chatListItem = document.createElement("chat-list-item");
+ chatListItem.setAttribute("id", chat.id);
+ chatListItem.setAttribute("title", chat.name);
+ chatListItem.setAttribute("name", chat.name);
+ chatListItem.setAttribute("avatar", chat.avatar);
+ chatListItem.setAttribute("desc", chat.desc || chat.username);
+ chatListItem.setAttribute("lastseen", chat.lastseen);
+ chatListItem.setAttribute("unreadcount", chat.unreadcount);
+ if (chat.online)
+ chatListItem.setAttribute("online", '');
+
+ // set the click listener of newly created component
+ chatListItem.on(APP_EVENTS.CHAT_CLICKED, this._onChatClicked.bind(this));
+
+ return chatListItem
+ }
+
+ /**
+ * this method calls when component received a message to clear selection
+ * @private
+ */
+ _onChatDeselect() {
+ if (!this._selectedChat)
+ return;
+
+ this._selectedChat.elm.selected = false;
+ this._selectedChat = null;
+
+ this.render();
+ }
+
+ /**
+ * fires when a chat-item-list has been clicked
+ * @param detail
+ * @private
+ */
+ _onChatClicked({detail}) {
+
+ this._selectedChat = null;
+ // we loop over chats to reset unread message counter
+ // of clicked chat and remove selection of other chats
+ this._chats.map(chat => {
+ if (chat.id !== detail.id) {
+ chat.elm.selected = false;
+ this._selectedChat = chat;
+ } else {
+ chat.unreadcount = 0;
+ }
+ });
+
+ // send the clicked chat-item details to parent component
+ this.emit(APP_EVENTS.CHAT_SELECTED, detail)
+ }
+
+ /**
+ * this method controls the adding of chat to the this.chatsWrapper element
+ * also, the searching action happens here. it filters the chat with the value of search input
+ * @param chat
+ * @param appendFirst
+ */
+ appendChatToList(chat, appendFirst = false) {
+ const searchTrend = (this._searchInput.value || '').toLowerCase();
+
+ // check if the chat.name or chat.username contains the value of search input or not
+ if (~chat.name.toLowerCase().indexOf(searchTrend)
+ || ~chat.username.toLowerCase().indexOf(searchTrend)) {
+
+ if (!appendFirst) { // append at the end of the list
+ this.chatsWrapper.appendChild(chat.elm);
+
+ } else { // append as the first child of the list
+ this.chatsWrapper.insertBefore(chat.elm, this.chatsWrapper.firstChild);
+ }
+ }
+ }
+
+ /**
+ * render component according to template and attributes
+ */
+ render() {
+ this.chatsWrapper.innerHTML = '';
+ this._chats = this._chats.map(chat => {
+
+ // generate chat-list-item component for the chat object
+ chat.elm = this.generateChatListItem(chat);
+
+ // append chat to list
+ this.appendChatToList(chat);
+
+ return chat
+ })
+ }
+
+}
+
+// define chats-list tag name
+customElements.define(ChatsList.tagName, ChatsList);
diff --git a/Chat Application/components/component.js b/Chat Application/components/component.js
new file mode 100644
index 0000000..8deb519
--- /dev/null
+++ b/Chat Application/components/component.js
@@ -0,0 +1,194 @@
+/**
+ * Main component class that other components extends from it
+ */
+class Component extends HTMLElement {
+
+ constructor({attrTypes, template, shadowMode = "open"}) {
+ super();
+
+ this.attrTypes = attrTypes;
+ this._template = template;
+ this._shadowMode = shadowMode;
+
+ // his method attach template to root if exists
+ this.makeShadow();
+ }
+
+ /**
+ * this method fire when component attached to DOM
+ */
+ connectedCallback() {
+ // Check attributes types for each component
+ this.checkAttrs();
+ // and call onMount method
+ // onMount is the only method that call inside connectedCallback
+ if (this.onMount && typeof this.onMount === "function")
+ this.onMount();
+ }
+
+ /**
+ * This method fire when component removed from DOM
+ */
+ disconnectedCallback() {
+ // call onMount method of component.
+ // onUnmount is the only method that call inside disconnectedCallback
+ if (this.onUnmount && typeof this.onUnmount === "function")
+ this.onUnmount();
+ }
+
+ /**
+ * parse attribute types according to passed types
+ * @param value
+ * @param target
+ * @returns {(number | boolean | string)|*}
+ */
+ parseAttrType(value, target) {
+ if (value === void 0 || value === null)
+ return value;
+
+ switch (target) {
+ case "n":
+ case "number":
+ value = value.indexOf(".") ? parseFloat(value) : parseInt(value);
+ break;
+
+ case "o":
+ case "object":
+ value = JSON.parse(value);
+ break;
+
+ case "b":
+ case "bool":
+ case "boolean":
+ value = Boolean(value);
+ break;
+
+ default:
+ value = value.toString()
+ }
+
+ return value;
+ }
+
+ /**
+ * check type of attributes
+ */
+ checkAttrs() {
+ if (!this.attrTypes)
+ return;
+
+ for (let [attr, details] of Object.entries(this.attrTypes)) {
+
+ let value = this.parseAttrType(this.getAttribute(attr), details.type);
+
+ // replace attribute with parsed value if value is not null
+ if (value !== null)
+ this.setAttribute(attr, value || "");
+
+ if (details.required)
+ this.assert(!!value,
+ `"${attr}" attr is knows as required but not passed to component.`);
+
+ if (value !== null && details.type) {
+ this.assert(typeof value === details.type,
+ `The type of "${attr}" attr must be ${details.type}.`);
+ }
+
+ }
+ }
+
+ /**
+ * to check condition and fire event if its false
+ * @param condition
+ * @param error
+ */
+ assert(condition, error) {
+ if (!condition)
+ console.error(`Warning: ${error}`)
+ }
+
+ /**
+ * parse html and get content as html
+ * @returns {Node}
+ */
+ parseTemplate() {
+ let parser = new DOMParser();
+ const doc = parser.parseFromString(this._template, 'text/html');
+
+ return doc.querySelector("template").content.cloneNode(true);
+ }
+
+ /**
+ * attach template to shadow
+ */
+ makeShadow() {
+ // get template note
+ const template = this.parseTemplate();
+
+ // generate shadow dom
+ this.attachShadow({mode: this._shadowMode}).appendChild(template);
+ }
+
+ /**
+ * dispatch an event
+ * @param event
+ * @param detail
+ */
+ emit(event, detail) {
+ this.dispatchEvent(new CustomEvent(event, {detail}));
+ }
+
+ /**
+ * Add listener to the host
+ * @param event
+ * @param callback
+ */
+ on(event, callback) {
+ this.shadowRoot.host.addEventListener(event, callback.bind(this))
+ }
+
+ /**
+ * Remove listener of the host
+ * @param event
+ * @param callback
+ */
+ off(event, callback) {
+ this.shadowRoot.host.removeEventListener(event, callback.bind(this))
+ }
+
+ get disabled() {
+ return this.hasAttribute('disabled');
+ }
+
+ /**
+ * reflect the disabled attr on HTML tag
+ * @param val
+ */
+ set disabled(val) {
+ const isDisabled = Boolean(val);
+ if (isDisabled)
+ this.setAttribute('disabled', '');
+ else
+ this.removeAttribute('disabled');
+ }
+
+ /**
+ * generate tag-name from component class name
+ * @returns {string}
+ */
+ static generateTagName(className) {
+ return className.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
+ }
+
+ /**
+ * generate list of attrs has observe:true
+ * @param attrTypes {Object}
+ * @returns {string[]}
+ */
+ static getObservedAttrs(attrTypes = {}) {
+ return Object.entries(attrTypes || {})
+ .filter(([_, details]) => details.observe)
+ .map(([attr, _]) => attr);
+ }
+
+}
diff --git a/Chat Application/components/new-message.js b/Chat Application/components/new-message.js
new file mode 100644
index 0000000..2a43110
--- /dev/null
+++ b/Chat Application/components/new-message.js
@@ -0,0 +1,323 @@
+class NewMessage extends Component {
+
+ /**
+ * define attributes types
+ * @returns {Object}
+ */
+ static get attrTypes() {
+ return {};
+ }
+
+ /**
+ * generate observed attributes array from attr types object
+ */
+ static get observedAttributes() {
+ return super.getObservedAttrs(NewMessage.attrTypes);
+ }
+
+ /**
+ * generate tag-name from component class name
+ * @returns {string}
+ */
+ static get tagName() {
+ return super.generateTagName(NewMessage.name);
+ }
+
+ /**
+ * styles of component
+ * @returns {string}
+ */
+ static get style() {
+ return (``)
+ }
+
+ /**
+ * html template of component
+ * @returns {string}
+ */
+ static get template() {
+ return (`
+
+ ${NewMessage.style}
+
+
+
+
+
+
+
+
+ `)
+ }
+
+ constructor() {
+ super({
+ attrTypes: NewMessage.attrTypes,
+ template: NewMessage.template
+ });
+
+ this._textSendButton = this.shadowRoot.getElementById("text-send-btn");
+ this._soundRecordBtn = this.shadowRoot.getElementById("sound-record-btn");
+ this._textarea = this.shadowRoot.getElementById("new-message-input");
+
+ // check if audio device is exists and make mic button visible
+ this.setMicBtnVisibility(Recorder.isMicAvailable());
+
+ // create recorder instance to control recording audio message
+ this._recorder = new Recorder();
+ }
+
+ /**
+ * toggle microphone button visibility
+ * @param showMic
+ */
+ setMicBtnVisibility(showMic) {
+ this._soundRecordBtn.style.display = showMic ? "flex" : "none";
+ this._textSendButton.style.display = showMic ? "none" : "flex";
+ }
+
+ // call on mounting
+ onMount() {
+ this.initListeners();
+ }
+
+ // call on un-mounting
+ onUnmount() {
+ this.removeListeners();
+ }
+
+ /**
+ * Initialize required listeners
+ */
+ initListeners() {
+ this.addEventListener("keydown", this._onKeyPress.bind(this));
+ this._textarea.addEventListener("input", this._onType.bind(this));
+ this._textSendButton.addEventListener("click", this._onTextSend.bind(this));
+ this._soundRecordBtn.addEventListener("mousedown", this._onRecordStart.bind(this));
+ this._soundRecordBtn.addEventListener("touchstart", this._onRecordStart.bind(this));
+ this._soundRecordBtn.addEventListener("mouseup", this._onRecordStop.bind(this));
+ this._soundRecordBtn.addEventListener("touchend", this._onRecordStop.bind(this));
+ }
+
+ /**
+ * remove added listeners
+ */
+ removeListeners() {
+ this.removeEventListener("keydown", this._onKeyPress.bind(this));
+ this._textarea.removeEventListener("input", this._onType.bind(this));
+ this._textSendButton.removeEventListener("click", this._onTextSend.bind(this));
+ this._soundRecordBtn.removeEventListener("mousedown", this._onRecordStart.bind(this));
+ this._soundRecordBtn.removeEventListener("touchstart", this._onRecordStart.bind(this));
+ this._soundRecordBtn.removeEventListener("mouseup", this._onRecordStop.bind(this));
+ this._soundRecordBtn.removeEventListener("touchend", this._onRecordStop.bind(this));
+
+ }
+
+ /**
+ * fires when a key pressed and handle the Ctrl+Enter press
+ * if the user pressed Ctrl+Enter keys, this calls the send method
+ * @param e
+ * @private
+ */
+ _onKeyPress(e) {
+ if (e.ctrlKey && e.key.toLowerCase() === "enter") {
+ this._onTextSend();
+ }
+ }
+
+ /**
+ * fires when the value of textarea changes,
+ * to toggle the visibility of text message sending button
+ * @param e
+ * @private
+ */
+ _onType(e) {
+ this.setMicBtnVisibility(!e.target.value);
+ }
+
+ /**
+ * fires when you want to send a new message,
+ * when the textarea has a valid value, this
+ * emit the message details to the parent component
+ * @private
+ */
+ _onTextSend() {
+
+ if (!this._textarea.value) {
+ this._textarea.focus();
+ return;
+ }
+
+ this.emit(APP_EVENTS.AUTHED_USER_NEW_MESSAGE, {
+ text: this._textarea.value.trim(),
+ time: new Date(),
+ })
+ }
+
+ /**
+ * fires when mic btn pressed and hold, it means to start the recording
+ * @private
+ */
+ _onRecordStart() {
+ // add "recording" class to record btn to start animation around the btn
+ this._soundRecordBtn.classList.add("recording");
+ this._recorder.start();
+ }
+
+ /**
+ * fires when mic btn released, it means to stop the recording
+ * @returns {Promise}
+ * @private
+ */
+ async _onRecordStop() {
+ // remove "recording" class to record btn to stop btn animation
+ this._soundRecordBtn.classList.remove("recording");
+
+ // generate the audioObj of recording and pass it as new message to parent component
+ let audio = await this._recorder.stop();
+ this.emit(APP_EVENTS.AUTHED_USER_NEW_MESSAGE, {
+ audio,
+ time: new Date(),
+ })
+ }
+
+ // getter for the value of textarea
+ get message() {
+ return this._textarea.value;
+ }
+
+ /**
+ * this will clear the content of the textarea and puts focus on it
+ */
+ clear() {
+ this._textarea.value = "";
+ this._textarea.focus();
+
+ // check if audio device is exists and make mic button visible
+ this.setMicBtnVisibility(Recorder.isMicAvailable())
+ }
+
+}
+
+customElements.define(NewMessage.tagName, NewMessage);
diff --git a/Chat Application/index.html b/Chat Application/index.html
new file mode 100644
index 0000000..b3ece45
--- /dev/null
+++ b/Chat Application/index.html
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+ Web Chat App
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Chat Application/scripts/chat-app.js b/Chat Application/scripts/chat-app.js
new file mode 100644
index 0000000..ce702af
--- /dev/null
+++ b/Chat Application/scripts/chat-app.js
@@ -0,0 +1,207 @@
+// connection between components is with events and
+// this const object is for keeping all event types in one place to easy access
+window.APP_EVENTS = {
+ PROFILE_BTN_CLICK: "profile-btn-click",
+ CHAT_CLICKED: "chat-clicked",
+ CHAT_SELECTED: "chat-selected",
+ AUTHED_USER_NEW_MESSAGE: "authed-user-new-message",
+ USER_SIGN_IN: "user-sign-in",
+ SEARCH_IN_CHATS: "search-in-chats",
+ NEW_MESSAGE_RECEIVE: "new-message-receive",
+ CHAT_BOX_BACK_CLICKED: "chat-box-back-clicked",
+ DESELECT_SELECTED_CHAT: "deselect-selected-chat",
+};
+
+/**
+ * This class controls the whole app
+ */
+class ChatApp {
+
+ /**
+ * this receive the id of container of app and get it
+ * the container is required to run app
+ * @param appId
+ */
+ constructor(appId) {
+ // check the existence of appId
+ this.assert(appId, "app container id not passed.");
+ this._app = document.getElementById(appId);
+ // check the existence of container
+ this.assert(this._app, `Container with id "${appId}" not found. `);
+
+ this._authedUser = null;
+ this._chats = [];
+ this._messages = [];
+ this._componenets = {};
+
+ // find and assign required app-components
+ this.assignComponents();
+
+ // initialize listeners
+ this.initListeners();
+
+ // render the existed chats
+ this.sendChatsToList();
+ }
+
+ /**
+ * Find main components and assign it to this._components property
+ * It's just for remove duplication, after this, we access all components
+ * in this._components without re-select from DOM and just
+ */
+ assignComponents() {
+ this._componenets.authedUser = document.querySelector("authed-user");
+ this._componenets.appBranch = document.querySelector("app-brand");
+ this._componenets.chatsList = document.querySelector("chats-list");
+ this._componenets.chatBox = document.querySelector("chat-box");
+ }
+
+ /**
+ * This method is for initializing required events
+ */
+ initListeners() {
+ this._componenets.appBranch.on(APP_EVENTS.PROFILE_BTN_CLICK, this._onProfileBtnClick.bind(this));
+ this._componenets.chatsList.on(APP_EVENTS.CHAT_SELECTED, this._onChatSelected.bind(this));
+ this._componenets.chatBox.on(APP_EVENTS.AUTHED_USER_NEW_MESSAGE, this._onAuthedUserNewMessages.bind(this));
+ this._componenets.chatBox.on(APP_EVENTS.CHAT_BOX_BACK_CLICKED, this._onChatBoxBack.bind(this));
+ }
+
+ /**
+ * To simulate sign-in, use this method.
+ * Logged in user object should pass to this.
+ * @param user
+ */
+ signin(user) {
+ // check the validity of user object
+ this.assert(user && user.id, "Invalid user object");
+ this._authedUser = user;
+
+ // after sign-in we need to set active user on authedUser component
+ // and make it hidden by default
+ // and tell the chatBox component that a user is signed-in
+ this._componenets.authedUser.setUser(this._authedUser);
+ this._componenets.authedUser.hidden = true;
+ this._componenets.chatBox.emit(APP_EVENTS.USER_SIGN_IN, {id: user.id});
+ }
+
+ /**
+ * use this method to send message to app
+ * @param msg {{time: Date, sender: String, text: String, toChat: String}}
+ */
+ newMessage(msg) {
+ // check the validity of received msg object
+ this.assert(msg && msg.time && msg.sender && msg.text && msg.toChat,
+ `Invalid message object.`);
+
+ // push to messages pool
+ this._messages.push(msg);
+
+ // we need to check the sender of new message, if it send by logged in
+ // user we should tell chatBox to render the message too.
+ if (this.activeChat && msg.sender === this.activeChat.id) {
+ this._componenets.chatBox.renderMessage(msg);
+ }
+ // also we need to send received message to chatsList component
+ this._componenets.chatsList.emit(APP_EVENTS.NEW_MESSAGE_RECEIVE, msg);
+ }
+
+ /**
+ * getter for this._authedUser
+ * @returns {Object}
+ */
+ get authedUser() {
+ return this._authedUser;
+ }
+
+ /**
+ * handle profile section visibility on profile-btn click
+ * @private
+ */
+ _onProfileBtnClick() {
+ // toggle the visibility of authedUser component
+ this._componenets.authedUser.hidden = !this._componenets.authedUser.hidden
+ }
+
+ /**
+ * this method fire when a chat selected.
+ * it find the messages of target chat and send those to chatBox to render
+ * @param detail
+ * @private
+ */
+ _onChatSelected({detail}) {
+ // find all messages of selected chat
+ const chatMessaged = this._messages.filter(m => m.sender === detail.id || m.toChat === detail.id);
+
+ // set selected chat as activeChat of whole app
+ this.activeChat = this._chats.find(c => c.id === detail.id);
+
+ // if the chatBox if open for activeChat, scroll content to end
+ if (this._componenets.chatBox.activeChat && this._componenets.chatBox.activeChat.id === this.activeChat.id) {
+ this._componenets.chatBox.scrollToEnd();
+ return;
+ }
+
+ // mark all messages as read and remove unread badge for selected chat
+ this.activeChat.elm.markAllAsRead();
+ // change the current chat of chatBox component
+ this._componenets.chatBox.setActiveChat(this.activeChat);
+
+ // send all messages of target chat to render in chatBox
+ chatMessaged.map(msg => {
+ this._componenets.chatBox.renderMessage(msg)
+ })
+ }
+
+ /**
+ * this method fire when a new message from signed in user sent to a chat
+ * @param detail
+ * @private
+ */
+ _onAuthedUserNewMessages({detail}) {
+ // add sender property to message and
+ // push it to the messages pool
+ this._messages.push({...detail, sender: this.authedUser.id})
+ }
+
+ /**
+ * fires when back btn clicked in chat-box
+ * @private
+ */
+ _onChatBoxBack() {
+ this._componenets.chatsList.emit(APP_EVENTS.DESELECT_SELECTED_CHAT);
+ }
+
+ /**
+ * send chats to chatList
+ */
+ sendChatsToList() {
+ if (!this._chats)
+ return;
+
+ this._componenets.chatsList.setChats(this._chats)
+ }
+
+ /**
+ * use this method to add new chat to whole app
+ * @param chat
+ */
+ addChat(chat) {
+ // check the validity of chat object
+ this.assert(chat && chat.id, `Invalid chat object.`);
+
+ // update chats array
+ this._chats.push(chat);
+
+ this.sendChatsToList();
+ }
+
+ /**
+ * to check condition and fire event if its false
+ * @param condition
+ * @param error
+ */
+ assert(condition, error) {
+ if (!condition)
+ throw new Error(`${error}`)
+ }
+}
diff --git a/Chat Application/scripts/data-factory.js b/Chat Application/scripts/data-factory.js
new file mode 100644
index 0000000..2f35423
--- /dev/null
+++ b/Chat Application/scripts/data-factory.js
@@ -0,0 +1,75 @@
+// generate random date
+function randomDate() {
+ const start = 1585008324467;
+ const end = new Date().getTime();
+
+ let date = new Date(+start + Math.random() * (end - start));
+ let hour = randomNumber(24, 1);
+ date.setHours(hour);
+ return date;
+}
+
+// generate random number in range
+function randomNumber(max, min = 0) {
+ return Math.floor(Math.random() * (max - min) + min);
+}
+
+// generate chat object
+function chatGenerator(index) {
+ const names = ["Mario Speedwagon", "Petey Cruiser", "Anna Sthesia", "Paul Molive", "Anna Mull", "Gail Forcewind", "Paige Turner", "Bob Frapples", "Walter Melon", "Nick R. Bocker", "Barb Ackue", "Buck Kinnear", "Greta Life", "Ira Membrit", "Shonda Leer", "Brock Lee", "Maya Didas", "Rick O'Shea", "Pete Sariya", "Monty Carlo", "Sal Monella", "Sue Vaneer", "Cliff Hanger", "Barb Dwyer", "Terry Aki", "Cory Ander", "Robin Banks", "Jimmy Changa", "Barry Wine", "Wilma Mumduya", "Buster Hyman", "Poppa Cherry", "Zack Lee", "Don Stairs", "Saul T. Balls", "Peter Pants", "Hal Appeno", "Otto Matic", "Moe Fugga", "Graham Cracker", "Tom Foolery", "Al Dente", "Bud Wiser", "Polly Tech", "Holly Graham", "Frank N. Stein", "Cam L. Toe", "Pat Agonia", "Tara Zona", "Barry Cade"]
+ const name = names[index];
+ let lastseen = randomDate().toLocaleDateString().replace(/\//g, ".");
+ if (index % 3 === 0)
+ lastseen = "Today";
+ if (index % 4 === 0)
+ lastseen = "Yesterday";
+
+ return {
+ id: Math.random().toString(32).substr(2, 10),
+ name,
+ username: name.replace(/[^a-zA-Z]/g, '').toLowerCase().substr(0, 8),
+ online: Math.random() > .7,
+ lastseen,
+ unreadcount: "0",
+ avatar: `https://randomuser.me/api/portraits/${index % 3 ? "women" : "men"}/${index + 1}.jpg`,
+ }
+}
+
+// generate random sentences for messages
+function getRandomText(sub = false) {
+ // sub-string a long paragraph.
+ if (sub) {
+ const lorem = `If the family member doesn’t need hospitalization and can be cared for at home, you should help him or her with basic needs and monitor the symptoms, while also keeping as much distance as possible, according to guidelines issued by the C.D.C. If there’s space, the sick family member should stay in a separate room and use a separate bathroom. If masks are available, both the sick person and the caregiver should wear them when the caregiver enters the room. Make sure not to share any dishes or other household items and to regularly clean surfaces like counters, doorknobs, toilets and tables. Don’t forget to wash your hands frequently.`;
+ const i1 = randomNumber(lorem.length, 6);
+ const i2 = randomNumber(lorem.length, 6);
+ const start = Math.min(i1, i2);
+ const end = Math.min(i2, i1);
+ return lorem.substr(start, end)
+ }
+
+ // make a sentences of random words
+ let verbs, nouns, adjectives, adverbs, preposition;
+ nouns = ["bird", "clock", "boy", "plastic", "duck", "teacher", "old lady", "professor", "hamster", "dog",
+ "area", "book", "business", "case", "child", "company", "country", "day", "eye",
+ "fact", "family", "government", "group", "hand", "home", "job", "life", "lot"];
+ verbs = ["kicked", "ran", "flew", "dodged", "sliced", "rolled", "died", "breathed", "slept", "killed",
+ "ask", "be", "become", "begin", "call", "can", "come", "could", "do",
+ "feel", "find", "get", "give", "go", "have", "hear", "help", "keep", "know",];
+ adjectives = ["beautiful", "lazy", "professional", "lovely", "dumb", "rough", "soft", "hot", "vibrating", "slimy", "important",
+ "able", "bad", "best", "better", "big", "black", "certain", "clear", "different", "early",
+ "easy", "economic", "federal", "free", "full", "good", "great", "hard", "high", "human"];
+ adverbs = ["slowly", "elegantly", "precisely", "quickly", "sadly", "humbly", "proudly", "shockingly", "calmly", "passionately"];
+ preposition = ["down", "into", "up", "on", "upon", "below", "above", "through", "across", "towards"];
+
+
+ var rand1 = Math.floor(Math.random() * 10);
+ var rand2 = Math.floor(Math.random() * 10);
+ var rand3 = Math.floor(Math.random() * 30);
+ var rand4 = Math.floor(Math.random() * 30);
+ var rand5 = Math.floor(Math.random() * 30);
+ var rand6 = Math.floor(Math.random() * 30);
+ return "The " + adjectives[rand1] + " " + nouns[rand2] + " " + adverbs[rand1] + " " + verbs[rand4] + " because some " + nouns[rand1]
+ + " " + adverbs[rand2] + " " + verbs[rand1] + " " + preposition[rand1] + " a " + adjectives[rand2] + " " + nouns[rand5]
+ + " which, became a " + adjectives[rand3] + ", " + adjectives[rand4] + " " + nouns[rand6] + ".";
+}
+
diff --git a/Chat Application/scripts/index.js b/Chat Application/scripts/index.js
new file mode 100644
index 0000000..28304e0
--- /dev/null
+++ b/Chat Application/scripts/index.js
@@ -0,0 +1,55 @@
+const numberOfChats = 10;
+let fakeChats = [];
+// generate an array of fake chats to show in app
+for (let i = 1; i < numberOfChats; i++) {
+ fakeChats.push(chatGenerator(i))
+}
+
+// this is the signed-in user object
+const authedUser = {
+ id: '12',
+ name: "Behnam Azimi",
+ username: "bhnmzm",
+ online: true,
+ lastSeen: "Today",
+ avatar: "https://randomuser.me/api/portraits/men/1.jpg"
+};
+
+// create instance of ChatApp,
+// this is the line that run application
+const app = new ChatApp("chat-web-app");
+app.signin(authedUser);
+
+// add all generated chats to app one-by-one
+fakeChats.map(fc => app.addChat(fc));
+
+
+// below code is just for simulating message receive
+// here we send 100 messages in different times ro app
+let fakeMsgCounter = 100;
+const interval = setInterval(() => {
+
+ if (--fakeMsgCounter === 0) {
+ clearInterval(interval);
+ return;
+ }
+
+ setTimeout(() => {
+ const fakeSender = fakeChats[randomNumber(numberOfChats, 1)];
+ if (!fakeSender)
+ return;
+
+ // flag with a 20% probability
+ const randomFlag = Math.random() > .8;
+
+ app.newMessage({
+ text: getRandomText(Math.random() > .5),
+ sender: randomFlag ? authedUser.id : fakeSender.id,
+ time: new Date(),
+ toChat: randomFlag ? fakeSender.id : authedUser.id
+ });
+
+ // new message sending time can be dynamic, between 1s and 5s
+ }, randomNumber(1000, 5000))
+
+}, 1500);
diff --git a/Chat Application/scripts/recorder.js b/Chat Application/scripts/recorder.js
new file mode 100644
index 0000000..bcff52f
--- /dev/null
+++ b/Chat Application/scripts/recorder.js
@@ -0,0 +1,162 @@
+/**
+ * this class control the recording functionality.
+ * it's enough to create an instance of this and call start()
+ * to start recording and stop() to put the end to the recording.
+ */
+class Recorder {
+
+ constructor() {
+ this._recorder = null;
+ this._audioChunks = [];
+
+ this.init();
+ }
+
+ /**
+ * initial the recorder and create a new instance of MediaRecorder.
+ */
+ init() {
+ navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia;
+ if (navigator.getUserMedia) {
+ navigator.getUserMedia({audio: true}, (stream) => {
+
+ this._recorder = new MediaRecorder(stream);
+
+ // we need to listen for data available for the recorder and update the audio chunk
+ this._recorder.addEventListener("dataavailable", (e) => {
+ this._audioChunks.push(e.data)
+ });
+
+ }, () => {
+ throw new Error("Use Media not found.")
+ });
+ } else {
+ throw new Error("Use Media not found.")
+ }
+ }
+
+ /**
+ * call start method of recorder
+ */
+ start() {
+ if (!this._recorder)
+ return;
+
+ this._audioChunks = [];
+ this._recorder.start();
+ }
+
+ /**
+ * call the stop method of recorder and generate the audio object and resolve it
+ * @returns {Promise}
+ */
+ stop() {
+ return new Promise((resolve) => {
+
+ // to create audio, we should listen for stop event of recorder
+ this._recorder.addEventListener("stop", async () => {
+
+ // to create the audio, we should make its Blob first
+ // and then create a object URL for it and pass it to the Audio API
+ const audioBlob = new Blob(this._audioChunks);
+ const audioUrl = URL.createObjectURL(audioBlob);
+ this._audio = new Audio(audioUrl);
+
+ // calc the duration of audio
+ const duration = await this.findDuration(audioBlob);
+
+ resolve({audio: this._audio, duration, audioUrl})
+ });
+
+ // stop the recording
+ this._recorder.stop();
+ })
+ }
+
+ /**
+ * find duration of recorded audio
+ * @param blob
+ * @returns {Promise}
+ */
+ findDuration(blob) {
+ return new Promise((resolve) => {
+ const file = new File([blob], "audio.mp3");
+ let reader = new FileReader();
+ reader.onload = (e) => {
+ let audioContext = new (window.AudioContext || window.webkitAudioContext)();
+
+ // Asynchronously decode audio file data contained in an ArrayBuffer.
+ audioContext.decodeAudioData(e.target.result, function (buffer) {
+ let floatDuration = buffer.duration;
+
+ let dMin = Math.floor(floatDuration / 60);
+ let dSec = Math.floor(floatDuration % 60);
+
+ if (dMin < 10)
+ dMin = "0" + dMin;
+
+ if (dSec < 10)
+ dSec = "0" + dSec;
+
+ resolve(`${dMin}:${dSec}`)
+ });
+ };
+
+ reader.readAsArrayBuffer(file);
+ })
+ }
+
+ /**
+ * getter for audio objec
+ * @returns {HTMLAudioElement}
+ */
+ get audio() {
+ return this._audio;
+ }
+
+ /**
+ * check if the audio device is available
+ * @returns {boolean}
+ */
+ static isMicAvailable() {
+ let isAvailable = true;
+ navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia;
+ if (navigator.getUserMedia) {
+ navigator.getUserMedia({audio: true}, (stream) => {
+ isAvailable = true;
+ }, () => {
+ isAvailable = false;
+ });
+ }
+
+ return isAvailable;
+ }
+
+ /**
+ * Convert numbers in second to time string like 00:00
+ *
+ * @param seconds
+ * @returns {string}
+ */
+ static secToTimeStr(seconds) {
+ let timeInHour = Math.floor(seconds / 3600);
+ let timeInMin = Math.floor((seconds % 3600) / 60);
+ let timeInSec = Math.floor(seconds % 60);
+
+ if (timeInHour < 10)
+ timeInHour = `0${timeInHour}`;
+
+ if (timeInMin < 10)
+ timeInMin = `0${timeInMin}`;
+
+ if (timeInSec < 10)
+ timeInSec = `0${timeInSec}`;
+
+ let timeStr = `${timeInMin}:${timeInSec}`;
+ if (parseInt(timeInHour))
+ timeStr = `${timeInHour}:${timeStr}`;
+
+ return timeStr;
+ }
+
+}
diff --git a/Chat Application/static/chat-box-bg.png b/Chat Application/static/chat-box-bg.png
new file mode 100644
index 0000000..a5eeab1
Binary files /dev/null and b/Chat Application/static/chat-box-bg.png differ
diff --git a/Chat Application/static/chat-placeholder.svg b/Chat Application/static/chat-placeholder.svg
new file mode 100644
index 0000000..e675709
--- /dev/null
+++ b/Chat Application/static/chat-placeholder.svg
@@ -0,0 +1,294 @@
+
+
+
diff --git a/Chat Application/static/mic.svg b/Chat Application/static/mic.svg
new file mode 100644
index 0000000..06b79be
--- /dev/null
+++ b/Chat Application/static/mic.svg
@@ -0,0 +1,7 @@
+
diff --git a/Chat Application/styles/styles.css b/Chat Application/styles/styles.css
new file mode 100644
index 0000000..3195dca
--- /dev/null
+++ b/Chat Application/styles/styles.css
@@ -0,0 +1,76 @@
+/** Simple Reset - START */
+html {
+ box-sizing: border-box;
+ font-size: 16px;
+ overflow-x: hidden;
+}
+
+*, *:before, *:after {
+ box-sizing: inherit;
+}
+
+body, h1, h2, h3, h4, h5, h6, p, ol, ul {
+ margin: 0;
+ padding: 0;
+ font-weight: normal;
+}
+
+ol, ul {
+ list-style: none;
+}
+
+img {
+ max-width: 100%;
+ height: auto;
+}
+
+/** Global - START */
+
+body {
+ font-family: 'Lato', sans-serif;
+ height: 100vh;
+ max-height: 100vh;
+ overflow: hidden;
+}
+
+#chat-web-app {
+ position: fixed;
+ height: 100vh;
+ width: 100vw;
+ top: 0;
+ left: 0;
+
+ display: flex;
+ flex-direction: row;
+}
+
+#chat-web-app .sidebar {
+ box-shadow: 0 0 5px 2px rgba(0, 0, 0, .14);
+ display: flex;
+ flex-direction: column;
+ width: 280px;
+ min-width: 280px;
+ position: relative;
+ z-index: 2;
+}
+
+@media screen and (max-width: 564px) {
+ #chat-web-app .sidebar {
+ min-width: 100%;
+ background-color: #fff;
+ z-index: 3;
+ }
+
+ chat-box {
+ position: absolute;
+ top: 0;
+ left: 0;
+ z-index: 2;
+ background: #fff;
+ width: 100%;
+ }
+
+ chat-box:not([hidden]) {
+ z-index: 5;
+ }
+}