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 @@
+
+
+
+ Тут текст сообщения
+
+
+
+ 00:00
+
+
+
+
+
+
\ 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 @@
\ 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