diff --git a/nginx/nginx.conf b/nginx/nginx.conf index b8035a10..da5ad779 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -25,6 +25,17 @@ http { proxy_set_header X-Forwarded-Proto $scheme; } + location /websocket/ { + proxy_pass https://host.docker.internal:8008/api/messages/setconn; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_buffering off; + proxy_read_timeout 86400; + } + location /images/default.png { proxy_pass http://frontend:5173/default.png; } diff --git a/public/chat-background.png b/public/chat-background.png new file mode 100644 index 00000000..76d4802a Binary files /dev/null and b/public/chat-background.png differ diff --git a/src/components/BaseComponent/BaseComponent.ts b/src/components/BaseComponent/BaseComponent.ts new file mode 100644 index 00000000..2a13f88f --- /dev/null +++ b/src/components/BaseComponent/BaseComponent.ts @@ -0,0 +1,64 @@ +'use strict'; + +interface BaseComponentData { + parent: HTMLElement; + id: number | string; + templateData: { [key: string]: unknown }; +} + +export default abstract class BaseComponent { + protected parent: HTMLElement; + protected templateData; + + protected template: HandlebarsTemplateDelegate; + private elementId: string; + + public thisElement: HTMLElement; + + /** + * Creates a basic component + * @param data - Data for base component initialization + */ + constructor(data: BaseComponentData) { + this.thisElement = null as unknown as HTMLElement; // fuck typescript =D + + this.elementId = this.constructor.name + '-' + data.id; + + // Automatic template set + const templateName = `${this.constructor.name}.hbs`; + this.template = Handlebars.templates[templateName]; + if (!this.template) { + throw new Error('No such template found:' + templateName); + } + + this.parent = data.parent; + this.templateData = data.templateData; + } + + /** + * Func that attaches event listeners. + */ + protected abstract addEventListeners(): void; + + /** + * Called only once. + */ + render() { + this.parent.insertAdjacentHTML( + 'beforeend', + this.template({ + id: this.elementId, + ...this.templateData, + }) + ); + + // Wait till browser renders the component + requestAnimationFrame(() => { + this.thisElement = document.getElementById( + this.elementId + ) as HTMLElement; + + this.addEventListeners(); + }); + } +} diff --git a/src/components/ChatWindow/ChatWindow.hbs b/src/components/ChatWindow/ChatWindow.hbs new file mode 100644 index 00000000..a177cf3e --- /dev/null +++ b/src/components/ChatWindow/ChatWindow.hbs @@ -0,0 +1,46 @@ + + +
+
+ +
+ +
+ + +
+
\ No newline at end of file diff --git a/src/components/ChatWindow/ChatWindow.scss b/src/components/ChatWindow/ChatWindow.scss new file mode 100644 index 00000000..b6e3a3a8 --- /dev/null +++ b/src/components/ChatWindow/ChatWindow.scss @@ -0,0 +1,109 @@ +.chat-window { + &__chat-window { + flex: 1 1; + background: url('/chat-background.png'); + box-sizing: border-box; + margin: 0 auto; + + display: flex; + flex-direction: column; + + &__messages { + overflow-y: auto; + flex: 1 1 0; + display: flex; + flex-direction: column; + row-gap: 10px; + padding: 10px 20px 10px; + } + + &__message { + display: flex; + flex-direction: row; + justify-content: space-between; + flex: 0 0 auto; + gap: 15px; + + width: fit-content; + height: min-content; + min-width: 70px; + min-height: 30px; + background-color: #ffffff; + border-radius: 20px; + align-items: flex-start; + box-sizing: border-box; + padding: 10px 10px; + align-items: end; + + &__text { + font-size: 17px; + line-height: 24px; + } + + &__time-wrapper { + display: flex; + align-items: end; + flex: 1 1; + } + + &__time { + font-size: 12px; + color: #15224266; + } + + &--mine { + margin-left: auto; + color: #fff; + background-color: #0468ff; + .chat-window__chat-window__message__time { + color: #fff; + } + } + } + + &__bottom-part { + padding: 0 20px; + box-sizing: border-box; + border-top: 1px solid #e4e4e4; + background-color: #fff; + width: 100%; + } + + &__input { + display: block; + padding: 4px 12px; + width: 100%; + height: 31px; + line-height: 20px; + overflow: hidden; + outline: none; + border: 1px solid #ced1d7; + font-family: Inter; + font-size: 16px; + box-sizing: border-box; + border-radius: 16px; + resize: none; + } + + &__bottom-part { + padding: 5px 10px; + display: flex; + gap: 10px; + align-items: flex-end; + } + + &__send-button { + display: flex; + justify-content: center; + align-items: center; + border: none; + background-color: #0468ff; + width: 32px; + height: 32px; + color: #fff; + rotate: 90deg; + border-radius: 50%; + cursor: pointer; + } + } +} diff --git a/src/components/ChatWindow/ChatWindow.ts b/src/components/ChatWindow/ChatWindow.ts new file mode 100644 index 00000000..d013eb08 --- /dev/null +++ b/src/components/ChatWindow/ChatWindow.ts @@ -0,0 +1,154 @@ +import BaseComponent from '../BaseComponent/BaseComponent'; +import globalStore from '../../modules/GlobalStore'; +import { convertTimeToMinutesAndSeconds } from '../../modules/Utils'; +import { Message } from '../../repositories/ChatRepository'; + +export default class ChatWindow extends BaseComponent { + private messages: Message[]; + private messagesContainer: HTMLDivElement; + private recipientId: string; + + constructor(parent: HTMLElement, recipientId: string, messages: Message[]) { + super({ + parent: parent, + id: '0', + templateData: {}, + }); + + this.recipientId = recipientId; + this.messages = messages; + + requestAnimationFrame(() => { + this.messagesContainer = document.getElementById('js-messages'); + + this.displayMessageHistory(); + + this.messagesContainer.scrollTop = + this.messagesContainer.scrollHeight; + }); + + if (globalStore.chat.socket) { + globalStore.chat.socket.close(); + } + + globalStore.chat.socket = new WebSocket( + `wss://${window.location.hostname}/websocket/` + ); + } + + private async displayMessageHistory() { + for (const message of this.messages) { + this.addNewMessageElement(message); + } + } + + private attachSocketEventListeners() { + const socket = globalStore.chat.socket; + if (!socket) { + throw new Error('socket is null!'); + } + + socket.onopen = (e) => { + console.log('[open] Соединение установлено'); + console.log('Отправляем данные на сервер'); + }; + + socket.onmessage = (event) => { + console.log(`[message] Данные получены с сервера: ${event.data}`); + this.addNewMessageElement(JSON.parse(event.data) as Message); + }; + + socket.onclose = function (event) { + if (event.wasClean) { + console.log( + `[close] Соединение закрыто чисто, код=${event.code} причина=${event.reason}` + ); + } else { + // например, сервер убил процесс или сеть недоступна + // обычно в этом случае event.code 1006 + console.log('[close] Соединение прервано'); + } + }; + + socket.onerror = function (error) { + console.error(error); + }; + } + + protected addEventListeners(): void { + this.attachSocketEventListeners(); + + window.onbeforeunload = () => { + // If user wants to refresh page close the connection + globalStore.chat.socket?.close(); + globalStore.chat.socket = null; + }; + + const textArea = this.thisElement.querySelector( + '.js-message-input' + ) as HTMLInputElement; + textArea.oninput = () => { + setTimeout(function () { + textArea.style.cssText = + 'height:' + textArea.scrollHeight + 'px'; + }, 0); + }; + + const sendMessageButton = document.getElementById( + 'js-send-message-button' + ) as HTMLButtonElement; + + sendMessageButton.onclick = () => { + const text = textArea.value; + this.sendMessage(text); + textArea.value = ''; + }; + + textArea.onkeydown = (event) => { + if (event.ctrlKey && event.key === 'Enter') { + const text = textArea.value; + this.sendMessage(text); + textArea.value = ''; + } + }; + } + + private sendMessage(text: string) { + globalStore.chat.socket?.send( + JSON.stringify({ + receiverId: this.recipientId, + content: text, + }) + ); + this.addNewMessageElement({ + content: text, + receiverId: '', + senderId: globalStore.auth.userId!, + id: 0, + createdAt: new Date().toISOString(), + }); + } + + private addNewMessageElement(message: Message) { + const template = document.getElementById( + 'js-chat-message-template' + ) as HTMLTemplateElement; + + const newMessage = template.content.cloneNode(true) as DocumentFragment; + ( + newMessage.querySelector('.js-message-text') as HTMLSpanElement + ).textContent = message.content; + + ( + newMessage.querySelector('.js-message-time') as HTMLSpanElement + ).textContent = convertTimeToMinutesAndSeconds(message.createdAt); + + if (message.senderId == globalStore.auth.userId) { + newMessage.children[0]!.classList!.add( + 'chat-window__chat-window__message--mine' + ); + } + + this.messagesContainer.appendChild(newMessage); + } +} diff --git a/src/components/Header/Header.scss b/src/components/Header/Header.scss index 17b7de92..19c4dc37 100644 --- a/src/components/Header/Header.scss +++ b/src/components/Header/Header.scss @@ -1,6 +1,7 @@ .header { background-color: #ffffff; height: 80px; + box-sizing: content-box; display: flex; align-items: center; border-bottom: 1px #e8e9ec solid; diff --git a/src/components/ProfilePopup/ProfilePopup.ts b/src/components/ProfilePopup/ProfilePopup.ts index d6fd0126..20ad9195 100644 --- a/src/components/ProfilePopup/ProfilePopup.ts +++ b/src/components/ProfilePopup/ProfilePopup.ts @@ -35,6 +35,10 @@ class ProfilePopup { title: 'Мои объявления', href: '/ads/?author=me', }, + chats: { + title: 'Мессенджер', + href: '/chats', + }, logout: { title: 'Выйти', href: '', diff --git a/src/index.ts b/src/index.ts index d7ed5750..6785bafd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,8 @@ import router from './modules/Router'; import { HorizontalAdCardData } from './components/HorizontalAdCard/HorizontalAdCard'; import { getCookie } from './modules/Utils'; import globalStore from './modules/GlobalStore'; +import ChatPage from './pages/ChatPage/ChatPage'; +import ChatRepository from './repositories/ChatRepository'; const renderMainPage = async () => { const data = await ApiClient.getAds(); @@ -202,6 +204,14 @@ router.addRoute('/profiles', async (params: URLSearchParams) => { page.render(pageContainer); }); +router.addRoute('/chats', async (params: URLSearchParams) => { + const recipientId = params.get('recipientId') as string; + + const data = await ChatRepository.getAll(); + const chatPage = new ChatPage(pageContainer, data, recipientId); + chatPage.render(); +}); + const init = async () => { await renderHeader(); pageContainer.classList.add('page-container'); diff --git a/src/modules/GlobalStore.ts b/src/modules/GlobalStore.ts index 97d1e193..9c878df4 100644 --- a/src/modules/GlobalStore.ts +++ b/src/modules/GlobalStore.ts @@ -3,6 +3,9 @@ const globalStore = { isAuthorized: false as boolean, userId: null as string | null, }, + chat: { + socket: null as WebSocket | null, + }, }; export default globalStore; diff --git a/src/modules/Utils.ts b/src/modules/Utils.ts index 84a00a85..9bd3e46a 100644 --- a/src/modules/Utils.ts +++ b/src/modules/Utils.ts @@ -161,3 +161,8 @@ export const updateDOM = (oldElement: HTMLElement, newElement: HTMLElement) => { } compareAndReplace(oldElement, newElement); }; + +export const convertTimeToMinutesAndSeconds = (isoString: string): string => { + const date = new Date(isoString); + return date.toTimeString().slice(0, 5); +}; diff --git a/src/pages/AdPage/AdPage.hbs b/src/pages/AdPage/AdPage.hbs index c7aa7db2..94cabc69 100644 --- a/src/pages/AdPage/AdPage.hbs +++ b/src/pages/AdPage/AdPage.hbs @@ -252,10 +252,14 @@
- + {{#if isAuthor}}
\ No newline at end of file diff --git a/src/pages/ChatPage/ChatPage.scss b/src/pages/ChatPage/ChatPage.scss new file mode 100644 index 00000000..c5d08118 --- /dev/null +++ b/src/pages/ChatPage/ChatPage.scss @@ -0,0 +1,48 @@ +.chat-page { + display: flex; + flex: 1 1 auto; +} + +.recipients-list { + border-right: 1px solid #e4e4e4; + width: 25%; +} + +.recipient-card { + display: flex; + align-items: center; + gap: 20px; + user-select: none; + border-bottom: 1px solid #e4e4e4; + background-color: #fff; + cursor: pointer; + + &:hover { + background-color: #f3f6ff; + } + + &__avatar { + width: 120px; + height: 120px; + padding: 20px; + box-sizing: border-box; + object-fit: cover; + } + + &__info { + display: flex; + flex-direction: column; + gap: 10px; + } + + &__name { + color: #152242; + font-weight: 600; + font-family: Inter; + } + + &__text { + color: #737a8e; + font-size: 14px; + } +} diff --git a/src/pages/ChatPage/ChatPage.ts b/src/pages/ChatPage/ChatPage.ts new file mode 100644 index 00000000..8a1a2284 --- /dev/null +++ b/src/pages/ChatPage/ChatPage.ts @@ -0,0 +1,56 @@ +import BaseComponent from '../../components/BaseComponent/BaseComponent'; +import ChatWindow from '../../components/ChatWindow/ChatWindow'; +import ChatRepository, { Chat } from '../../repositories/ChatRepository'; + +export default class ChatPage extends BaseComponent { + constructor( + parent: HTMLElement, + data: { chats: Chat[] }, + startChatWithRecipientId?: string + ) { + super({ + parent: parent, + id: '', + templateData: data, + }); + + if (!startChatWithRecipientId) return; + + requestAnimationFrame(async () => { + const data = await ChatRepository.get(startChatWithRecipientId); + + const chatWindow = new ChatWindow( + this.thisElement, + startChatWithRecipientId, + data + ); + + chatWindow.render(); + }); + } + + protected addEventListeners(): void { + const cards = document.getElementsByClassName( + 'recipient-card' + ) as HTMLCollectionOf; + + for (const el of cards) { + (el as HTMLElement).onclick = async () => { + if (!el.dataset.id) { + throw new Error('recipient id is not defined'); + } + + document.getElementById('ChatWindow-0')?.remove(); + const data = await ChatRepository.get(el.dataset.id); + + const chatWindow = new ChatWindow( + this.thisElement, + el.dataset.id!, + data + ); + + chatWindow.render(); + }; + } + } +} diff --git a/src/repositories/ChatRepository.ts b/src/repositories/ChatRepository.ts new file mode 100644 index 00000000..dea7d845 --- /dev/null +++ b/src/repositories/ChatRepository.ts @@ -0,0 +1,30 @@ +import Ajax from '../modules/Ajax'; + +export interface Message { + content: string; + createdAt: string; + id: number; + receiverId: string; + senderId: string; +} + +export interface Chat { + authorAvatar: string; + authorName: string; + authorUuid: string; + lastDate: string; + lastMessage: string; +} + +export default class ChatRepository { + static async getAll() { + const response = await Ajax.get('/api/messages/chats'); + return (await response.json()) as { chats: Chat[] }; + } + + static async get(recipientId: string) { + const response = await Ajax.get(`/api/messages/chat/${recipientId}`); + const data = await response.json(); + return data['chat'] as Message[]; + } +} diff --git a/src/styles/mixins.scss b/src/styles/mixins.scss index 6aaaab86..85f53c2b 100644 --- a/src/styles/mixins.scss +++ b/src/styles/mixins.scss @@ -11,6 +11,7 @@ border: none; font-size: 16px; font-weight: 600; + user-select: none; cursor: pointer; transition: transform 0.1s, diff --git a/src/styles/styles.sass b/src/styles/styles.sass index 186f0aa7..e9036bfd 100644 --- a/src/styles/styles.sass +++ b/src/styles/styles.sass @@ -16,6 +16,7 @@ @use "../src/components/ProfileData/NoReviews/NoReviews" @use "../src/components/Spinner/Spinner" @use '../src/components/HorizontalAdCard/HorizontalAdCard' +@use '../src/components/ChatWindow/ChatWindow' @use '../src/components/BookingCalendar/BookingCalendar' /* pages */ @@ -26,6 +27,7 @@ @use "../src/pages/MapPage/MapPage" @use "../src/pages/CityPage/CityPage" @use '../src/pages/AdPage/AdPage' +@use '../src/pages/ChatPage/ChatPage' @use '../src/pages/FavouritePage/FavouritePage' @@ -39,13 +41,28 @@ margin: 0 padding: 0 +html + height: 100% body + display: flex + flex-direction: column + height: 100% font-optical-sizing: auto font-style: normal font-family: Inter, Arial, 'sans-serif' background-color: #fff +#root + flex: 1 1 auto + height: 100% + display: flex + flex-direction: column + +.page-container + flex: 1 1 + display: flex + flex-direction: column @font-face font-family: MarckScript-Regular