diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..ec44aacbe --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +package-lock.json + +node_modules/ diff --git a/README.md b/README.md index e8e4ea159..63f901dad 100644 --- a/README.md +++ b/README.md @@ -19,4 +19,42 @@ Lista de recursos usados em aula para este projeto | Cloud Functions | https://firebase.google.com/docs/functions/?hl=pt-br | | Cloud Storage | https://firebase.google.com/docs/storage/?authuser=0 | | PDF.js | https://mozilla.github.io/pdf.js/ | -| MediaDevices.getUserMedia() | https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia | \ No newline at end of file +| MediaDevices.getUserMedia() | https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia | + + +### Instalação de Dependências + +Atualização do npm: +```bash +npm install -g npm +``` + +Inicialização do npm: +```bash +npm init +``` + +Webpack 3.1.0 e Webpack Dev Server 2.5.1: +```bash +npm install webpack@3.1.0 --save +npm install webpack-dev-server@2.5.1 --save +npm install firebase --save +npm i firebase@9.0.0-beta.7 +``` + +### Alterando o arquivo package.json + +Adicione as seguintes linhas em "script" no arquivo package.json, logo abaixo de "test": +```js +"build": "webpack --config webpack.config.js", +"start": "webpack-dev-server" +``` + +### Executando + +Rodando no servidor +```bash +npm run start +``` + +Acesse: http://localhost:8080/ diff --git a/css/style.css b/css/style.css index 8f16af928..ca4f37367 100644 --- a/css/style.css +++ b/css/style.css @@ -3356,7 +3356,7 @@ html[dir=rtl] ._3DeDN{ height:100% } html[dir] ._3qlW9{ - background-color:#f7f9fa; + background-color:#052433 /* #f7f9fa */; cursor:default; padding-bottom:28px; padding-top:28px @@ -16136,3 +16136,12 @@ html[dir=rtl] ._2ICcy:after{ #main { display:none; } +._2oKVP{ + display: flex; + justify-content: center; +} +#picture-camera { + height: 100%; + width: auto; + text-align: center; +} \ No newline at end of file diff --git a/index.html b/index.html index a847b69bc..53c70a046 100644 --- a/index.html +++ b/index.html @@ -126,7 +126,7 @@ -
+
-
-
- -
- - -
-
-
-
- -
- - - - - - - - -
-
-
-
-
Nome do Contato Anexado
-
-
-
- 17:01 -
- - - - - -
-
-
-
-
-
Enviar mensagem
-
-
- -
-
-
- -
- - -
-
- Oi! -
-
-
- 11:33 -
-
-
-
-
-
- -
-
- -
-
-
-
-
-
- Arquivo.pdf -
-
- - - - - - - -
-
-
-
- 32 páginas - PDF - 4 MB -
-
-
- 18:56 -
- - - - - -
-
-
-
-
-
-
-
- - -
-
- Oi, tudo bem? -
-
-
- 11:52 -
- - - - - - - - - - - - - - - - - - - - -
-
-
-
-
-
-
- -
-
-
-
-
-
-
- - - -
-
- - - - - -
-
-
- -
-
-
-
- Texto da foto -
-
-
-
- 17:22 -
- - - - - -
-
-
-
-
- -
- - - - - -
-
-
- -
- -
-
-
-
-
-
- - - - - -
-
-
0:05
-
- - - -
-
-
-
-
-
- - - - - - -
-
-
-
- -
- - - - - - - - -
-
-
-
-
-
- 17:48 -
- - - - - -
-
-
-
- -
- - - - - -
-
-
-
+
@@ -1331,6 +1042,7 @@

WhatsApp Clone da Hcode Treinamentos

+ diff --git a/package.json b/package.json new file mode 100644 index 000000000..b9a214e3b --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "curso-javascript-projeto-whatsapp-clone", + "version": "1.0.0", + "description": "[![Hcode Treinamentos](https://www.hcode.com.br/res/img/hcode-200x100.png)](https://www.hcode.com.br)", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "build": "webpack --config webpack.config.js", + "start": "webpack-dev-server" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/btrcardoso/curso-javascript-projeto-whatsapp-clone.git" + }, + "author": "", + "license": "ISC", + "bugs": { + "url": "https://github.com/btrcardoso/curso-javascript-projeto-whatsapp-clone/issues" + }, + "homepage": "https://github.com/btrcardoso/curso-javascript-projeto-whatsapp-clone#readme", + "dependencies": { + "firebase": "^9.0.0-beta.7", + "pdfjs-dist": "^2.0.489", + "webpack": "^3.1.0", + "webpack-dev-server": "^2.5.1" + } +} diff --git a/src/app.js b/src/app.js new file mode 100644 index 000000000..6ff2b7784 --- /dev/null +++ b/src/app.js @@ -0,0 +1,3 @@ +import {WhatsAppController} from './controller/WhatsAppController' + +window.app = new WhatsAppController(); \ No newline at end of file diff --git a/src/controller/CameraController.js b/src/controller/CameraController.js new file mode 100644 index 000000000..6183c52c8 --- /dev/null +++ b/src/controller/CameraController.js @@ -0,0 +1,50 @@ +export class CameraController{ + + constructor(videoEl){ + + this._videoEl = videoEl; + + navigator.mediaDevices.getUserMedia({ + video: true + }).then(stream => { + + this._stream = stream; + + // Cria um arquivo + //this._videoEl.src = URL.createObjectURL(stream); + this._videoEl.srcObject = stream; + + // Toca o vídeo + this._videoEl.play(); + + }).catch(err => { + console.error(err); + }); + } + + // Percorre cada track (como áudio e vídeo) e manda parar + stop(){ + this._stream.getTracks().forEach(track => { + track.stop(); + }); + } + + takePicture(mimeType = 'image/png'){ + + let canvas = document.createElement('canvas'); + + // Pega a altura e largura da imagem + canvas.setAttribute('height', this._videoEl.videoHeight); + canvas.setAttribute('width', this._videoEl.videoWidth); + + let context = canvas.getContext('2d'); + + // Desenhar Imagem (elemento, x, y, w, h) + context.drawImage(this._videoEl, 0, 0, canvas.width, canvas.height);; + + return canvas.toDataURL(mimeType); + + + } + +} \ No newline at end of file diff --git a/src/controller/DocumentPreviewController.js b/src/controller/DocumentPreviewController.js new file mode 100644 index 000000000..2ed10dcd7 --- /dev/null +++ b/src/controller/DocumentPreviewController.js @@ -0,0 +1,46 @@ +export class DocumentPreviewController{ + + constructor(file){ + + this._file = file; + + } + + getPreviewData(){ + return new Promise((resolve,reject)=>{ + + switch(this._file.type){ + + case 'image/png': + case 'image/jpeg': + case 'image/jpg': + case 'image/gif': + + let reader = new FileReader(); + + reader.onload = e => { + resolve({ + src:reader.result, + info:this._file.name + }); + }; + + reader.onerror = e => { + reject(e); + } + + reader.readAsDataURL(this._file); + + break; + + case 'application/pdf': + + break; + + default: + reject(); + } + + }); + } +} \ No newline at end of file diff --git a/src/controller/MicrophoneController.js b/src/controller/MicrophoneController.js new file mode 100644 index 000000000..72329207b --- /dev/null +++ b/src/controller/MicrophoneController.js @@ -0,0 +1,152 @@ +import {ClassEvent} from "../util/ClassEvent"; + +export class MicrophoneController extends ClassEvent{ + + constructor(){ + + // Chama o construtor do pai ClassEvent + super(); + + this._mimeType = 'audio/webm'; + + // Confere se o usuário permitiu o uso do mic + this._available = false; + + navigator.mediaDevices.getUserMedia({ + audio: true + }).then(stream => { + + this._available = true; + + this._stream = stream; + + // Faz o audio tocar + /* + let audio = new Audio(); + audio.srcObject = stream; + audio.play(); + */ + + this.trigger('ready', this._stream); + + + }).catch(err => { + console.error(err); + }); + + } + + isAvailable(){ + return this._available; + } + + // Percorre cada track (como áudio e vídeo) e manda parar + stop(){ + this._stream.getTracks().forEach(track => { + track.stop(); + }); + } + + startRecorder(){ + + if(this.isAvailable()){ + + // Para saber o suporte do navegador: MediaRecorder.isTypeSupported('audio/webm') + + this._mediaRecorder = new MediaRecorder(this._stream, { + mymeTipe: this._mimeType + }); + + // Array onde serão armazenados os pedaços de audios gravados + this._recordedChunks = []; + + this._mediaRecorder.addEventListener('dataavailable', e => { + + // Se já houver algum pedaço de audio gravado, guarda no array + if(e.data.size > 0){ + this._recordedChunks.push(e.data); + } + + }); + + + this._mediaRecorder.addEventListener('stop', e => { + + // Prepara o arquivo de audio + + let blob = new Blob(this._recordedChunks,{ + type: this._mimeType + }); + + let filename = `rec${Date.now()}.webm`; + + let file = new File([blob], filename, { + type: this._mimeType, + lastModified: Date.now() + }); + + console.log('file', file); + + /* + // Toca áudio + + let reader = new FileReader(); + + reader.onload = e => { + + console.log('reader file', file); + + let audio = new Audio(reader.result); + + audio.play(); + + } + + reader.readAsDataURL(file); + */ + + }); + + this._mediaRecorder.start(); + + this.startTimer(); + + } + + } + + stopRecorder(){ + + if(this.isAvailable()){ + + // Para a gravação + this._mediaRecorder.stop(); + + // Para o microfone + this.stop(); + + } + + this.stopTimer(); + + } + + startTimer(){ + + let start = Date.now(); + + this._recordMicrophoneInterval = setInterval(() => { + + this.trigger('recordtimer', (Date.now() - start)); + + }, 100); + + } + + stopTimer(){ + + clearInterval(this._recordMicrophoneInterval); + + } + +} \ No newline at end of file diff --git a/src/controller/WhatsAppController.js b/src/controller/WhatsAppController.js new file mode 100644 index 000000000..e245818d8 --- /dev/null +++ b/src/controller/WhatsAppController.js @@ -0,0 +1,836 @@ +import {Format} from './../util/Format'; +import {CameraController} from './CameraController'; +import {DocumentPreviewController} from './DocumentPreviewController' +import {MicrophoneController} from './MicrophoneController' +import {Firebase} from './../util/Firebase' +import {User} from './../model/User' +import {Chat} from './../model/Chat' +import {Message} from './../model/Message' + +export class WhatsAppController{ + constructor(){ + this._firebase = new Firebase(); + this.initAuth(); + this.loadElements(); + this.elementsPrototype(); + this.initEvents(); + } + + // Inicia a autenticação por email + initAuth(){ + this._firebase.initAuth().then((response)=>{ + + this._user = new User(response.user.email); + + this._user.on('datachange', data => { + + document.querySelector('title').innerHTML = data.name + ' - WhatsApp Clone'; + + this.el.inputNamePanelEditProfile.innerHTML = data.name; + + if(data.photo) { + + let photo = this.el.imgPanelEditProfile; + photo.src = data.photo; + photo.show(); + this.el.imgDefaultPanelEditProfile.hide(); + + let photo2 = this.el.myPhoto.querySelector('img'); + photo2.src = data.photo; + photo2.show(); + + } + + this.initContacts(); + + }); + + + this._user.name = response.user.displayName; + this._user.email = response.user.email; + this._user.photo = response.user.photoURL; + + this._user.save().then(()=>{ + + // Exibe o painel do whatsapp + this.el.appContent.css({ + display:'flex' + }); + + }); + + }). catch(err=>{ + console.error(err); + }); + } + + // Carrega a lista de Conversas com os Contatos + initContacts(){ + + this._user.on('contactschange', docs => { + + this.el.contactsMessagesList.innerHTML = ''; + + docs.forEach(doc => { + + let contact = doc.data(); + + let div = document.createElement('div'); + div.className = 'contact-item'; + div.innerHTML = ` +
+
+ +
+ + + + + + + + +
+
+
+
+
+
+ ${contact.name} +
+
+ ${contact.lastMessageTime} +
+
+
+
+ + + +
+ + + + + +
+ ${contact.lastMessage} +
+ +
+ +
+
+
+
+
+
+ `; + + if(contact.photo){ + let img = div.querySelector('.photo'); + img.src = contact.photo; + img.show(); + } + + div.on('click', e=>{ + + this.setActiveChat(contact); + + }); + + this.el.contactsMessagesList.appendChild(div); + + }); + + + }); + + this._user.getContacts(); + } + + // Faz o chat do contato especificado aparecer + setActiveChat(contact){ + + if(this._contactActive){ + + Message.getRef(this._contactActive.chatId).onSnapshot(() => {}); + + } + + this._contactActive = contact; + + this.el.activeName.innerHTML = contact.name; + this.el.activeStatus.innerHTML = contact.status; + + if(contact.photo){ + + let img = this.el.activePhoto; + img.src = contact.photo; + img.show(); + + } + + this.el.home.hide(); + this.el.main.css({ + display:'flex' + }) + + this.el.panelMessagesContainer.innerHTML = ''; + + // Ordenação das mensagens na tela + Message.getRef(this._contactActive.chatId).orderBy('timeStamp') + .onSnapshot(docs=>{ + + let scrollTop = this.el.panelMessagesContainer.scrollTop; // topo do scroll + + let scrollTopMax = ( // máximo que se consegue empurrar o scroll + this.el.panelMessagesContainer.scrollHeight // altura total de toda a conversa + - this.el.panelMessagesContainer.offsetHeight // tamanho fixo da conversa na tela + ); + + let autoScroll = (scrollTop >= scrollTopMax); // Se o topo do scroll for igual ao topo máximo, então a pessoa está no fim da conversa + + docs.forEach(doc => { + + let data = doc.data(); + data.id = doc.id; + + let message = new Message(); + + message.fromJSON(data); + + let me = (data.from === this._user.email); + + if(!this.el.panelMessagesContainer.querySelector('#_'+data.id)){ + + if(!me){ + + doc.ref.set({ + status: 'read' + }, { + merge: true + }); + + } + + let view = message.getViewElement(me); + + this.el.panelMessagesContainer.appendChild(view); + + } else if(me){ + + let msgEl = this.el.panelMessagesContainer.querySelector('#_'+data.id); + + // outerHTML pega também o conteúdo que envolve o html especificado + msgEl.querySelector('.message-status').innerHTML = message.getStatusViewElement().outerHTML; + + } + + }); + + if(autoScroll) { + + this.el.panelMessagesContainer.scrollTop = + (this.el.panelMessagesContainer.scrollHeight + - this.el.panelMessagesContainer.offsetHeight); + + } else { + + this.el.panelMessagesContainer.scrollTop = scrollTop; + + } + + }); + + } + + // Percorre os elementos da tela e os coloca em this.el + loadElements(){ + + this.el = {}; + + document.querySelectorAll('[id]').forEach(element=>{ + + this.el[Format.getCamelCase(element.id)] = element; + + }); + } + + // Adiciona funções para alguns elementos + elementsPrototype(){ + + // Element é uma classe nativa do js, e aqui são adicionadas algumas funções a ela. + // this.style é nativo da classe Element que pode mudar os estilos. + + Element.prototype.hide = function(){ + this.style.display = 'none'; + return this; + }; + + Element.prototype.show = function(){ + this.style.display = 'block'; + return this; + }; + + Element.prototype.toggle = function(){ + this.style.display = (this.style.display === 'none') ? 'block' : 'none'; + return this; + }; + + // Realizar eventos + Element.prototype.on = function(events, fn){ + events.split(' ').forEach(event=>{ + this.addEventListener(event,fn); + }); + return this; + }; + + // Alterar características do css + Element.prototype.css = function(styles){ + for (let name in styles){ + this.style[name] = styles[name]; + } + return this; + }; + + + /* Alterar características das classes html */ + + Element.prototype.addClass = function(name){ + this.classList.add(name); + return this; + }; + + Element.prototype.removeClass = function(name){ + this.classList.remove(name); + return this; + }; + + Element.prototype.toggleClass = function(name){ + this.classList.toggle(name); + return this; + }; + + Element.prototype.hasClass = function(name){ + return this.classList.contains(name); + }; + + + //Retorna um FormData para o formulário + HTMLFormElement.prototype.getForm = function(){ + + return new FormData(this); + + }; + + // Retorna os campos do formulário em objeto JSON + HTMLFormElement.prototype.toJSON = function(){ + + let json = {}; + + this.getForm().forEach((value, key) => { + + json[key] = value; + + }); + + return json; + + } + + } + + // Inicia os eventos que os componentes da tela realizam + initEvents(){ + + // Busca de contatos + this.el.inputSearchContacts.on('keyup', e=>{ + + if(this.el.inputSearchContacts.value.length > 0){ + this.el.inputSearchContactsPlaceholder.hide(); + } else { + this.el.inputSearchContactsPlaceholder.show(); + } + + // Faz a busca de acordo com o que o usuário escreveu + this._user.getContacts(this.el.inputSearchContacts.value); + + }); + + // Abre o painel para Editar Perfil + this.el.myPhoto.on('click', e => { + + this.closeAllLeftPanel(); + this.el.panelEditProfile.show(); + + setTimeout(()=>{ + this.el.panelEditProfile.addClass('open'); + }, 300); + + }); + + // Abre o Painel para Adicionar Novo Contato + this.el.btnNewContact.on('click', e => { + + this.closeAllLeftPanel(); + this.el.panelAddContact.show(); + + setTimeout(()=>{ + this.el.panelAddContact.addClass('open'); + }, 300); + + }); + + // Fecha o painel de Editar Perfil + this.el.btnClosePanelEditProfile.on('click', e=> { + + this.el.panelEditProfile.removeClass('open'); + + }); + + // Fecha o Painel de Adicionar Novo Contato + this.el.btnClosePanelAddContact.on('click', e => { + + this.el.panelAddContact.removeClass('open'); + + }); + + // Aciona o input para importar foto + this.el.photoContainerEditProfile.on('click', e=> { + + this.el.inputProfilePhoto.click(); + + }); + + // Salva o nome quando a tecla Enter é acionada + this.el.inputNamePanelEditProfile.on('keypress', e=>{ + + if(e.key === 'Enter'){ + + e.preventDefault(); + this.el.btnSavePanelEditProfile.click(); + + } + + }); + + // Salva as alterações no Perfil + this.el.btnSavePanelEditProfile.on('click',e => { + + this.el.btnSavePanelEditProfile.disabled = true; + + this._user.name = this.el.inputNamePanelEditProfile.innerHTML; + + this._user.save().then(()=>{ + + this.el.btnSavePanelEditProfile.disabled = false; + + }); + + }); + + // Submete os dados do formulário de Adicionar Contato + this.el.formPanelAddContact.on('submit', e => { + + e.preventDefault(); + + let formData = new FormData(this.el.formPanelAddContact); + + let contact = new User(formData.get('email')); + + contact.on('datachange', data => { + + if(data.name){ + + Chat.createIfNotExists(this._user.email, contact.email).then(chat => { + + contact.chatId = chat.id; + this._user.chatId = chat.id; + + contact.addContact(this._user); + + this._user.addContact(contact).then(() => { + + this.el.btnClosePanelAddContact.click(); + console.info('Contato adicionado') + + }); + + }); + + } else { + console.error('Usuário não foi encontrado'); + } + + }); + + }); + + // Abre a conversa do contato selecionado na Lista de Contatos + this.el.contactsMessagesList.querySelectorAll('.contact-item').forEach(item => { + + item.on('click', e => { + + this.el.home.hide(); + + this.el.main.css({ + display:'flex' + }); + + }); + + }); + + // Abre o Menu de Anexos + this.el.btnAttach.on('click', e => { + + e.stopPropagation(); + + this.el.menuAttach.addClass('open'); + + document.addEventListener('click', this.closeMenuAttach.bind(this)); + + }); + + // Aciona o input para importar foto + this.el.btnAttachPhoto.on('click', e => { + + this.el.inputPhoto.click(); + + }); + + // Seleciona as fotos a serem importadas + this.el.inputPhoto.on('change', e => { + + [...this.el.inputPhoto.files].forEach(file => { + + Message.sendImage(this._contactActive.chatId, this._user.email, file); + + + }) + + }); + + // Abre o Painel da Câmera + this.el.btnAttachCamera.on('click', e => { + + this.closeAllMainPanel(); + + this.el.panelCamera.addClass('open'); + + this.el.panelCamera.css({ + + 'height':'calc(100% - 120px)' + + }); + + this._camera = new CameraController(this.el.videoCamera); + + }); + + // Fecha todos os painéis, para a câmera e abre a conversa + this.el.btnClosePanelCamera.on('click', e => { + + this.closeAllMainPanel(); + + this.el.panelMessagesContainer.show(); + + this._camera.stop(); + + }); + + // Tira foto + this.el.btnTakePicture.on('click', e=> { + + let dataUrl = this._camera.takePicture(); + + this.el.pictureCamera.src = dataUrl; + + this.el.pictureCamera.show(); + + this.el.videoCamera.hide(); + + this.el.btnReshootPanelCamera.show(); + + this.el.containerTakePicture.hide(); + + this.el.containerSendPicture.show(); + + }); + + // Tirar foto novamente + this.el.btnReshootPanelCamera.on('click', e => { + + this.el.pictureCamera.hide(); + + this.el.videoCamera.show(); + + this.el.btnReshootPanelCamera.hide(); + + this.el.containerTakePicture.show(); + + this.el.containerSendPicture.hide(); + + }); + + // Enviar foto + this.el.btnSendPicture.on('click', e => { + + console.log(this.el.pictureCamera.src); + + }) + + // Abre o Painel de Visualizar Documentos + this.el.btnAttachDocument.on('click', e => { + + this.closeAllMainPanel(); + + this.el.panelDocumentPreview.addClass('open'); + + this.el.panelDocumentPreview.css({ + + 'height':'calc(100% - 120px)' + + }); + + // Clica no input de Documentos abrirá os documentos do computador para que um seja selecionado + this.el.inputDocument.click(); + + }); + + // Mostra o documento no painel de Pré-visualização + this.el.inputDocument.on('change', e => { + + if(this.el.inputDocument.files.length){ + + let file = this.el.inputDocument.files[0]; + + this._documentPreviewController = new DocumentPreviewController(file); + + // Traz o arquivo do DocumentPreviewController + this._documentPreviewController.getPreviewData().then(result => { + + this.el.imgPanelDocumentPreview.src = result.src; + this.el.infoPanelDocumentPreview.innerHTML = result.info; + this.el.imagePanelDocumentPreview.show(); + this.el.filePanelDocumentPreview.hide(); + + }).catch(err => { + + this.el.iconPanelDocumentPreview.className = 'jcxhw icon-doc-generic'; + + this.el.filenamePanelDocumentPreview.innerHTML = file.name; + this.el.imagePanelDocumentPreview.hide(); + this.el.filePanelDocumentPreview.show(); + + }); + } + + }); + + // Fecha todos os painéis e abre a conversa + this.el.btnClosePanelDocumentPreview.on('click', e => { + + this.closeAllMainPanel(); + + this.el.panelMessagesContainer.show(); + + }); + + // Envia documentos + this.el.btnSendDocument.on('click', e => { + + console.log('send document'); + + }); + + // Mostra a Lista de Contatos + this.el.btnAttachContact.on('click', e => { + + this.el.modalContacts.show(); + + }); + + // Fecha a Lista de Contatos + this.el.btnCloseModalContacts.on('click', e => { + + this.el.modalContacts.hide(); + + }); + + // Abre gravação do microfone + this.el.btnSendMicrophone.on('click', e => { + + this.el.recordMicrophone.show(); + this.el.btnSendMicrophone.hide(); + + this._microphoneController = new MicrophoneController(); + + this._microphoneController.on('ready', musica => { + + console.log('ready'); + + this._microphoneController.startRecorder(); + + }); + + this._microphoneController.on('recordtimer', timer => { + + this.el.recordMicrophoneTimer.innerHTML = Format.toTime(timer); + + }); + + }); + + // Cancela gravação do microfone + this.el.btnCancelMicrophone.on('click', e => { + + this._microphoneController.stopRecorder(); + this.closeRecordMicrophone(); + + + }); + + // Envia Gravação do Microfone + this.el.btnFinishMicrophone.on('click', e => { + + this._microphoneController.stopRecorder(); + this.closeRecordMicrophone(); + + }); + + // Enviar mensagem pela tecla Enter + this.el.inputText.on('keypress', e => { + + if(e.key === 'Enter' && !e.ctrlKey){ + e.preventDefault(); + this.el.btnSend.click(); + } + + }); + + // Alterações de estilo na hora de digitar + this.el.inputText.on('keyup', e => { + + if(this.el.inputText.innerHTML.length){ + + this.el.inputPlaceholder.hide(); + this.el.btnSendMicrophone.hide(); + this.el.btnSend.show(); + + } else { + + this.el.inputPlaceholder.show(); + this.el.btnSendMicrophone.show(); + this.el.btnSend.hide(); + + } + + }); + + // Enviar Mensagem + this.el.btnSend.on('click', e => { + + Message.send( + this._contactActive.chatId, + this._user.email, + 'text', + this.el.inputText.innerHTML + ); + + this.el.inputText.innerHTML = ''; + this.el.panelEmojis.removeClass('open'); + + // this.el.inputPlaceholder.show(); + + }); + + // Abrir/Fechar painel de emojis + this.el.btnEmojis.on('click', e => { + + this.el.panelEmojis.toggleClass('open'); + + }); + + // Selecionar emoji + this.el.panelEmojis.querySelectorAll('.emojik').forEach(emoji => { + + emoji.on('click', e => { + + // Clona o emoji para a variável img + let img = this.el.imgEmojiDefault.cloneNode(); + img.style.cssText = emoji.style.cssText; + img.dataset.unicode = emoji.dataset.unicode; + img.alt = emoji.alt; + emoji.classList.forEach(name => { + img.classList.add(name); + }); + + // Seleciona a posição onde está o cursor + let cursor = window.getSelection(); + + // Verifica o foco do cursor (onde ele está) + if(!cursor.focusNode || cursor.focusNode.id == 'input-text'){ + this.el.inputText.focus(); + cursor = window.getSelection(); + } + + // Pega a faixa de texto selecionada e apaga para colocar o emoji ali + let range = document.createRange(); + range = cursor.getRangeAt(0); + range.deleteContents(); + + // Cria um espaço para colocar a imagem do emoji + let frag = document.createDocumentFragment(); + frag.appendChild(img); + range.insertNode(frag); + + // Coloca o cursor depois da imagem do emoji + range.setStartAfter(img); + + this.el.inputText.dispatchEvent(new Event('keyup')); + + }); + + }); + + } + + // Fecha a gravação do Microfone + closeRecordMicrophone(){ + + this.el.recordMicrophone.hide(); + + this.el.btnSendMicrophone.show(); + + this.el.recordMicrophoneTimer.innerHTML = Format.toTime(0); + + } + + // Fecha todos os painéis principais + closeAllMainPanel(){ + + this.el.panelMessagesContainer.hide(); + this.el.panelDocumentPreview.removeClass('open'); + this.el.panelCamera.removeClass('open'); + + } + + // Fecha o menu de anexos + closeMenuAttach(e){ + + document.removeEventListener('click', this.closeMenuAttach); + this.el.menuAttach.removeClass('open'); + + } + + // Fecha todos os painéis que ficam na esquerda da tela + closeAllLeftPanel(){ + + this.el.panelEditProfile.hide(); + this.el.panelAddContact.hide(); + + } + +} \ No newline at end of file diff --git a/src/model/Chat.js b/src/model/Chat.js new file mode 100644 index 000000000..76ee2ed8f --- /dev/null +++ b/src/model/Chat.js @@ -0,0 +1,96 @@ +import {Model} from "./Model" +import {Firebase} from "../util/Firebase" + +export class Chat extends Model{ + + constructor(){ + super(); + } + + get users(){ + return this._data.users; + } + set users(value){ + this._data.users = value + } + + get timeStamp(){ + return this._data.timeStamp; + } + set timeStamp(value){ + this._data.timeStamp = value + } + + static getRef(){ + return Firebase.db().collection('/chats'); + } + + static create(meEmail, contactEmail){ + + return new Promise((s,f) => { + + let users = {}; + + users[btoa(meEmail)] = true; + users[btoa(contactEmail)] = true; + + Chat.getRef().add({ + users, + timeStamp: new Date() + }).then(doc => { + + Chat.getRef().doc(doc.id).get().then(chat => { + + s(chat); + + }).catch(err => { f(err); }); + + }).catch(err => { f(err); }); + + }); + + } + + static find(meEmail, contactEmail){ + + // .where faz a busca (chave que procuramos,compara, segundo elemento a comparar com o primeiro) + return Chat.getRef() + .where(btoa(meEmail),'==', true) + .where(btoa(contactEmail),'==', true).get(); + + + } + + static createIfNotExists(meEmail, contactEmail){ + + return new Promise ((s,f) => { + + Chat.find(meEmail, contactEmail).then(chats => { + + if(chats.empty){ + + Chat.create(meEmail, contactEmail).then(chat => { + + s(chat); + + }); + + } else { + + chats.forEach(chat => { + + s(chat); + + }); + + } + + }).catch(err => { + f(err); + }); + + }); + + } + +} \ No newline at end of file diff --git a/src/model/Message.js b/src/model/Message.js new file mode 100644 index 000000000..996f9b98a --- /dev/null +++ b/src/model/Message.js @@ -0,0 +1,450 @@ +import {Model} from "./Model" +import {Firebase} from "../util/Firebase"; +import {Format} from "../util/Format"; + +export class Message extends Model { + + constructor(){ + + super(); + + } + + get id(){ + return this._data.id; + } + set id(value){ + return this._data.id = value; + } + + get content(){ + return this._data.content; + } + set content(value){ + return this._data.content = value; + } + + get type(){ + return this._data.type; + } + set type(value){ + return this._data.type = value; + } + + get timeStamp(){ + return this._data.timeStamp; + } + set timeStamp(value){ + return this._data.timeStamp = value; + } + + get status(){ + return this._data.status; + } + set status(value){ + return this._data.status = value; + } + + // Mostra a mensagem de acordo com seu tipo + getViewElement(me = true){ + + let div = document.createElement('div'); + + div.className = 'message'; + + switch(this.type){ + + case 'contact': + + div.innerHTML = ` +
+ + +
+
+
+
+ +
+ + + + + + + + +
+
+
+
+
Nome do Contato Anexado
+
+
+
+ ${Format.timeStampToTime(this.timeStamp)} +
+
+
+
+
Enviar mensagem
+
+
+ +
+ `; + + break; + + case 'image': + + div.innerHTML = ` +
+
+
+
+
+
+
+ + + +
+
+ + + + + +
+
+
+ +
+
+
+
+ Texto da foto +
+
+
+
+ ${Format.timeStampToTime(this.timeStamp)} + +
+
+
+
+ +
+ + + + + +
+
+ + `; + + div.querySelector('.message-photo').on('load', e=>{ + console.log('load ok'); + }); + + break; + + case 'document': + + div.innerHTML = ` +
+
+ +
+
+
+
+
+
+ Arquivo.pdf +
+
+ + + + + + + +
+
+
+
+ 32 páginas + PDF + 4 MB +
+
+
+ ${Format.timeStampToTime(this.timeStamp)} +
+
+
+
+ `; + + break; + + case 'audio': + + div.innerHTML = ` +
+
+
+
+
+
+ + + + + +
+
+
0:05
+
+ + + +
+
+
+
+
+
+ + + + + + +
+
+
+
+ +
+ + + + + + + + +
+
+
+
+
+
+ ${Format.timeStampToTime(this.timeStamp)} +
+
+
+ +
+ + + + + +
+
+ `; + + break; + + default: + + div.innerHTML = ` +
+ + +
+
+ ${this.content} +
+
+
+ ${Format.timeStampToTime(this.timeStamp)} +
+
+
+
+ `; + + } + + let className = 'message-in'; + + if (me) { + + className = 'message-out'; + + // coloca o status como irmão do message-time + div.querySelector('.message-time').parentElement.appendChild(this.getStatusViewElement()); + + } + + div.firstElementChild.classList.add(className); + + return div; + + } + + static sendImage(chatId, from, file){ + + return new Promise((s,f) => { + + // put é pra colocar o blob no banco + let uploadTask = Firebase.hd().ref(from).child(Date.now() + '_', + file.name).put(file); + + uploadTask.on('state_changed', e => { + + console.info('upload', e); + + }, err => { + + console.error(err); + + }, () => { + + console.log('uploadTask.snapshot.downloadURL: ',uploadTask.snapshot.downloadURL); + + Message.send( + chatId, + from, + 'image', + uploadTask.snapshot.downloadURL) + .then(()=>{ + s(); + }); + + }); + + }); + + } + + // Envia mensagem + static send(chatId, from, type, content){ + + return new Promise((s,f) => { + + // Adiciona a nova mensagem ao banco de dados + Message.getRef(chatId).add({ + content, + timeStamp: new Date(), + status: 'wait', + type, + from + }).then(result => { + + // Vai no documento pai e procura o result.id, ou seja o id da mensagem que acabou de ser inserida, e muda o status para enviada + result.parent.doc(result.id).set({ + status: 'sent' + }, { + merge: true // as demais informações continuam + }).then(()=>{ + s(); + }); + + }); + + }); + + } + + // Pega a referência do banco de dados + static getRef(chatId){ + + return Firebase.db() + .collection('chats') + .doc(chatId) + .collection('messages'); + + } + + getStatusViewElement(){ + + let div = document.createElement('div'); + + div.className = 'message-status'; + + switch(this.status){ + + case 'wait': + div.innerHTML = ` + + + + + + `; + break; + + case 'sent': + div.innerHTML = ` + + + + + + `; + break; + + case 'received': + div.innerHTML = ` + + + + + + ` + break; + + case 'read': + div.innerHTML = ` + + + + + + `; + break; + + //default: + + } + + return div; + + } + +} \ No newline at end of file diff --git a/src/model/Model.js b/src/model/Model.js new file mode 100644 index 000000000..924bf9392 --- /dev/null +++ b/src/model/Model.js @@ -0,0 +1,24 @@ +import {ClassEvent} from './../util/ClassEvent' + +export class Model extends ClassEvent{ + + constructor(){ + super(); + + this._data = {}; + } + + fromJSON(json){ + // Pega o json e junta no _data + this._data = Object.assign(this._data, json); + + // Define o que o evento datachange faz + this.trigger('datachange', this.toJSON()); + } + + toJSON(){ + return this._data; + } + + +} \ No newline at end of file diff --git a/src/model/User.js b/src/model/User.js new file mode 100644 index 000000000..7a19ba633 --- /dev/null +++ b/src/model/User.js @@ -0,0 +1,117 @@ +import {Firebase} from './../util/Firebase' +import {Model} from './Model' + +export class User extends Model{ + + constructor(id){ + + super(); + + if(id) this.getById(id); + + } + + get name(){ + return this._data.name; + } + set name(value){ + this._data.name = value; + } + + get email(){ + return this._data.email; + } + set email(value){ + this._data.email = value; + } + + get photo(){ + return this._data.photo; + } + set photo(value){ + this._data.photo = value; + } + + get chatId(){ + return this._data.chatId; + } + set chatId(value){ + this._data.chatId = value; + } + + // Retorna a promessa com os documentos do usuário + getById(id){ + + return new Promise((s,f) => { + + User.findByEmail(id).onSnapshot(doc => { + + this.fromJSON(doc.data()); + + s(doc); + + }); + + }); + + } + + save(){ + return User.findByEmail(this.email).set(this.toJSON()); + } + + // Pega a referência do bando de dados + static getRef(){ + return Firebase.db().collection('/users'); + + } + + static getContactsRef(id){ + return User.getRef() + .doc(id) + .collection('contacts'); + } + + // Busca informações do documento do usuário por email + static findByEmail(email){ + return User.getRef().doc(email); + } + + addContact(contact){ + return User.getContactsRef(this.email) + .doc(btoa(contact.email))// btoa() base64 + .set(contact.toJSON()); + } + + getContacts(filter = ''){ + + console.log(filter); + + return new Promise((s,f) => { + + // Esse comando >= tem problema + User.getContactsRef(this.email).where('name', '>=', filter).onSnapshot(docs=>{ + + let contacts = []; + + docs.forEach(doc => { + + let data = doc.data(); + + data.id = doc.id; + + contacts.push(data); + + }); + + this.trigger('contactschange', docs); + + s(contacts); + + }); + + }); + + } + +} \ No newline at end of file diff --git a/src/util/ClassEvent.js b/src/util/ClassEvent.js new file mode 100644 index 000000000..42cb0f190 --- /dev/null +++ b/src/util/ClassEvent.js @@ -0,0 +1,36 @@ +export class ClassEvent{ + + constructor(){ + this._events = {}; + } + + on(eventName, fn){ + + if(!this._events[eventName]) this._events[eventName] = new Array(); + + this._events[eventName].push(fn); + + } + + trigger(){ + + // Contém 0 ou todos os argumentos passados no parâmetro + let args = [...arguments]; + + // Retorna o primeiro elemento de um array e remove ele + let eventName = args.shift(); + + args.push(new Event(eventName)); + + if(this._events[eventName] instanceof Array){ + + this._events[eventName].forEach(fn => { + + // executa a fn + fn.apply(null, args); + + }); + + } + } +} \ No newline at end of file diff --git a/src/util/Firebase.js b/src/util/Firebase.js new file mode 100644 index 000000000..dc77aeb93 --- /dev/null +++ b/src/util/Firebase.js @@ -0,0 +1,67 @@ +// v9 compat packages are API compatible with v8 code +import firebase from 'firebase/compat/app'; +import 'firebase/compat/auth'; +import 'firebase/compat/firestore'; +import 'firebase/compat/storage'; + +export class Firebase{ + constructor(){ + this._config = { + apiKey: "AIzaSyCgk1c7coRQI00XkOB1-8srhrzcmDbwPRU", + authDomain: "whatsapp-clone-7683c.firebaseapp.com", + projectId: "whatsapp-clone-7683c", + storageBucket: "whatsapp-clone-7683c.appspot.com", + messagingSenderId: "568059323995", + appId: "1:568059323995:web:7870f38cc6105c66ea817e", + measurementId: "G-SM1RBM5TD1" + } + this.init(); + } + + init(){ + // Initialize Firebase + + if(!window._initializedFirebase){ + + const firebaseApp = firebase.initializeApp(this._config); + + window._initializedFirebase = true; + + } + } + + static db(){ + return firebase.firestore(); + } + + static hd(){ + return firebase.storage(); + } + + initAuth(){ + // Autenticação por email + + return new Promise((s,f) => { + + let provider = new firebase.auth.GoogleAuthProvider(); + + firebase.auth().signInWithPopup(provider).then(result => { + + // token do usuário no firebase, é usada por segurança pois é temporário + let token = result.credential.accessToken; + + let user = result.user; + + s({user:user,token:token}); + + }).catch(err=>{ + + f(err); + + }); + + + }) + } + +} \ No newline at end of file diff --git a/src/util/Format.js b/src/util/Format.js new file mode 100644 index 000000000..b533df201 --- /dev/null +++ b/src/util/Format.js @@ -0,0 +1,41 @@ +export class Format{ + + static getCamelCase(text){ + + let div = document.createElement('div'); + + div.innerHTML = `
`; + + return Object.keys(div.firstChild.dataset)[0]; + } + + static toTime(duration){ + + let seconds = parseInt((duration/(1000)) % 60); + let minutes = parseInt((duration/(1000 * 60)) % 60); + let hours = parseInt((duration/(1000 * 60 * 60)) % 24); + + if(hours > 0){ + return `${hours}:${minutes.toString().padStart(2,'0')}:${seconds.toString().padStart(2,'0')}`; + } else { + return `${minutes}:${seconds.toString().padStart(2,'0')}`; + } + + } + + static dateToTime(date, locale = 'pt-BR'){ + + return date.toLocaleTimeString(locale, { + hour: '2-digit', + minute: '2-digit' + }); + + } + + static timeStampToTime(timeStamp){ + + return (timeStamp && typeof timeStamp.toDate === 'function') ? Format.dateToTime(timeStamp.toDate()) : ''; + + } + +} \ No newline at end of file diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 000000000..4bd254e38 --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,12 @@ +let path = require('path'); + +module.exports = { + + entry: './src/app.js', + output: { + filename: 'bundle.js', + path: path.resolve(__dirname,'/dist'), + publicPath: 'dist' + } + +} \ No newline at end of file