Messenger. Технический обзор клиента (Vue.js)

В этом материале я опишу общую архитектуру и некоторые детали реализации клиентской части Messenger. Обзор будет не слишком подробным, т.к если пытаться описать каждый компонент приложения, статья напротив, получится очень объемной. В целом, принципы реализации от компонента к компоненту схожи. Но в случаях, когда есть на что обратить особое внимание, я постараюсь раскрыть детали подробнее.

Клиент платформы CheckerWars написан на фреймворке Vue.js с использованием Quasar Framework. Первоначально разработка велась на чистом Javascript, но в последствии было принято решение переписать все на Typescript — что в значительной степени уже сделано. Кроме того, я так же принял решение отказаться от Qusasr, часть страниц, компонентов уже используют чистый Vue.js с Typescript.

Так же есть подробный обзор сервера, который написан на Nest.js:

Но продолжим разбираться с клиентом на Vue.js.

При разработке клиентского приложения CheckerWars я обращаю серьезное внимание на оптимизацию зависимостей. В конечном итоге, я хочу минимизировать использование внешних зависимостей, что бы иметь максимальный контроль над кодом и функционалом, а так же не загромождать код тяжеловесными и избыточными во многих случаях библиотеками, вроде Moment.js.

Основные моменты:

  • Используется фреймворк Vue.js 3
  • Преимущественно используется Options API
  • Код пишется на Typescript
  • Интерфейс во многом сделан с использованием Quasar framework (но в процессе отказа от него)
  • Используется хранилище Pinia
  • Передача данных в реальном времени осуществляется при помощи библиотеки Socket.io
  • Версия PWA (средствами Quasar framework)
  • Версия под Android с использованием Capacitor (средствами Quasar framework)
  • Клиент, как и сервер, упакован в Docker контейнер. Имеется два режима запуска проекта — developer и production.

В статье по ссылке ниже, приведена конфигурация Docker для этого приложения:


Роутер

Данная структура маршрутизации в проекте Vue.js определяет различные пути и их соответствующие компоненты для рендера. Ниже приведем краткое описание каждого маршрута:

Главный файл маршрута

Это основная конфигурация роутера, в нее подключаются маршруты для вложенных страниц (файлы маршрутов размещены в папках модулей проекта):

Главная страница

  • Путь: '/'
  • Компонент: HelloPage.vue
  • Описание: Отображает статичную страницу приветствия

Приложение

  • Путь: '/app'
  • Компонент: AppLayout.vue
  • Вложенные маршруты: app
  • Описание: Второстепенные страницы приложения, такие как «О проекте»

Мессенджер

  • Путь: '/im'
  • Компонент: ImLayout.vue
  • Вложенные маршруты: messenger
  • Описание: Страницы мессенджера

Пользователи

  • Путь: '/users'
  • Компонент: AppLayout.vue
  • Вложенные маршруты: users
  • Описание: Страницы для управления и просмотра информации о пользователях.

Аккаунт

  • Путь: '/account'
  • Компонент: AppLayout.vue
  • Вложенные маршруты: account
  • Описание: Управление настройками учетной записи.

Профиль

  • Путь: '/profile'
  • Компонент: AppLayout.vue
  • Вложенные маршруты: profile
  • Описание: Просмотр и редактирование профиля пользователя.

Друзья

  • Путь: '/friends'
  • Компонент: AppLayout.vue
  • Вложенные маршруты: friends
  • Описание: Взаимодействие с друзьями пользователя.

Аутентификация

  • Путь: '/auth'
  • Компонент: NLayout.vue
  • Вложенные маршруты: auth
  • Описание: Управление входом и регистрацией пользователей.

Кроме того, есть маршрут /:catchAll(.*)* который перехватывает неопознанные страницы, и переадресовавает запросы на страницу ErrorNotFound.vue.

Этот список описывает, как различные части приложения ассоциируются с определенными путями на уровне маршрутизации, обеспечивая организацию и доступ к различным функциональным областям приложения.

Маршруты для Messenger:

Главная страница подписки

  • Путь: ''
  • Компонент: SubscribePage
  • Описание: Страница друзей и подписок пользователя

Страница чата

  • Путь: ':idMixed'
  • Компонент: ChatPage
  • Описание: Страница с чатом

Страница создания комнаты

  • Путь: 'create/:type'
  • Компонент: RoomCreate
  • Описание: Страница с формой создания новой коснаты (группа, канал)

Редактирование аватара

  • Путь: 'avatar/:roomId'
  • Компонент: AvatarEdit
  • Описание: Страница с формой для редакирования аватара

Страница комнаты

  • Путь: 'room/:pageId'
  • Компонент: RoomPage
  • Описание: Страница с описанием комнаты (группа, канал)

Редактирование комнаты

  • Путь: 'room-edit/:pageId'
  • Компонент: RoomEdit
  • Описание: Страница редактирования коснаты (группы, канала)

Поиск комнат

  • Путь: 'rooms/:type'
  • Компонент: RoomSearch
  • Описание: Страница со списком и формой поиска комнат (групп, каналы)

Маршруты для Account

Получение информации об аккаунте

  • Путь: /:userId
  • Компонент: GetAccount.vue
  • Описание: Отображает информацию о пользователе и загружает соответствующий компонент

Редактирование никнейма

  • Путь: /nickname/:userId
  • Компонент: EditNickname.vue
  • Описание: Позволяет изменить никнейм пользователя

Редактирование email

  • Путь: /email/:userId
  • Компонент: EditEmail.vue
  • Описание: Позволяет пользователю изменить свой email

Изменение пароля

  • Путь: /password/:userId
  • Компонент: EditPassword.vue
  • Описание: Предоставляет функциональность смены пароля пользователя

Редактирование аватара

  • Путь: /avatar/:userId
  • Компонент: EditAvatar.vue
  • Описание: Позволяет пользователю изменить аватар

Просмотр сессий

  • Путь: /sessions/:userId
  • Компонент: GetSessions.vue
  • Описание: Отображает активные сессии пользователя

Маршруты для Profile

Основной маршрут

  • Путь: /:userId
  • Компонент: GetProfile.vue
  • Описание: Отображение профиля конкретного пользователя

Редактирование имени

  • Путь: name/:userId
  • Компонент: EditName.vue
  • Описание: Интерфейс для изменения имени пользователя

Редактирование даты рождения

  • Путь: birthday/:userId
  • Компонент: EditBirthday.vue
  • Описание: Изменение данных о дате рождения пользователя

Редактирование пола

  • Путь: sex/:userId
  • Компонент: EditSex.vue
  • Описание: Изменение данных о поле пользователя

Редактирование местоположения

  • Путь: location/:userId
  • Компонент: EditLocation.vue
  • Описание: Интерфейс для изменения местоположения пользователя

Редактирование информации о себе

  • Путь: about/:userId
  • Компонент: EditAbout.vue
  • Описание: Изменение информации о пользователе

Маршруты для Friend

Маршрут «Взаимные друзья» (mutually):

  • Путь: mutually/:userId
  • Компонент: FriendsUsers.vue
  • Описание: Вкладка «взаимные друзья» на странице списка друзей

Маршрут «Подписки» (subscribed):

  • Путь: subscribed/:userId
  • Компонент: FriendsUsers.vue
  • Описание: Вкладка «подписки» на странице списка друзей

Маршрут «Подписчики» (subscriber):

  • Путь: subscriber/:userId
  • Компонент: FriendsUsers.vue
  • Описание: Вкладка «подписчики» на странице списка друзей

Маршруты для Auth

Login (Авторизация)

  • Путь: 'login'
  • Компонент: LoginPage.vue
  • Описание: Страница логина пользователя

Logout (Выход из системы)

  • Путь: 'logout'
  • Компонент: LogoutPage.vue
  • Описание: Страница выхода пользователя

Registration (Регистрация)

  • Путь: 'registration'
  • Компонент: RegistrationPage.vue
  • Описание: Страница регистрации нового пользователя

Create Password (Создание Пароля)

  • Путь: 'password/'
  • Компонент: CreatePassword.vue
  • Описание: Страница для создания нового пароля

Работа с REST API

Для начала необходимо сконфигурировать Axios:

TypeScript
import { boot } from 'quasar/wrappers';
import axios, { AxiosInstance } from 'axios';

declare module '@vue/runtime-core' {
  interface ComponentCustomProperties {
    $axios: AxiosInstance;
    $api: AxiosInstance;
  }
}

const api = axios.create({
  withCredentials: true,
  baseURL: import.meta.env.VITE_API_URL + '/api',
});

api.interceptors.request.use((config) => {
  config.headers.Authorization = `Bearer ${localStorage.getItem(
    'accessToken'
  )}`;
  return config;
});

api.interceptors.response.use(
  (config) => {
    return config;
  },
  async (error) => {
    const originalRequest = error.config;
    if (
      error.response?.status == 401 &&
      error.config &&
      !error.config._isRetry
    ) {
      originalRequest._isRetry = true;
      try {
        const sessionId = localStorage.getItem('sessionId');
        const response = await axios.post(
          `${import.meta.env.VITE_API_URL}/api/refresh`,
          { sessionId, withCredentials: true }
        );
        localStorage.setItem('accessToken', response.data.accessToken);
        return api.request(originalRequest);
      } catch (e) {
        console.log('Not Authorized');
      }
    }
    throw error;
  }
);

export default boot(({ app }) => {
  app.config.globalProperties.$axios = axios;
  app.config.globalProperties.$api = api;
});

export { api };


В данном коде осуществляется базовая конфигурация Axios: разрешается использование cookie, а так же устанавливается базовый url.

Затем, устанавливается два перехватчика — на запрос, и ответ. Перехватчики (interceptors) — это функции, которые выполняются при каждом запросе axios. В данном случае, при каждом запросе на сервер, к заголовкам запроса обновляется токен авторизации. В свою очередь, при каждом полученном ответе от сервера, выполняется проверка на ошибку «401 Unauthorized». Если ошибка произошла, клиент делает запрос к серверу на обновление токена доступа, и повторяет предыдущий запрос уже с новым токеном.

В файле api.ts находятся основные методы приложения для работы с удаленным API:

TypeScript
import { api } from '../boot/axios';
import { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios';
import { ApiResponse, ServerErrorResponse, ApiData } from './api.interfaces';
import { Notify, Loading, QSpinnerCube } from 'quasar';

export function axiosError(e: unknown) {
  if (e instanceof AxiosError) {
    const error = e;
    if (error.response) {
      const errorData = error.response.data as ServerErrorResponse;
      if (errorData && 'message' in errorData) {
        if (Array.isArray(errorData.message)) {
          errorData.message = errorData.message.join(', ');
        }
        return {
          status: error.response.status ?? 500,
          message: errorData.message,
        };
      }

      const errorDataArray = error.response.data as {
        validationErrors: [];
      };
      if (errorDataArray && 'validationErrors' in errorDataArray) {
        return {
          status: error.response.status ?? 500,
          message: 'Ошибка валидации',
        };
      }
    }

    if (error.code === 'ECONNABORTED' || error.message === 'Network Error') {
      return {
        status: 0,
        message: 'Ошибка соединения с сервером',
      };
    }
  } else {
    // Обработка ошибок, не являющихся AxiosError
    console.error(e);
  }

  return {
    status: 0,
    message: 'Неизвестная ошибка',
  };
}

export async function sendApiRequest<T>(
  method: 'get' | 'post' | 'put' | 'delete',
  url: string,
  config: AxiosRequestConfig = {}
): Promise<ApiResponse<T>> {
  try {
    const response: AxiosResponse<T> = await api.request<T>({
      url,
      method,
      ...config,
    });
    return {
      status: response.status,
      response: response.data,
    };
  } catch (error) {
    return axiosError(error);
  }
}


Этот код формирует запрос Axios, а так же производит обработку ошибок.

Далее формируем запросы к серверу. Для примера приведу несколько функций из messenger.api.ts:

TypeScript
export default class MessengerApi {
  static async roomCreate(
    type: number,
    name: string,
    privacy: number,
    about: string
  ): Promise<ApiResponse<RoomCreate>> {
    return sendApiRequest<RoomCreate>('post', '/room/create', {
      data: {
        type,
        name,
        privacy,
        about,
      },
    });
  }

  static async editAvatar(
    roomId: number,
    imageBlob: Blob,
    cropWidth: number,
    cropHeight: number,
    cropLeft: number,
    cropTop: number
  ): Promise<ApiResponse<EditAvatar>> {
    const formData = new FormData();
    formData.append('roomId', roomId.toString());
    formData.append('image', imageBlob);
    formData.append('cropWidth', cropWidth.toString());
    formData.append('cropHeight', cropHeight.toString());
    formData.append('cropLeft', cropLeft.toString());
    formData.append('cropTop', cropTop.toString());

    return sendApiRequest<EditAvatar>('post', '/room/avatar', {
      data: formData,
      headers: {
        'Content-Type': 'multipart/form-data',
      },
    });
  }

  static async dialogueCreate(
    to: number
  ): Promise<ApiResponse<DialogueCreate>> {
    return sendApiRequest<DialogueCreate>('post', '/room/createDialogue', {
      params: {
        to,
      },
    });
  }

  static async roomGet(idMixed: string): Promise<ApiResponse<Room>> {
    return sendApiRequest<Room>('get', '/room', {
      params: {
        id: idMixed,
      },
    });
  }


В целом, эти функции типовые для всех запросов, и приведенный код демонстрирует общий принцип реализации работы с api в приложении.

messenger.store.ts:

TypeScript
actions: {
    async roomCreate(
      type: number,
      name: string,
      privacy: number,
      about: string
    ): Promise<RoomCreate | boolean | { error: string; code: number }> {
      return await setResponse({
        func: MessengerApi.roomCreate,
        args: [type, name, privacy, about],
      });
    },

    async roomGet(
      idMixed: string
    ): Promise<Room | boolean | { error: string; code: number }> {
      return await setResponse({
        func: MessengerApi.roomGet,
        args: [idMixed],
      });
    },
    
    async editAvatar(
      roomId: number,
      imageBlob: Blob,
      cropWidth: number,
      cropHeight: number,
      cropLeft: number,
      cropTop: number
    ): Promise<EditAvatar | boolean | { error: string; code: number }> {
      return await setResponse({
        func: MessengerApi.editAvatar,
        args: [roomId, imageBlob, cropWidth, cropHeight, cropLeft, cropTop],
      });
    },


Эти методы уже непосредственно вызываются из компонентов приложения.

В целом, формирование и обработка запроса в приложении происходит в несколько этапов. В будущем, я хочу лучше оптимизировать и улучшить архитектуру формирования запросов, т.к даже нахождение методов запросов к API в хранилище Pinia (и они в основном не используют его возможности) выглядит не вполне корректным, и сохранилось по историческим причинам — первые примеры кода, с которых я начинал изучение Vue.js были очень упрощенные, и работа с API там проходила полностью в хранилище и самих компонентах. В моей же реализации, код работы с API уже полностью вынесен из компонентов, сделана обработка ошибок (пока неидеально), формирование запросов. В дальнейших итерациях разработки я планирую оставить в хранилищах лишь тот код, которому непосредственно необходимы возможности хранилища. А код работы с API вынести в отдельные файлы и оптимизировать еще лучше.

Работа с Socket.io API

Работа с API через Socket.io вынесена в отделный файл socket.service.ts. Приведу его содержимое полностью:

TypeScript
import { io, Socket } from 'socket.io-client';

const sessionId = localStorage.getItem('sessionId');

export interface Msg {
  msgId: number;
  roomId: number;
  name: string;
  userId: number;
  avatar: string;
  date: string;
  type: number;
  content: string;
  replyTo: number;
}

export interface Online {
  userId: number;
  online: boolean;
  timestamp: number;
  counter: number;
}

export interface Typing {
  userId: number;
  userName: string;
  typing: boolean;
  timestamp: number;
  roomId: number;
}

export interface Exception {
  status: string;
  message: string;
}

export default class SocketService {
  socket = {} as Socket;

  constructor() {
    this.connectToSocket();
  }

  connectToSocket() {
    const timerSocketConnect = setInterval(async () => {
      const accessToken = localStorage.getItem('accessToken');
      if (accessToken !== null) {
        this.socket = io(import.meta.env.VITE_API_URL, {
          withCredentials: true,
          extraHeaders: {
            Authorization: `Bearer ${accessToken}`,
          },
        });
        clearInterval(timerSocketConnect);
      }
    }, 3000);
  }

  disconnectFromSocket() {
    if (this.socket) {
      this.socket.disconnect();
    }
  }

  emit(event: string, data: { sessionId?: string | null }) {
    data.sessionId = sessionId;
    return new Promise((resolve, reject) => {
      if (!this.socket) {
        reject('No socket connection');
      } else {
        this.socket.emit(
          event,
          data,
          (response: { data: object; error: string }) => {
            if (response?.data) {
              resolve(response);
            } else {
              console.error(
                'Error on emit: ' + event + 'Server respose: ' + response //?.error
              );
              reject(response);
            }
          }
        );
      }
    });
  }

  async joinUserToAllRooms(userId: number) {
    return await this.emit('joinUserToAllRooms', { userId });
  }

  async joinToUserRoom(userId: number) {
    return await this.emit('joinToUserRoom', { userId });
  }

  async joinToRoom(roomId: number) {
    return await this.emit('joinToRoom', { roomId });
  }

  async leaveRoom(roomId: number) {
    return await this.emit('leaveRoom', { roomId });
  }

  async callUser(counter = 1) {
    return await this.emit('callUser', { counter });
  }

  async userOffline() {
    return await this.emit('userOffline', {});
  }

  async createMessage(message: object) {
    return await this.emit('createMessage', message);
  }

  async typing(roomId: string) {
    return await this.emit('typing', { roomId });
  }

  async subscribeRoom(roomId: number) {
    return await this.emit('subscribeRoom', { roomId });
  }

  async banUser(userId: number, roomId: number, banned: boolean) {
    return await this.emit('banUser', { userId, roomId, banned });
  }

  onUserOnlineStatus(action: (online: Online) => void) {
    this.socket.on('userOnlineStatus', action);
  }

  onSendMessage(action: (msg: Msg) => void) {
    this.socket.on('sendMessage', action);
  }

  onTyping(action: (typing: Typing) => void) {
    this.socket.on('broadcastTyping', action);
  }

  onException(action: (exception: Exception) => void) {
    this.socket.on('exception', action);
  }

  onConnect(action: () => void) {
    this.socket.on('connect', action);
  }

  onDisconnect(action: () => void) {
    this.socket.on('disconnect', action);
  }
}

Компоненты

Для начала рассмотрим главный компонент, служащий основой для большей части приложения:

TypeScript
<template>
  <router-view />
  <div class="modal-wrapper">
    <transition name="toggleBB">
      <div v-if="showMessage" class="modal modal-message">
        <div class="message" @click="onMessage">
          <div class="truncate-text">
            {{ messageLabel }}
          </div>
        </div>
      </div>
    </transition>

    <transition name="toggleBB">
      <div v-if="showAlert" class="modal modal-error">
        <div class="message">
          <div class="truncate-text">
            {{ alertLabel }}
          </div>
        </div>
      </div>
    </transition>
  </div>

  <div v-if="appStore.srvErrorOverlay" class="srvErrorOverlay">
    <div>
      <div>Что-то пошло не так...</div>
      <button @click="reloadPage">Обновить</button>
    </div>
  </div>

  <div v-if="appStore.loadingOverlay" class="loadingOverlay">
    <qq-spinner />
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue';
import { useAppStore } from './stores/app.store';
import { useUserStore } from '../user/user.store';
import QqSpinner from './QqSpinner.vue';
import SocketService from './socket.service';

export default defineComponent({
  name: 'App',
  components: { QqSpinner },
  data() {
    return {
      showAlert: false,
      showMessage: false,
      alertLabel: '',
      messageLabel: '',
      alertTimeout: {} as NodeJS.Timeout,
      msgTimeout: {} as NodeJS.Timeout,
    };
  },
  setup() {
    const appStore = useAppStore();
    const userStore = useUserStore();
    const socketService = new SocketService();
    appStore.socketService = socketService;
    return { appStore, userStore, socketService };
  },
  async mounted() {
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'visible') {
        this.socketService.callUser();
      } else {
        this.socketService.userOffline();
      }
    });

    setInterval(() => {
      this.appStore.isSocketConnected = this.socketService.socket.connected;
    }, 2000);

    setTimeout(() => {
      this.initEvents();
      this.joinUserToAllRooms();
    }, 3000);
  },
  methods: {
    joinUserToAllRooms() {
      let timerJoinToAllRooms = setInterval(async () => {
        const accessToken = localStorage.getItem('accessToken');
        if (accessToken !== null) {
          await this.socketService.joinUserToAllRooms(this.userStore.userId);
          clearInterval(timerJoinToAllRooms);
        }
      }, 3000);
    },
    onMessage() {
      this.showAlert = true;
    },
    reloadPage() {
      location.reload();
    },
    initEvents() {
      this.socketService.onConnect(() => {
        this.appStore.isSocketConnected = true;
        console.log('CONNECTED');
        this.joinUserToAllRooms();
      });

      this.socketService.onDisconnect(() => {
        this.appStore.isSocketConnected = false;
        console.error('DISCONNECTED');
      });

      this.socketService.onException((exception) => {
        const error = exception.message;
        this.appStore.setAlertMsg(error);
        console.error(error);
      });

      this.socketService.onUserOnlineStatus((data) => {
        if (data?.userId) {
          this.appStore.userOnline = data;

          if (data.counter <= 1) {
            if (document.visibilityState === 'visible') {
              this.socketService.callUser(data.counter + 1);
            } else {
            }
          }
        }
      });

      this.socketService.onSendMessage((data) => {
        const message = {
          msgId: data.msgId,
          roomId: data.roomId,
          name: data.name,
          userId: data.userId,
          avatar: data.avatar,
          date: data.date,
          type: data.type,
          content: data.content,
          replyTo: data.replyTo,
        };

        this.appStore.lastMessage = message;
      });

      this.socketService.onTyping((data) => {
        const typing = {
          userId: data.userId,
          userName: data.userName,
          typing: data.typing,
          timestamp: data.timestamp,
          roomId: data.roomId,
        };

        this.appStore.typing = typing;
      });
    },
  },
  computed: {
    alertMessage() {
      return this.appStore.alertMessage.timestamp;
    },
    messageAlert() {
      return this.appStore.lastMessage.date;
    },
  },
  watch: {
    alertMessage() {
      clearTimeout(this.alertTimeout);
      this.showAlert = true;
      this.alertLabel = this.appStore.alertMessage.message;
      this.alertTimeout = setTimeout(() => {
        this.showAlert = false;
      }, 3000);
    },
    messageAlert() {
      if (
        this.appStore.activeRoom !== this.appStore.lastMessage.roomId &&
        this.appStore.lastMessage.userId !== this.userStore.userId
      ) {
        clearTimeout(this.msgTimeout);
        this.showMessage = true;

        switch (this.appStore.lastMessage.type) {
          case 1:
            this.messageLabel =
              this.appStore.lastMessage.name +
              ': ' +
              this.appStore.lastMessage.content;
            break;
          default:
            this.messageLabel = this.appStore.lastMessage.name + ': ...';
        }

        this.msgTimeout = setTimeout(() => {
          this.showMessage = false;
        }, 3000);
      }
    },
  },
});
</script>

<style>
body {
  background-size: cover;
  background-color: #ffffff;
  background-image: url('tr.jpg');
  font-family: Roboto, -apple-system, 'Helvetica Neue', Helvetica, Arial,
    sans-serif;
  box-sizing: border-box;
  font-size: 14px;
  direction: ltr;
  unicode-bidi: isolate;
  text-size-adjust: 100%;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
  line-height: 21px;
  overflow: hidden;
}

a {
  text-decoration: none;
  color: black;
}

/* Remove outline on the forms and links */
:active,
:hover,
:focus {
  outline: 0;
  outline-offset: 0;
}

.app-header {
  flex: 0 0 auto;
  padding: 0 5px;
  background: #272727;
  color: white;
  text-align: center;
  height: 60px;
  display: flex;
  flex-direction: row;
  align-items: center;
  z-index: 10000;
}
</style>

<style scoped>
.modal-wrapper {
  display: block;
}

.modal {
  bottom: 0px;
  left: 0px;
  right: 0px;
  position: fixed;
  display: flex;
  flex-wrap: nowrap;
  flex-direction: column;
  align-items: center;
  z-index: 9500;
  margin-left: 30px;
  margin-right: 30px;
  user-select: none;
  height: 30px;
}

.modal-error {
  margin-bottom: 70px;
  bottom: 0px;
}

.modal-message {
  margin-top: 80px;
  top: 0px;
  cursor: pointer;
}

.message {
  display: flex;
  align-items: center;
  max-width: 100%;
  height: 30px;
  padding: 0 14px 0 15px;
  border-radius: 20px;
  background: #1d1d1d;
  color: rgb(250, 249, 249);
}

.truncate-text {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

@keyframes aniUp {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

@keyframes aniDown {
  to {
    opacity: 0;
  }
}

.toggleBB-enter-active,
.toggleBB-leave-active {
  animation: aniUp 0.3s;
}

.toggleBB-leave-active,
.toggleBB-leave-to {
  animation: aniDown 0.5s;
}

.srvErrorOverlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(26, 26, 26, 0.97);
  z-index: 10000000;
  display: flex;
  align-items: center;
  justify-content: center;
  color: rgb(228, 228, 228);
  font-size: 20px;
}

.srvErrorOverlay > div {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  padding-top: 20px;
}

.srvErrorOverlay > div > div {
  margin: 30px 0;
}

.srvErrorOverlay > div > button {
  padding: 15px 70px;
  border: 1px solid rgb(63, 63, 63);
  cursor: pointer;
  background-color: rgb(228, 228, 228);
  font-size: 20px;
  border-radius: 5px;
}

.srvErrorOverlay > div > button:active {
  background-color: rgb(202, 202, 202);
}

.loadingOverlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(26, 26, 26, 0.185);
  z-index: 10000000;
  display: flex;
  align-items: center;
  justify-content: center;
  color: rgb(228, 228, 228);
  font-size: 20px;
}
</style>


Здесь реализованы всплывающие уведомления, оверлей с ошибкой и загрузкой, а так же целый ряд функций, связанных с работой мессенджера через Socket.io (отправка событий онлайн или офлайн в зависимости от видимости окна, первоначальная инициализация вроде joinUserToAllRooms(), устанавливается ряд обработчиков событий сокетов onConnect, onDisconnect, onException, onUserOnlineStatus, onSendMessage, onTyping).

Выпадающее меню в окне чата DropMenu:


Полностью реализовано самостоятельно. Код целиком:

TypeScript
<template>
  <div
    v-if="appStore.showDropMenu"
    @click.self="hide()"
    class="menu-layout"
  ></div>
  <transition name="toggleBB">
    <div v-if="appStore.showDropMenu" class="menu" @click="hide()">
      <div class="items-container">
        <div
          v-for="(item, index) in content"
          :key="index"
          @click="item.click"
          class="item"
          :style="'color:' + item.color"
        >
          <div class="item-icon">
            <div class="icon">
              <svg-icon type="mdi" :path="item.icon"></svg-icon>
            </div>
          </div>
          <div class="item-data">
            <div class="item-text">
              {{ item.title }}
            </div>
          </div>
        </div>
      </div>
    </div>
  </transition>
</template>

<script lang="js">
import { defineComponent } from 'vue';
import { useAppStore } from '../common/stores/app.store';
import { useUserStore } from '../user/user.store';
import SvgIcon from '@jamescoyle/vue-icon';

export default defineComponent({
  name: 'DropMenu',
  props: ['content'],
  components: { SvgIcon },
  setup() {
    const appStore = useAppStore();
    const userStore = useUserStore();
    return { appStore, userStore };
  },
  methods: {
    hide() {
      this.appStore.showDropMenu = false;
    },
  },
});
</script>

<style scoped>
.menu-layout {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: 19000;
  display: flex;
  flex-direction: column;
}

.menu {
  position: fixed;
  top: 10px;
  right: 8px;
  width: 300px;
  z-index: 20000;
  background-color: #313131;
  display: flex;
  flex-direction: column;
  border-radius: 5px;
  box-shadow: 1px 1px 1px #252525;
  user-select: none;
}

.item-arrow {
  color: #dab3b3;
}

.item {
  display: flex;
  flex-direction: row;
  width: 100%;
  min-height: 48px;
  color: #fde2e2;
  padding-left: 15px;
}

.item:active {
  background-color: #3a3a3a;
}

.item-icon {
  display: flex;
  align-items: center;
}

.icon {
  display: flex;
  justify-content: center;
  align-items: center;
  width: 30px;
  height: 30px;
}

.item-data {
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding-left: 18px;
  width: 100%;
}

.item-text {
  font-size: 16px;
}

@keyframes aniUp {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

@keyframes aniDown {
  to {
    opacity: 0;
  }
}

.toggleBB-enter-active,
.toggleBB-leave-active {
  animation: aniUp 0.2s;
}

.toggleBB-leave-active,
.toggleBB-leave-to {
  animation: aniDown 0.15s;
}
</style>

Боковое меню DrawerPanel:

Полностью реализовано самостоятельно. Код целиком:

TypeScript
<template>
  <transition name="toggleDC">
    <div
      v-if="appStore.showDrawer"
      @click.self="hide()"
      class="drawer-container"
    ></div>
  </transition>
  <transition name="toggleBB">
    <div
      v-if="appStore.showDrawer"
      class="drawer"
      @touchstart="onTouchStart"
      @touchmove="onTouchMove"
    >
      <div class="header">
        <div class="header-caption">
          <img src="icon.png" width="23px" />
          <RouterLink
            to="/app"
            style="text-decoration: none; color: white; padding-top: 1px"
            >essenger</RouterLink
          >
        </div>
      </div>

      <div>
        <router-link :to="`/profile/${userStore.userId}`">
          <div class="account">
            <div class="user-avatar">
              <user-avatar
                :userName="userStore.userName"
                :avatar="userStore.userAvatar"
              />
            </div>
            <div class="user-info">
              <div class="user-name">{{ userStore.userName }}</div>
              <div class="user-name-caption">Профиль</div>
            </div>
            <div class="user-right">
              <div class="item-arrow">〉</div>
            </div>
          </div>
        </router-link>
      </div>

      <div class="items-container" @click="hide()">
        <router-link
          :to="item.link"
          v-for="(item, index) in content"
          :key="index"
        >
          <div class="item">
            <div class="item-icon">
              <div class="icon">
                <svg-icon type="mdi" :path="item.icon"></svg-icon>
              </div>
            </div>
            <div class="item-data">
              <div class="item-text">{{ item.title }}</div>
              <div v-if="item.caption" class="item-caption">
                {{ item.caption }}
              </div>
            </div>
            <div class="item-right">
              <div v-if="item.counter" class="item-counter">
                <div class="item-counter-leveler">
                  {{ item.counter }}
                </div>
              </div>
            </div>
          </div>
        </router-link>
      </div>
    </div>
  </transition>
</template>

<script lang="js">
import { defineComponent } from 'vue';
import UserAvatar from 'src/user/UserAvatar.vue';
import { useAppStore } from '../common/stores/app.store';
import { useUserStore } from '../user/user.store';
import SvgIcon from '@jamescoyle/vue-icon';

export default defineComponent({
  name: 'DrawerPanel',
  props: ['content'],
  //emits: ['close'],
  components: { UserAvatar, SvgIcon },
  setup() {
    const appStore = useAppStore();
    const userStore = useUserStore();
    return { appStore, userStore };
  },
  data() {
    return {
      touchStartX: 0,
      touchStartY: 0,
      swipeThreshold: 80, // минимальное расстояние для свайпа
    };
  },
  methods: {
    hide() {
      this.appStore.showDrawer = false;
    },
    onTouchStart(event) {
      const touch = event.touches[0];
      this.touchStartX = touch.clientX;
      this.touchStartY = touch.clientY;
    },
    onTouchMove(event) {
      if (!this.appStore.showDrawer) {
        return;
      }

      const touch = event.touches[0];
      const touchEndX = touch.clientX;
      const touchEndY = touch.clientY;

      const deltaX = this.touchStartX - touchEndX;
      const deltaY = this.touchStartY - touchEndY;

      // Проверяем, является ли движение свайпом влево и достаточно ли оно большое
      if (deltaX > this.swipeThreshold && Math.abs(deltaY) < this.swipeThreshold) {
        this.appStore.showDrawer = false;
      }
    }
  },
});
</script>

<style scoped>
@font-face {
  font-family: 'headFont';
  src: url('/crimson/Crimson-SemiboldItalic.otf');
}

a {
  text-decoration: none;
}

.header-caption {
  font-size: 21px;
  padding-top: 21px;
  color: #ffefef;
  letter-spacing: 0.2px;
  height: 26px;
  display: flex;
  align-items: center;
  margin-left: 7px;
  margin-top: 5px;
}

.drawer-container {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: 19000;
  background-color: hsla(0, 0%, 4%, 0.493);
  display: flex;
  flex-direction: column;
}

.drawer {
  position: fixed;
  top: 0;
  left: 0;
  width: 300px;
  height: 100%;
  z-index: 20000;
  background-color: #272727;
  display: flex;
  flex-direction: column;
}

.header {
  width: 100%;
  height: 60px;
  padding-left: 16px;
}

.account {
  display: flex;
  flex-direction: row;
  width: 100%;
  height: 75px;
  background-color: #5e4242;
  padding-left: 16px;
  margin-bottom: 18px;
}

.user-avatar {
  display: flex;
  align-items: center;
}

.user-info {
  padding-left: 12px;
  display: flex;
  flex-direction: column;
  justify-content: center;
}

.user-name {
  color: white;
}

.user-name-caption {
  color: #dab3b3;
  font-size: 12px;
}

.user-right {
  display: flex;
  color: white;
  width: 100%;
  padding-right: 15px;

  flex-direction: row-reverse;
  align-items: center;
}

.item-arrow {
  color: #dab3b3;
}

.items-container {
  overflow-y: auto;
}

.item {
  display: flex;
  flex-direction: row;
  width: 100%;
  min-height: 48px;
  color: #fde2e2;
  padding-left: 12px;
}

.item:active {
  background-color: #3a3a3a;
}

.item-icon {
  display: flex;
  align-items: center;
}

.icon {
  display: flex;
  justify-content: center;
  align-items: center;
  width: 30px;
  height: 30px;
}

.icon span {
  font-size: 23px;
}

.item-data {
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding-left: 30px;
  width: 100%;
}

.item-caption {
  color: #dab3b3;
  font-size: 12px;
}

.item-right {
  display: flex;
  flex-direction: row-reverse;
  align-items: center;
  padding-right: 15px;
}

.item-counter {
  display: flex;
  justify-content: center;
  align-items: center;
  border-radius: 15px;
  min-width: 25px;
  height: 25px;
  background-color: #dddddd;
  font-size: 12px;
  color: #404749;
  padding: 8px;
}

.item-counter-leveler {
  margin-left: -1px;
  margin-bottom: -1px;
}

@keyframes aniUp {
  from {
    transform: translateX(-150%);
  }
  to {
    transform: translateX(0);
  }
}

@keyframes aniDown {
  to {
    transform: translateX(-150%);
  }
}

.toggleBB-enter-active,
.toggleBB-leave-active {
  animation: aniUp 0.3s;
}

.toggleBB-leave-active,
.toggleBB-leave-to {
  animation: aniDown 0.5s;
}

@keyframes containerShow {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

@keyframes containerHide {
  to {
    opacity: 0;
  }
}

.toggleDC-enter-active,
.toggleDC-leave-active {
  animation: containerShow 0.3s;
}

.toggleDC-leave-active,
.toggleDC-leave-to {
  animation: containerHide 0.5s;
}
</style>

Выбор аватара

Образка аватара реализована при помощи сторннего компонента vue-advanced-cropper. Этот компонент позволяет удобно определить координаты обрезки для изображения аватара, а затем уже на сервере, будет произведена обрезка. Ниже привожу код страницы загрузки аватара полностью:

TypeScript
<template>
  <div class="q-pa-md">
    <div class="square" v-if="!file" @click="pickFiles">
      <q-icon
        name="add_a_photo"
        size="150px"
        class="cursor-pointer inner-block"
        style="color: #c5c5c5"
      />
    </div>

    <cropper
      v-if="file"
      ref="cropper"
      class="cropper"
      :src="image.src"
      :stencil-props="{
        aspectRatio: 1 / 1,
      }"
      @change="change"
      :stencil-component="nonReactiveCircleStencil"
    />
    <br />

    <div class="buttonBar">
      <div class="center">
        <q-file
          ref="file"
          filled
          bottom-slots
          v-model="file"
          label="Выбрать аватар"
          counter
          @update:model-value="handleFileChange"
          accept=".jpg, .jpeg, .png, .gif, .bmp, .webp, .avif"
          class="full-width"
          :max-file-size="10 * 1_048_576"
        >
          <template v-slot:prepend>
            <q-icon name="cloud_upload" @click.stop.prevent />
          </template>
          <template v-slot:append>
            <q-icon
              name="close"
              @click.stop.prevent="reset"
              class="cursor-pointer"
            />
          </template>

          <template v-slot:hint> Не более 10 Мб </template>
        </q-file>
      </div>

      <div class="center">
        <q-btn
          color="brown-10"
          class="full-width"
          size="lg"
          @click="onSubmit"
          :label="submitLabel"
        />
      </div>
    </div>
  </div>
</template>

<script lang="js">
import { defineComponent, markRaw } from 'vue';
import { useUserStore } from '../../user/user.store';
import { Cropper, CircleStencil } from 'vue-advanced-cropper';
import 'vue-advanced-cropper/dist/style.css';

const userStore = useUserStore();

function getMimeType(file, fallback = null) {
  const byteArray = new Uint8Array(file).subarray(0, 4);
  let header = '';
  for (let i = 0; i < byteArray.length; i++) {
    header += byteArray[i].toString(16);
  }
  switch (header) {
    case '89504e47':
      return 'image/png';
    case '47494638':
      return 'image/gif';
    case 'ffd8ffe0':
    case 'ffd8ffe1':
    case 'ffd8ffe2':
    case 'ffd8ffe3':
    case 'ffd8ffe8':
      return 'image/jpeg';
    case '424d':
    case '4d42':
      return 'image/bmp';
    case '52494646':
      return 'image/webp';
    case '61764966':
      return 'image/avif';
    default:
      return fallback;
  }
}

export default defineComponent({
  name: 'EditAvatar',
  components: { Cropper },

  setup() {
    const nonReactiveCircleStencil = markRaw(CircleStencil);
    return {
      nonReactiveCircleStencil,
    };
  },

  data() {
    return {
      CircleStencil,
      submitLabel: 'Отмена',
      imageBlob: null,
      image: {
        src: null,
        type: null,
      },
      cropWidth: 0,
      cropHeight: 0,
      cropLeft: 0,
      cropTop: 0,
      file: null,
    };
  },

  methods: {
    pickFiles(){
      this.$refs.file.pickFiles();
    },

    handleFileChange() {
      if (this.file) {
        if (this.image.src) {
          URL.revokeObjectURL(this.image.src);
        }
        const blobUrl = URL.createObjectURL(this.file);
        const reader = new FileReader();
        reader.onload = (e) => {
          this.image = {
            src: blobUrl,
            type: getMimeType(e.target.result, this.file.type),
          };
        };
        reader.readAsArrayBuffer(this.file);
        this.imageBlob = this.file;
      }
    },

    change({ coordinates }) {
      this.cropWidth = coordinates.width;
      this.cropHeight = coordinates.height;
      this.cropLeft = coordinates.left;
      this.cropTop = coordinates.top;
    },

    reset() {
      this.file = null;
      this.image = {
        src: null,
        type: null,
      };
    },

    async onSubmit() {
      const formData = {
        imageBlob: this.imageBlob,
        cropWidth: this.cropWidth,
        cropHeight: this.cropHeight,
        cropLeft: this.cropLeft,
        cropTop: this.cropTop,
      };

      if (this.file) {
        this.$emit('submitForm', formData);
      } else {
        this.$emit('submitForm', false);
      }
    },

  watch: {
    file() {
      if (this.file) {
        this.submitLabel = 'Сохранить';
      } else {
        this.submitLabel = 'Отмена';
      }
    },
  },

  unmounted() {
    if (this.image.src) {
      URL.revokeObjectURL(this.image.src);
    }
  },
});
</script>

<style scoped>
.cropper {
  min-height: 300px;
  max-height: 600px;
  margin: auto;
}

.center {
  display: flex;
  justify-content: center;
  margin-top: 17px;
}

.buttonBar {
  position: fixed;
  bottom: 0;

  left: 0;
  right: 0;
  padding: 10px;
  padding-bottom: 40px;
}

.square {
  margin: 0 auto;
  padding-top: 20px;
  padding-bottom: 20px;
  width: calc(100% - 40px);
  max-width: 500px;
  min-width: 100px;
  aspect-ratio: 1 / 1;
  max-height: 500px;
  min-height: 100px;
  position: relative;
  box-sizing: border-box;
  border: 1px dashed rgb(194, 194, 194);
  background-color: #ebebeb69;
  margin-top: 20px;
}

.inner-block {
  margin-top: -8px;
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(
    -50%,
    -50%
  );
  width: 50%;
  height: 50%;
}
</style>

Страница чата ChatPage

На текущий момент — самая сложная часть приложения. Особенно хочу отметить, что здесь реализована «бесконечная» прокрутка сообщений в чате, и сделано это без использования сторонних компонентов (Для примера был найден готовый пример реализации такого алгоритма, который, однако, потребовал полного вникания в его принцип работы и значительной доработки). Я пробовал использовать сторонние компоненты, но ни одно из решений меня не устроило, а так же являлось «черным ящиком». В конечном итоге, я решил, что для столь важной части приложения необходимо иметь полное понимание и контроль над кодом, что привело к созданию кастомного решения.


Пока это первая черновая реализация чата, и далее планируется глубокий рефакторинг — большая часть кода будет выделена в отдельные компоненты и переписана. Кроме того, будут исправлены некотрые баги и недоделки. Тем не менее, я приведу все 1755 строк кода этой страницы целиком (включая код CSS), не смотря на имеющиеся недочеты:

TypeScript
<template>
  <div class="container-all-chat no-framework-text">
    <div v-if="showChatLoadingError" class="chat-loading-overlay-err">
      <div>
        <div>{{ showChatLoadingErrorMsg }}</div>

        <button @click="reloadChat">Обновить</button>
      </div>
    </div>
    <div class="chat-container">
      <div class="app-header">
        <div class="header-button-arrow">
          <router-link :to="'/im'">
            <div class="btn-icon">
              <svg-icon type="mdi" :path="mdiArrowLeft" size="26"></svg-icon>
            </div>
          </router-link>
        </div>

        <div class="avatar-header">
          <chat-avatar
            :size="'msg'"
            :type="roomAvatarType"
            :name="roomName"
            :avatar="roomAvatar"
          />
        </div>
        <router-link :to="roomLink">
          <div class="header-caption">
            <div class="header-caption-title">{{ roomName }}</div>
            <div
              v-if="chatOnline && !isTyping && !isConnecting"
              class="header-caption-desc online"
            >
              в сети
            </div>
            <div
              v-if="isTyping && !isConnecting"
              class="header-caption-desc loading"
            >
              печатает...
            </div>
            <div
              v-if="isConnecting"
              class="header-caption-desc loading"
              style="color: palevioletred"
            >
              соединение...
            </div>
            <div
              v-if="!chatOnline && !isTyping && !isConnecting"
              class="header-caption-desc"
            >
              {{ chatDesc }}
            </div>
          </div>
        </router-link>
        <div class="header-menu">
          <div class="header-button-menu" @click="onChatMenuBtn">
            <div class="btn-icon">
              <svg-icon type="mdi" :path="mdiDotsVertical" size="26"></svg-icon>
            </div>
          </div>
        </div>
      </div>
      <div class="msg-overlay" :style="chatBackground">
        <div ref="messages" class="messages" v-on:scroll.passive="handleScroll">
          <div
            class="chunk"
            v-for="(chunk, index) in chunks"
            :style="{ transform: 'translateY(' + chunk.offset + 'px)' }"
            :key="index"
          >
            <div
              class="sentinel-start"
              v-if="index === 0"
              :class="{ disabled: lastChunk < 3 }"
            ></div>

            <div
              class="list-item"
              v-for="(item, index) in chunk.items"
              :key="index"
            >
              <chat-message
                v-if="item.type === 1"
                :index="index"
                :userId="item.userId"
                :name="item.name"
                :text="item.text"
                :date="item.date"
                :avatar="item.avatar"
                :sent="item.sent"
                :showAvatar="isShowAvatar"
                :showName="isShowName"
                :status="item.status"
                :showStatus="false"
              />
              <chat-notify v-if="item.type === 12" :name="item.name" />
            </div>

            <div
              class="sentinel"
              v-if="index === chunks.length - 1"
              :class="{ disabled: startFlag }"
            ></div>
          </div>

          <svg height="0" width="0">
            <defs>
              <clipPath id="svgPathRight">
                <path fill="#FFFFFF" d="M20,20H0V0H0A20,20,0,0,0,20,20Z" />
              </clipPath>

              <clipPath id="svgPathLeft">
                <path fill="#FFFFFF" d="M0,20H20V0H20A20,20,0,0,1,0,20Z" />
              </clipPath>
            </defs>
          </svg>
        </div>
      </div>

      <div v-if="inputArea === 'input'" class="input-area">
        <textarea
          ref="inputField"
          rows="1"
          v-model="inputMessage"
          @keydown="inputHandler"
          class="input-field"
          placeholder="Сообщение"
          @input="onInputMessage"
        ></textarea>
        <div>
          <div @click="sendMessage" class="btn-icon btn-send">
            <svg-icon type="mdi" :path="mdiSend" size="26"></svg-icon>
          </div>
        </div>
      </div>

      <div v-if="inputArea === 'alert'" class="input-area">
        <div class="input-area-alert">
          <div>
            <div class="input-area-alert-icon">
              <svg-icon type="mdi" :path="inputAreaAlertIcon"></svg-icon>
            </div>
          </div>
          <div class="input-area-alert-msg">{{ deniedMessage }}</div>
        </div>
      </div>

      <div
        v-if="inputArea === 'subscribeChannel'"
        class="chat-button"
        @click="onSubscribe"
      >
        Подписаться
      </div>

      <div
        v-if="inputArea === 'subscribeGroup'"
        class="chat-button"
        @click="onSubscribe"
      >
        Присоединиться к группе
      </div>

      <div
        v-if="inputArea === 'notifyStateOff'"
        class="chat-button"
        @click="onDisableNotifications"
      >
        Откл. уведомления
      </div>
      <div
        v-if="inputArea === 'notifyStateOn'"
        class="chat-button"
        @click="onEnableNotifications"
      >
        Вкл. уведомления
      </div>
    </div>

    <transition name="toggleBB">
      <div
        v-if="toBeginButtonVisible"
        class="toBeginButton"
        @click="toBeginButton"
      >
        <svg-icon type="mdi" :path="mdiChevronDown" size="26" />
      </div>
    </transition>

    <drop-menu :content="messengerMenu" />
  </div>
</template>

<script>
import { debounce } from 'lodash';
import { defineComponent } from 'vue';
import ChatMessage from './chat/ChatBubble.vue';
import ChatNotify from './chat/ChatNotify.vue';
import ChatAvatar from '../avatar/ChatAvatar.vue';
import { useQuasar } from 'quasar';
import { RoomType, NotifyState, SubscribeRole } from '../common/enums';

import { useUserStore } from '../../user/user.store';
import { useMessengerStore } from '../common/messenger.store';
import { useAppStore } from '../../common/stores/app.store';

import { date } from 'quasar';
import dateTokens from '../../i18n/ru/date';

import SvgIcon from '@jamescoyle/vue-icon';

import DropMenu from '../../common/DropMenu.vue';

import {
  mdiBroom,
  mdiDelete,
  mdiBrushVariant,
  mdiBell,
  mdiBellCancel,
  mdiCancel,
  mdiExclamationThick,
  mdiArrowLeft,
  mdiSend,
  mdiDotsVertical,
  mdiChevronDown,
} from '@mdi/js';

export default defineComponent({
  name: 'ChatPage',
  props: ['idMixed'],
  components: { ChatMessage, ChatNotify, ChatAvatar, SvgIcon, DropMenu }, // DrawerPanel

  setup() {
    const appStore = useAppStore();
    const messengerStore = useMessengerStore();
    const userStore = useUserStore();

    const $q = useQuasar();

    return {
      showNotif() {
        $q.notify({
          timeout: 0,
          message: 'Ошибка',
          actions: [
            {
              label: 'Перезагрузить',
              color: 'blue',
              handler: () => {
                this.loading = false;

                switch (this.lastCallback) {
                  case 'endCallback':
                    this.endCallbackFunc();
                    break;
                  case 'startCallback':
                    this.startCallbackFunc();
                    break;
                  default:
                    location.reload();
                }
              },
            },
          ],
        });
      },
      appStore,
      messengerStore,
      userStore,
    };
  },

  data() {
    return {
      chatBackground: `background-image: url('${
        import.meta.env.VITE_API_URL
      }/background/tgbg1/Theme=Free-Time.svg'), url('${
        import.meta.env.VITE_API_URL
      }/background/tgbg1/Gradient=Fresh-morning.svg'); background-size: auto, cover; background-color: rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5);`,

      isShowAvatar: true,
      isShowName: true,

      type: null,
      privacy: null,
      roomName: '?',
      roomAvatar: '',
      countSubscribers: 0,
      inputMessage: '',

      startOffsetTop: 0,
      correctionOffset: 0,

      allowReload: false,
      toBeginButtonVisible: false,

      newMessages: [],

      data: [],
      chunks: [],
      offsets: { 1: 0 },
      lastChunk: 1,
      startFlag: false,
      rooms: [],
      page: 1,
      pageSize: 50,
      totalPages: 0,
      onePage: false,
      emptyPage: false,
      loading: false,
      pageData: [],
      startCallbackFunc: null,
      endCallbackFunc: null,
      lastCallback: 'none',
      showSidebar: true,
      roomId: null,

      roomAvatarType: 'room',
      inputArea: 'input',
      inputAreaAlertIcon: mdiExclamationThick,

      lastSeen: 'был(а) давно',
      chatOnline: false,
      companionId: null,

      chatOnlineValue: false,

      isTyping: false,

      typingTimer: 0,

      newDialogue: false,
      sendShiftEnter: false,

      userSex: null,
      deniedMessage: '',
      roomLink: '',
      mdiArrowLeft,
      mdiSend,
      mdiDotsVertical,
      mdiChevronDown,

      showTopMenu: true,

      mdiBroom,
      mdiDelete,
      mdiBrushVariant,
      mdiBell,
      mdiBellCancel,
      mdiCancel,

      ldOvTimer: 0,
      showChatLoading: false,
      showChatLoadingError: false,

      loadingOverlayTimer: {}, // as NodeJS.Timeout,
      roomDenied: false, // as false | string,
      showChatLoadingErrorMsg: 'Что то пошло не так...0',
      companionClientsOnline: {},
      isSentOffline: false,

      messengerMenu: [],
      userNotifyMenu: 0, // 0: hide, 1: ON, 2: OFF
      blockUserMenu: 0,
    };
  },

  async created() {
    const roomRequest = await this.messengerStore.roomGet(this.idMixed);

    if (!(roomRequest && roomRequest !== true)) {
      //
    }

    this.roomDenied = roomRequest.denied;

    switch (roomRequest.type) {
      case RoomType.initDialogue: {
        this.roomLink = `/profile/${roomRequest.user.userId}`;
        this.type = roomRequest.type;
        this.newDialogue = true;
        this.roomName = roomRequest.user.userName;
        this.roomAvatar = roomRequest.user.userAvatar;
        this.roomAvatarType = 'avatar';
        this.lastSeen = roomRequest.user.lastSeen;
        this.companionId = roomRequest.user.userId;
        this.roomId = this.idMixed;
        this.userSex = roomRequest.user.sex;
        this.isShowAvatar = false;
        this.isShowName = false;

        await this.appStore.socketService.joinToUserRoom(
          roomRequest.user.userId
        );
        this.pageData = [{ type: 0 }]; // Empty invisible message
        break;
      }
      case RoomType.dialogue: {
        this.roomLink = `/profile/${roomRequest.subscribe.companionUser.id}`;
        this.companionId = roomRequest.subscribe.companionUser.id;
        this.roomId = roomRequest.id;
        this.type = roomRequest.type;
        this.lastSeen = roomRequest.subscribe.companionUser.lastSeen;
        this.userSex = roomRequest.subscribe.companionUser.profile.sex;
        this.roomAvatar = roomRequest.subscribe.companionUser.avatar;
        this.roomAvatarType = 'avatar';
        this.roomName = roomRequest.subscribe.companionUser.userName;
        this.isShowAvatar = false;
        this.isShowName = false;

        this.userNotifyMenu = 1;

        if (roomRequest.subscribe.notifyState === NotifyState.disabled) {
          this.userNotifyMenu = 1;
        } else this.userNotifyMenu = 2;

        if (roomRequest.isBannedCompanion) {
          this.blockUserMenu = 2;
        } else this.blockUserMenu = 1;

        break;
      }

      case RoomType.channel: {
        if (roomRequest.subscribe === null) {
          this.inputArea = 'subscribeChannel';
        } else if (
          roomRequest.subscribe.userRole === SubscribeRole.subscriber
        ) {
          if (roomRequest.subscribe.notifyState === NotifyState.disabled) {
            this.userNotifyMenu = 1;
            this.notifyButtonOn(true);
          } else {
            this.userNotifyMenu = 2;
            this.notifyButtonOn(false);
          }
        }

        this.roomLink = `/im/room/${roomRequest.id}`;
        this.roomId = roomRequest.id;
        this.type = roomRequest.type;
        this.roomAvatar = roomRequest.roomAvatar;
        this.roomAvatarType = 'room';
        this.roomName = roomRequest.name;
        this.isShowAvatar = false;
        this.countSubscribers = roomRequest.countSubscribers;

        await this.appStore.socketService.joinToRoom(roomRequest.id);
        break;
      }

      case RoomType.group: //{
        if (roomRequest.subscribe === null) {
          this.inputArea = 'subscribeGroup';
        } else if (roomRequest.subscribe.notifyState === NotifyState.disabled) {
          this.userNotifyMenu = 1;
        } else this.userNotifyMenu = 2;

        this.roomLink = `/im/room/${roomRequest.id}`;
        this.roomId = roomRequest.id;
        this.type = roomRequest.type;
        this.roomAvatar = roomRequest.roomAvatar;
        this.roomAvatarType = 'room';
        this.roomName = roomRequest.name;
        this.isShowAvatar = true;
        this.countSubscribers = roomRequest.countSubscribers;

        const joinResult = await this.appStore.socketService.joinToRoom(
          roomRequest.id
        );
        console.log(joinResult);
        if (joinResult?.data?.denied) {
          this.deniedMessage = 'Доступ запрещен';
          this.inputArea = 'alert';
        }

        break;
    }

    if (roomRequest.type !== RoomType.initDialogue) {
      await this.loadData();
    }

    this.chunks = [
      {
        offset: 0,
        items: this.pageData,
      },
    ];
  },

  async mounted() {
    setInterval(async () => {
      this.chatOnlineValue = false;

      for (let sessionId in this.companionClientsOnline) {
        if (
          this.companionClientsOnline[sessionId].timestamp >=
          new Date() + 1000 * 10
        )
          delete this.companionClientsOnline[sessionId];
      }

      if (document.visibilityState === 'visible') {
        this.appStore.socketService.callUser();
        this.isSentOffline = false;
      } else {
        if (!this.isSentOffline) {
          this.appStore.socketService.userOffline();
          this.isSentOffline = true;
        }
      }

      setTimeout(() => {
        if (this.chatOnlineValue === false) {
          this.chatOnline = false;
        } else this.chatOnline = true;
      }, 3000);
    }, 10000);
  },

  async beforeUnmount() {
    if (typeof this.roomId === 'string' && this.roomId.charAt(0) === 'u') {
      this.appStore.socketService.leaveRoom(this.roomId.toString());
    }
    this.appStore.activeRoom = null;
  },

  async updated() {
    await this.appStore.socketService.callUser();
    const options = {
      root: document.querySelector('.area'),
    };

    const handleEndCallback = debounce(async (entries) => {
      endCallback(entries);
    }, 200);

    const handleStartCallback = debounce(async (entries) => {
      startCallback(entries);
    }, 0);

    const endCallback = async (entries) => {
      this.lastCallback = 'endCallback';

      if (!this.loading) {
        let entriesCheck = false;
        if (entries === undefined) {
          entriesCheck = true;
        } else {
          entriesCheck = entries[0].intersectionRatio > 0;
        }

        if (entriesCheck) {
          if (this.lastChunk >= this.totalPages) return;

          let newChunks = { ...this.chunks };
          newChunks = Object.keys(newChunks).map(function (key) {
            return newChunks[key];
          });
          this.page = this.lastChunk + 1;

          if (!(await this.loadData())) return;

          let offset;
          if (this.startOffsetTop === 0) {
            offset = document.querySelector('.sentinel').offsetTop;
          } else {
            offset = this.startOffsetTop;
            this.correctionOffset =
              document.querySelector('.sentinel').offsetTop -
              this.startOffsetTop;
            this.startOffsetTop = 0;
          }

          newChunks.push({
            items: this.pageData,
            offset: this.offsets[this.lastChunk] + offset + 1000,
          });

          this.offsets[this.lastChunk + 1] =
            this.offsets[this.lastChunk] + offset + 1000;
          if (newChunks.length > 2) {
            newChunks.shift();
          }

          this.chunks = newChunks;

          if (this.chunks.length === 2) {
            this.chunks[1].offset =
              this.chunks[1].offset + this.correctionOffset;
          }

          this.lastChunk++;
          setTimeout(function () {
            startObserver.observe(document.querySelector('.sentinel-start'));
            endObserver.observe(document.querySelector('.sentinel'));
          }, 1000);
        }
      }
    };

    const startCallback = async (entries) => {
      this.lastCallback = 'startCallback';
      if (!this.loading) {
        let entriesCheck = false;
        if (entries === undefined) {
          entriesCheck = true;
        } else {
          entriesCheck = entries[0].intersectionRatio > 0;
        }

        if (entriesCheck) {
          this.$refs.messages.style.overflow = 'hidden';
          let newChunks = { ...this.chunks };
          newChunks = Object.keys(newChunks).map(function (key) {
            return newChunks[key];
          });

          this.page = this.lastChunk - 2;

          if (!(await this.loadData())) return;

          if (this.lastChunk === 3) {
            this.chunks[0].offset =
              this.chunks[0].offset - this.correctionOffset;
            this.chunks[1].offset =
              this.chunks[1].offset - this.correctionOffset;
            this.correctionOffset = 0;
          }

          newChunks.unshift({
            items: this.pageData,
            offset:
              this.offsets['' + (this.lastChunk - 2)] + this.correctionOffset,
          });
          if (newChunks.length >= 2) {
            newChunks.pop();
            this.lastChunk--;
          }
          this.chunks = newChunks;
          setTimeout(function () {
            startObserver.observe(document.querySelector('.sentinel-start'));
            endObserver.observe(document.querySelector('.sentinel'));
          }, 1000);
        }
      }
    };

    this.startCallbackFunc = handleStartCallback;
    this.endCallbackFunc = handleEndCallback;

    let endObserver = new IntersectionObserver(handleEndCallback, options);

    let endTarget = document.querySelector('.sentinel');
    if (endTarget !== null && typeof endTarget === 'object') {
      endObserver.observe(endTarget);
    }

    let startObserver = new IntersectionObserver(handleStartCallback, options);

    let startTarget = document.querySelector('.sentinel-start');
    if (startTarget !== null && typeof startTarget === 'object') {
      startObserver.observe(startTarget);
    }
  },

  methods: {
    onDisableNotifications() {
      if (this.changeNotifyState(0)) {
        this.userNotifyMenu = 1;
        this.appStore.setAlertMsg('Уведомления отключены');
        if (this.type === RoomType.channel) this.notifyButtonOn(true);
      }
    },
    onEnableNotifications() {
      if (this.changeNotifyState(1)) {
        this.userNotifyMenu = 2;
        this.appStore.setAlertMsg('Уведомления включены');
        if (this.type === RoomType.channel) this.notifyButtonOn(false);
      }
    },

    async changeNotifyState(notifyState) {
      return (changeNotifyState = await this.messengerStore.changeNotifyState(
        Number(this.roomId),
        notifyState
      ));
    },

    notifyButtonOn(on) {
      if (on) {
        this.inputArea = 'notifyStateOn';
      } else this.inputArea = 'notifyStateOff';
    },

    async onBlockUser() {
      const ban = await this.appStore.socketService.banUser(
        this.companionId,
        this.roomId,
        true
      );
      if (ban.data.banned) {
        this.blockUserMenu = 2;
        this.appStore.setAlertMsg('Пользователь заблокирован');
      }
    },

    async onUnblockUser() {
      const ban = await this.appStore.socketService.banUser(
        this.companionId,
        this.roomId,
        false
      );
      if (!ban.data.banned) {
        this.blockUserMenu = 1;
        this.appStore.setAlertMsg('Пользователь разблокирован');
      }
    },

    async onSubscribe() {
      const subscribeRoom = await this.appStore.socketService.subscribeRoom(
        this.roomId
      );

      if (subscribeRoom?.data) {
        if (this.type === RoomType.channel) {
          this.userNotifyMenu = 2;
          this.notifyButtonOn(false);
        } else {
          this.inputArea = 'input';
          this.userNotifyMenu = 2;
        }

        const messageSent = {
          name: this.userStore.userName,
          type: 12,
        };

        this.addMessage(messageSent);
      }
      if (subscribeRoom?.denied) this.roomDenied = subscribeRoom.denied;
    },

    reloadChat() {
      location.reload();
    },
    setLoadingChat() {
      clearTimeout(this.loadingOverlayTimer);
      this.showChatLoading = true;
      this.loadingOverlayTimer = setTimeout(() => {
        this.showChatLoading = false;
        this.showChatLoadingError = true;
      }, 10000);
    },
    offLoadingChat() {
      clearTimeout(this.loadingOverlayTimer);
      this.showChatLoading = false;
      this.showChatLoadingError = false;
    },

    showChatLoadingOverlay() {
      this.showChatLoading = true;
    },

    setLoading(enable = true) {
      if (typeof enable === 'string') {
        this.showChatLoadingErrorMsg = enable;
        clearTimeout(this.ldOvTimer);
        this.showChatLoading = false;
        this.showChatLoadingError = true;
      } else if (enable) {
        clearTimeout(this.ldOvTimer);
        this.showChatLoading = true;
        this.ldOvTimer = setTimeout(() => {
          this.showChatLoading = false;
          this.showChatLoadingError = true;
        }, 10000);
      } else {
        clearTimeout(this.ldOvTimer);
        this.showChatLoading = false;
        this.showChatLoadingError = false;
      }
    },

    hideChatLoadingOverlay() {
      this.showChatLoading = false;
    },

    async onInputMessage(event) {
      event.target.style.height = 'auto';
      event.target.style.height = event.target.scrollHeight + 'px';
      await this.appStore.socketService.typing(this.roomId.toString());
    },

    toBeginButton() {
      this.$refs.messages.scrollTop = 0;
      this.reload();
    },

    handleScroll(event) {
      const scrollTop = event.target.scrollTop;
      if (scrollTop === 0 && this.allowReload) {
        this.reload();
      } else {
        if (scrollTop > 2500) this.allowReload = true;
        if (scrollTop > 700) {
          this.toBeginButtonVisible = true;
        } else this.toBeginButtonVisible = false;
      }
    },

    onChatMenuBtn() {
      const menuNotifyEnabled = {
        title: 'Включить уведомления',
        icon: mdiBell,
        click: this.onEnableNotifications,
      };
      const menuNotifyDisabled = {
        title: 'Отключить уведомления',
        icon: mdiBellCancel,
        click: this.onDisableNotifications,
      };

      const menuBlockUser = {
        title: 'Заблокировать',
        icon: mdiCancel,
        color: 'red', // color: '#e46d6d',
        click: this.onBlockUser,
      };
      const menuUnblockUser = {
        title: 'Разблокировать',
        icon: mdiCancel,
        color: 'green',
        click: this.onUnblockUser,
      };

      const menuProfilePage = {
        title: 'Профиль пользователя',
        icon: mdiExclamationThick,
        click: () => {
          this.$router.push(`/profile/${this.companionId}`);
        },
      };

      const menuRoomPage = {
        title: 'Информация',
        icon: mdiExclamationThick,
        click: () => {
          this.$router.push(`/im/room/${this.roomId}`);
        },
      };

      this.messengerMenu = [];

      switch (this.type) {
        case 0:
          this.messengerMenu.push(menuProfilePage);
          break;
        case 1:
          this.messengerMenu.push(menuProfilePage);
          break;
        default:
          this.messengerMenu.push(menuRoomPage);
          break;
      }

      switch (this.userNotifyMenu) {
        case 1:
          this.messengerMenu.push(menuNotifyEnabled);
          break;
        case 2:
          this.messengerMenu.push(menuNotifyDisabled);
          break;
      }

      switch (this.blockUserMenu) {
        case 1:
          this.messengerMenu.push(menuBlockUser);
          break;
        case 2:
          this.messengerMenu.push(menuUnblockUser);
          break;
      }

      this.appStore.showDropMenu = true;
    },

    async menuTest() {
      this.chunks[0].items[0].status = 1;
    },

    async menuTestAdd() {
      //
    },

    addMessage(message) {
      console.log(message);
      this.chunks[0].items.unshift(message);
    },

    async inputHandler(e) {
      if (e.keyCode === 13 && !e.shiftKey && !this.sendShiftEnter) {
        e.preventDefault();
        this.sendMessage();
        e.target.style.height = 'auto';
      }

      if (e.keyCode === 13 && e.shiftKey && this.sendShiftEnter) {
        e.preventDefault();
        this.sendMessage();
        e.target.style.height = 'auto';
      }
    },

    async sendMessage() {
      if (!this.inputMessage) return false;

      if (this.startOffsetTop === 0) {
        this.startOffsetTop = document.querySelector('.sentinel').offsetTop;
      }

      if (this.$refs.messages.scrollTop > 700) {
        this.$refs.messages.scrollTop = 0;
        this.reload();
      }

      const message = {
        roomOrUserId: this.roomId.toString(),
        content: this.inputMessage,
        type: 1,
        sendAvatar: this.userStore.userAvatar,
      };

      const newMessage = await this.appStore.socketService.createMessage(
        message
      );

      if (newMessage.data.dialogueCreated) {
        console.log('Dialogue created');
        this.roomId = newMessage.data.roomId;
        this.userNotifyMenu = 2;
        this.blockUserMenu = 1;
      }

      if (newMessage.data.denied) {
        console.log('Room access denied: ' + newMessage.data.denied);
        this.roomDenied = newMessage.data.denied;
        return false;
      }

      let msgName = this.userStore.userName;
      if (this.type === RoomType.channel) {
        msgName = this.roomName;
      }

      const messageSent = {
        name: msgName,
        text: this.inputMessage,
        date: new Date(),
        avatar: this.userStore.userAvatar,
        sent: false,
        type: 1,
        status: 0,
      };

      this.inputMessage = '';
      this.$refs.inputField.style.height = 'auto';
      this.addMessage(messageSent);
    },

    async reload() {
      this.chunks = [];
      this.offsets = { 1: 0 };
      this.lastChunk = 1;
      this.startFlag = false;
      this.page = 1;
      this.pageSize = 50;
      this.totalPages = 0;
      this.loading = false;
      this.pageData = [];
      this.lastCallback = 'none';
      this.allowReload = false;

      await this.loadData();
      this.chunks = [
        {
          offset: 0,
          items: this.pageData,
        },
      ];
    },

    isSent(owner) {
      if (this.type === RoomType.channel || this.type === RoomType.group) {
        return false;
      }

      if (owner === this.userStore.userId) {
        return false;
      } else return true;
    },

    async loadData() {
      this.newMessages = [];

      if (this.page >= 1) {
        this.loading = true;
        this.setLoading();
        const response = await this.messengerStore.messagesGet(
          this.roomId,
          this.page,
          this.pageSize
        );
        if (response.messages) {
          this.totalPages = response.totalPages;

          this.pageData = response.messages.map((item) => {
            let type = Number(item.type);
            let msgName = item.ownerUser.userName;
            if (this.type === RoomType.channel) {
              if (item.type == 12) type = 0;
              msgName = this.roomName;
            }

            const pageDataMapped = {
              userId: item.owner,
              name: msgName,
              text: item.content,
              date: item.createdAt,
              avatar: item.ownerUser.avatar,
              sent: this.isSent(item.owner),
              type, //: Number(item.type),
            };

            return pageDataMapped;
          });

          setTimeout(() => {
            this.$refs.messages.style.overflow = 'auto';
          }, 200);

          this.loading = false;
          this.setLoading(false);
          return true;
        } else {
          this.loading = false;
          this.setLoading(false);
          if (response.denied) {
          } else {
            this.loading = false;
            this.setLoading(response.error);
            console.log('Messages get error: ' + response.error);
          }

          this.pageData = [{ type: 0 }]; // Empty invisible message
          this.$refs.messages.style.overflow = 'auto';
          return false;
        }
      }
    },

    declOfNum(number, titles) {
      const cases = [2, 0, 1, 1, 1, 2];
      return titles[
        number % 100 > 4 && number % 100 < 20
          ? 2
          : cases[number % 10 < 5 ? number % 10 : 5]
      ];
    },
  },
  computed: {
    chatDesc() {
      switch (this.type) {
        case RoomType.bot:
          return 'Бот';
        case RoomType.channel:
          const subscriberWord = this.declOfNum(this.countSubscribers, [
            'подписчик',
            'подписчика',
            'подписчиков',
          ]);
          return this.countSubscribers + ' ' + subscriberWord;
        case RoomType.initDialogue:
        case RoomType.dialogue: {
          let label;

          switch (this.userSex) {
            case 1:
              label = 'был ';
              break;
            case 2:
              label = 'была ';
              break;
            default:
              label = 'был(а) ';
          }

          if (this.lastSeen) {
            return (
              label +
              date.formatDate(this.lastSeen, 'D.MM.YYYY в HH:mm', dateTokens)
            );
          } else return label + 'никогда';
        }

        case RoomType.group:
          const memberWord = this.declOfNum(this.countSubscribers, [
            'участник',
            'участника',
            'участников',
          ]);
          return this.countSubscribers + ' ' + memberWord;
        default:
          return 'загрузка...';
      }
    },
    newMessage() {
      return this.appStore.lastMessage;
    },
    userOnline() {
      return this.appStore.userOnline; //?.online;
    },

    typing() {
      return this.appStore.typing;
    },

    isConnecting() {
      return !this.appStore.isSocketConnected;
    },
  },
  watch: {
    idMixed() {
      //
    },

    roomId() {
      this.appStore.activeRoom = this.roomId;
    },

    newMessage() {
      const msg = this.appStore.lastMessage;

      let sent = false;
      let msgName = msg.name;
      let type = Number(msg.type);

      if (msg.userId !== this.userStore.userId) {
        sent = true;
      }

      if (this.type === RoomType.channel) {
        sent = false;
        msgName = this.roomName;
        if (msg.type == 12) type = 0;
      }

      if (this.type === RoomType.group) {
        sent = false;
      }

      if (msg.msgId) {
        const message = {
          name: msgName,
          text: msg.content,
          date: msg.date,
          avatar: msg.avatar,
          sent,
          type, //: msg.type,
        };
        if (msg.roomId === this.roomId) this.addMessage(message);
      }
    },

    userOnline() {
      if (this.appStore.userOnline.userId === this.companionId) {
        if (this.appStore.userOnline.online) {
          this.lastSeen = new Date(
            this.appStore.userOnline.timestamp
          ).toISOString();
        }

        this.companionClientsOnline[this.appStore.userOnline.sessionId] = {
          status: this.appStore.userOnline.online,
          timestamp: this.appStore.userOnline.timestamp,
        };

        for (let sessionId in this.companionClientsOnline) {
          if (this.companionClientsOnline[sessionId].status) {
            this.chatOnline = true;
            this.chatOnlineValue = true;
            return;
          }
        }

        this.chatOnline = false;
        this.chatOnlineValue = false;
      }
    },

    typing() {
      clearTimeout(this.typingTimer);
      if (
        this.appStore.typing.typing === true &&
        this.appStore.typing.roomId === this.roomId.toString()
      ) {
        this.isTyping = true;
        this.typingTimer = setTimeout(() => {
          this.isTyping = false;
        }, 1000);
      }
    },

    roomIdRaw() {
      //
    },

    roomDenied() {
      console.log('Denied: ' + this.roomDenied);
      switch (this.roomDenied) {
        case false:
          this.inputArea = 'input';
          break;
        case 'userPrivacy':
          this.deniedMessage =
            'Пользователь ограничил круг лиц, которые могут присылать ему сообщения';
          this.inputArea = 'alert';
          break;
        case 'userBan':
          this.deniedMessage = 'Пользователь заблокировал вас';
          this.inputArea = 'alert';
          break;
        case 'adminUserBan':
          this.deniedMessage = 'Пользователь забанен администратором';
          this.inputArea = 'alert';
          break;
        case 'roomDeleted':
          this.deniedMessage = 'Комната удалена';
          this.inputArea = 'alert';
          break;
        case 'roomBanned':
          this.deniedMessage = 'Комната заблокирована';
          this.inputArea = 'alert';
          break;
        case 'roomReadonly':
          this.deniedMessage = 'Только для чтения';
          this.inputArea = 'alert';
          break;

        case 'roomSubscribersOnly':
          this.deniedMessage = 'Доступ только для подписчиков';
          this.inputArea = 'alert';
          break;

        case 'roomNotApproved':
          this.deniedMessage =
            'Доступ только после подтверждения администратором';
          this.inputArea = 'alert';
          break;

        case 'roomRole':
          this.deniedMessage = 'У вас нет прав создавать посты в этом канале';
          this.inputArea = 'alert';
          break;
      }
    },
  },
});
</script>

<style>
html,
body {
  overflow: hidden;
  margin: 0;
  padding: 0;
}
</style>

<style scoped lang="scss">
.container-all-chat {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: 2000;
}

.no-framework-text *,
.no-framework-text p,
.no-framework-text h1,
.no-framework-text h2,
.no-framework-text h3,
.no-framework-text h4,
.no-framework-text h5,
.no-framework-text h6 {
  margin: 0;
  padding: 0;
  font-size: inherit;
  font-weight: normal;
  line-height: normal;
  text-align: inherit;
  color: inherit;
  font-family: inherit;
  text-decoration: none;
  text-transform: none;
  letter-spacing: normal;
  word-spacing: normal;
  white-space: normal;
}

input[type='text'],
input[type='email'],
input[type='tel'],
input[type='number'],
textarea {
  -webkit-appearance: none;
  -moz-appearance: none;
  -ms-appearance: none;
  appearance: none;
  outline: 0;
  box-shadow: none;
}

/* -------------------------------------------------------------------------------- */
.header {
  flex: 0 0 auto;
  padding: 0 5px;
  background: #272727;
  color: white;
  text-align: center;
  height: 60px;
  display: flex;
  flex-direction: row;
  align-items: center;
  z-index: 10000;
}

.header-button-arrow {
  display: flex;
  justify-content: center;
  align-items: center;
  margin-left: 5px;
  color: white;
}

.header-caption {
  display: flex;
  flex-direction: column;
  align-items: start;
  margin-left: 12px;
  white-space: nowrap;
}

.header-caption-title {
  font-weight: bold;
  white-space: inherit;
  font-size: 17px;
  color: rgb(243, 243, 243);
}

.header-caption-desc {
  padding-top: 2px;
  font-size: 14px;
  color: #aeaecc;
  white-space: inherit;
}

.online {
  color: #aba2ff;
}

.loading {
  /* font-weight: bold; */
  display: inline-block;
  font-family: monospace;
  clip-path: inset(0 3ch 0 0);
  animation: l 1s steps(4) infinite;
}

@keyframes l {
  to {
    clip-path: inset(0 -1ch 0 0);
  }
}

.header-menu {
  display: flex;
  flex-direction: row-reverse;
  width: 100%;
}

.header-button-menu {
  display: flex;
  align-items: center;
  height: 100%;
  padding-right: 5px;
  color: white;
}

.avatar-header {
  display: flex;
  justify-content: center;
  align-items: center;
  padding-left: 15px;
  color: white;
}

.avatar-chat-body {
  display: flex;
  align-items: end;
}

/* ------------------------------------------------------------------------------------------- */
.input-area {
  display: flex;
  flex: 0 0 auto;
  padding: 5px;
  padding-left: 15px;
  background: #272727;
  z-index: 1001;
  height: auto;
}

.input-field {
  flex: 1;
  margin-right: 10px;
  border: none;
  font-size: 19px;
  background: #272727;
  caret-color: white;
  color: white;
  padding: 9px 7px 3px 5px;
  max-height: 300px;
  resize: none;
}

.send-button {
  padding-right: 5px;
  border: none;
  background: transparent;
  color: #6589ff;
}

/* ------------------------------------------------------------------------------------------- */

.chat-container {
  display: flex;
  flex-direction: column;
  height: 100%;
  overflow: hidden;
  background-size: contain;
  background-color: #000000;
}

.messages {
  flex: 1 1 auto;
  position: relative;
  overflow-y: auto;
  transform: scale(1, -1);
}

.messagesXXX {
  overflow-y: auto;
  transform: scale(1, -1);
  width: 100%;
}

.msg-overlay {
  display: flex;
  flex: 1 1 auto;
  overflow: hidden;
}

.msg-loading {
  overflow: hidden;
  display: flex;
  flex: 1 1 auto;
}

.bgChat {
  position: sticky;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: 10000;
  background-color: #0000003d;
}

.bgChatZ {
  position: fixed;
  width: 100%;
  height: 100%;
  z-index: 11000000;
  background-color: #ffffff;
}

.overlay-chatQ {
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: 1000000;
  background-color: #00000044;
  transform: rotate(180deg);
}

.chunk {
  width: 100%;
  position: absolute;
  top: 0;
}

.new-chunk {
  width: 100%;
  position: absolute;
  top: 0;
}

.chunk > .sentinel {
  position: absolute;
  pointer-events: none;
  height: 1000px;
  width: 100%;
  visibility: hidden;
  bottom: 0;
  z-index: 9999;
  border: 3px solid blue;
}

.chunk > .sentinel-start {
  position: absolute;
  pointer-events: none;
  height: 1000px;
  width: 100%;
  visibility: hidden;
  top: 0;
  z-index: 9999;
  border: 3px solid red;
}

.chunk > .sentinel-start.disabled,
.chunk > .sentinel.disabled {
  display: none;
}

.toBeginButton {
  width: 45px;
  height: 45px;
  position: absolute;
  z-index: 1000;
  bottom: 70px;
  right: 10px;
  border-radius: 50%;
  display: flex;
  justify-content: center;
  align-items: center;
  background-color: #272727;
  color: white;
}

@keyframes aniUp {
  from {
    transform: translateY(150%);
    opacity: 0;
  }
  to {
    transform: translateY(0);
    opacity: 1;
  }
}

@keyframes aniDown {
  to {
    transform: translateY(150%);
    opacity: 0;
  }
}

.toggleBB-enter-active,
.toggleBB-leave-active {
  animation: aniUp 0.3s;
}

.toggleBB-leave-active,
.toggleBB-leave-to {
  animation: aniDown 0.5s;
}

.input-area-alert {
  display: flex;
  flex-direction: row;
  align-items: center;
  height: 55px;
}

.input-area-alert-icon {
  display: flex;
  align-items: center;
  justify-content: center;
  color: #ffffff;
  font-size: 10px;
  border-radius: 50%;
  width: 40px;
  height: 40px;
  background-color: #f6b100;
}

.input-area-alert-msg {
  padding: 0 15px;
  color: white;
  font-size: 14px;
}

.chat-button {
  background: #272727;
  z-index: 1001;
  display: flex;
  align-items: center;
  justify-content: center;
  height: 50px;
  color: white;
  width: 100%;
  font-size: 15px;
  text-transform: uppercase;
}

.chat-button:active {
  background-color: #2c2c2c;
}

.btn-icon {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  cursor: pointer;
}

.btn-icon:active {
  background-color: #353535;
}

.btn-send {
  color: #3f97f6;
}

.chat-loading-overlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: #00000027;
  z-index: 1001;
  display: flex;
  justify-content: center;
  align-items: center;
}

.chat-loading-overlay-err {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(26, 26, 26, 0.918);
  z-index: 1001;
  display: flex;
  justify-content: center;
  align-items: center;
}

.chat-loading-overlay-err > div {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  padding-top: 20px;
}

.chat-loading-overlay-err > div > div {
  margin: 30px 0;
  color: white;
}

.chat-loading-overlay-err > div > button {
  padding: 15px 70px;
  border: 1px solid rgb(63, 63, 63);
  cursor: pointer;
  background-color: rgb(228, 228, 228);
  font-size: 20px;
  border-radius: 5px;
}

.chat-loading-overlay-err > div > button:active {
  background-color: rgb(202, 202, 202);
}

.loader,
.loader:before,
.loader:after {
  border-radius: 50%;
  width: 2em;
  height: 2em;
  animation-fill-mode: both;
  animation: bblFadInOut 1.2s infinite ease-in-out;
}
.loader {
  color: #222222;
  font-size: 7px;
  position: relative;
  text-indent: -9999em;
  transform: translateZ(0);
  animation-delay: -0.16s;
}
.loader:before,
.loader:after {
  content: '';
  position: absolute;
  top: 0;
}
.loader:before {
  left: -3.5em;
  animation-delay: -0.32s;
}
.loader:after {
  left: 3.5em;
}

@keyframes bblFadInOut {
  0%,
  80%,
  100% {
    box-shadow: 0 2.5em 0 -1.3em;
  }
  40% {
    box-shadow: 0 2.5em 0 0;
  }
}

.newMessage {
  width: 100px;
  height: 30px;
  background-color: #3f97f6;
}
</style>


В дополнение к коду выше стоит привести реализацию компонента ChatBubble:

TypeScript
<template>
  <div>
    <div v-if="!sent" class="message">
      <div class="chat">
        <div v-if="showAvatar" class="avatar-chat-body">
          <router-link :to="`/profile/${userId}`">
            <chat-avatar
              :size="'msg'"
              :type="'avatar'"
              :name="name"
              :avatar="avatar"
            />
          </router-link>
        </div>
        <div class="bubble-body" :style="theme.colorBubble">
          <div v-if="showName" class="bubble-name" :style="theme.colorName">
            {{ name }}
          </div>
          <div class="bubble-text" :style="theme.colorText">
            <div class="msg-text">{{ text }}</div>
            <div class="msg-date">{{ msgDate }}</div>
          </div>

          <div class="footer">
            <div v-if="showStatus" class="msg-status-icon" :style="statusColor">
              <svg-icon type="mdi" :path="statusIcon" size="15"></svg-icon>
            </div>
          </div>
        </div>
      </div>
    </div>

    <div v-if="sent" class="message msg-sent">
      <div class="chat">
        <div class="bubble-body-sent" :style="theme.colorBubbleSent">
          <div
            v-if="showName"
            class="bubble-name-sent"
            :style="theme.colorNameSent"
          >
            {{ name }}
          </div>
          <div class="bubble-text-sent" :style="theme.colorTextSent">
            <div class="msg-text">{{ text }}</div>
            <div class="msg-date-sent">{{ msgDate }}</div>
          </div>
          <div class="footer">
            <div v-if="showStatus" class="msg-status-icon" :style="statusColor">
              <svg-icon
                type="mdi"
                :path="statusIcon"
                size="15"
                :style="statusColor"
              ></svg-icon>
            </div>
          </div>
        </div>
        <div v-if="showAvatar" class="avatar-chat-body-sent">
          <router-link :to="`/profile/${userId}`">
            <chat-avatar
              :size="'msg'"
              :type="'avatar'"
              :name="name"
              :avatar="avatar"
            />
          </router-link>
        </div>
      </div>
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue';
import ChatAvatar from '../../avatar/ChatAvatar.vue';
import { date } from 'quasar';
import dateTokens from '../../../i18n/ru/date';
import SvgIcon from '@jamescoyle/vue-icon';

import { mdiUpdate, mdiCheck, mdiCheckAll } from '@mdi/js';

export default defineComponent({
  name: 'ChatBubble',
  props: [
    'sent',
    'name',
    'text',
    'date',
    'avatar',
    'status',
    'showAvatar',
    'showName',
    'showStatus',
    'userId',
  ],
  components: { ChatAvatar, SvgIcon },

  data() {
    return {
      theme: {
        colorBubble: { backgroundColor: 'white' },
        colorBubbleSent: { backgroundColor: '#effddb' },
        colorName: { color: '#141313' },
        colorNameSent: { color: '#141313' },
        colorDate: { color: '#141313' },
        colorDateSent: { color: '#141313' },
        colorText: { color: 'black' },
        colorTextSent: { color: 'black' },
      },

      mdiUpdate,
      mdiCheck,
      mdiCheckAll,
    };
  },
  computed: {
    msgDate() {
      return date.formatDate(this.date, 'HH:mm', dateTokens);
    },
    statusIcon() {
      switch (this.status) {
        case 0:
          return mdiUpdate;
        case 1:
          return mdiCheck;
        case 2:
          return mdiCheckAll;
        default:
          return mdiUpdate;
      }
    },
    statusColor() {
      switch (this.status) {
        case 0:
          return { color: 'black' };
        case 1:
          return { color: '#green' };
        case 2:
          return { color: '#green' };
        default:
          return { color: 'red' };
      }
    },
  },
});
</script>

<style scoped>
.avatar-chat-body {
  display: flex;
  align-items: end;
  margin-right: -8px;
}

.avatar-chat-body-sent {
  display: flex;
  align-items: end;
  margin-left: -7px;
}

/* ------------------------------------------------------------------------------------------- */

.message {
  display: flex;
  flex-direction: column;
  width: 100%;
  margin-bottom: 7px;
  margin-top: 7px;
  padding-left: 5px;
  padding-right: 5px;
  background: transparent;
  transform: scale(1, -1);
  overflow-wrap: break-word;
}

.msg-sent {
  align-items: end;
}

/* ------------------------------------------------------------------------------------------- */

.chat {
  display: flex;
  flex-direction: row;
  min-height: 35px;
  background-color: transparent;
}

.bubble-body {
  border-radius: 15px 15px 15px 0;
  background-color: white;
  margin-left: 7px;
  position: relative;
  padding: 7px 10px 7px 14px;
  display: flex;
  flex-flow: column;
  margin-left: 15px;
  max-width: calc(100vw - 33px);
  margin-right: 15px;
}

.bubble-body::after {
  content: '';
  position: absolute;
  bottom: 0;
  left: -19px;
  display: block;
  height: 20px;
  width: 20px;
  z-index: 100;
  background: inherit;
  clip-path: url(#svgPathLeft);
  background-color: inherit;
}

.bubble-name {
  font-size: 14px;
  font-family: 'Helvetica Neue', Helvetica, sans-serif;
  color: #141313;
  margin-bottom: 3px;
  font-weight: bold;
}

.bubble-text {
  color: black;
  font-size: 17px;
  line-height: 20px;
}

.msg-text {
  display: inline;
}

.msg-date {
  font-size: 12px;
  padding-top: 8px;
  flex-wrap: wrap;
  padding-left: 10px;
  margin-top: -2px;
  display: inline;
  float: right;
  color: #9e9d9d;
}

.msg-date-sent {
  font-size: 12px;
  padding-top: 8px;
  flex-wrap: wrap;
  padding-left: 10px;
  margin-top: -2px;
  display: inline;
  float: right;
  color: rgb(101, 172, 109);
}

.bubble-date {
  font-family: 'Helvetica Neue', Helvetica, sans-serif;
  text-align: right;
  font-size: 12px;
  color: #181515;
  margin-top: 3px;
}

.footer {
  display: flex;
}

.msg-status-icon {
  padding-top: 1px;
  padding-left: 4px;
  margin-bottom: -3px;
  margin-right: -3px;
}

/* ------------------------------------------------------------------------------------------- */

.bubble-bodyss {
  border-radius: 15px 15px 15px 0;
  background-color: white;
  margin-left: 7px;
  position: relative;
  padding: 7px 10px 7px 14px;
  display: flex;
  flex-flow: column;
  margin-left: 15px;
  max-width: 370px;
}

.bubble-body-sent {
  border-radius: 15px 15px 0 15px;
  background-color: #3490eb;
  margin-right: 14px;
  position: relative;
  padding: 7px 10px 7px 14px;
  display: flex;
  flex-flow: column;
  color: white;
  margin-left: 15px;
  max-width: calc(100vw - 31px);
  overflow-wrap: break-word;
}

.bubble-body-sent::after {
  content: '';
  position: absolute;
  bottom: 0;
  right: -19px;
  display: block;
  height: 20px;
  width: 20px;
  z-index: 100;
  background: inherit;
  clip-path: url(#svgPathRight);
  background-color: inherit;
}

.bubble-name-sent {
  font-size: 14px;
  font-family: 'Helvetica Neue', Helvetica, sans-serif;
  color: #ffffff;
  margin-bottom: 3px;
  font-weight: bold;
}

.bubble-text-sent {
  color: black;
  font-family: 'Helvetica Neue', Helvetica, sans-serif;
  font-size: 18px;
  color: #ffffff;
}
</style>

Страница профиля пользователя GetProfile


Приведу ее код целиком:

TypeScript
<template>
  <div class="q-pa-md" style="max-width: 700px; margin: auto">
    <q-list separator>
      <q-item
        clickable
        v-ripple
        class="profile"
        :to="isOwner || isPrivileged ? '/account/avatar/' + id : ''"
      >
        <user-avatar
          :userId="id"
          :avatar="avatar"
          :userName="userName"
          :size="'high'"
          :badgeType="'edit'"
        />
      </q-item>
      <q-item
        :to="isOwner || isPrivileged ? '/account/nickname/' + id : ''"
        clickable
        v-ripple
        class="profile"
        style="border-top: none; padding: 12px 0 10px 0; font-size: 25px"
      >
        {{ userName }}
        <q-icon
          v-if="isOwner || isPrivileged"
          name="edit"
          size="1.1rem"
          color="primary"
        />
      </q-item>

      <q-item v-if="!isOwner" style="border-top: none; padding-bottom: 15px">
        <div style="display: flex; justify-content: space-between; width: 100%">
          <friend-button :toId="userId" v-if="userId"></friend-button>

          <q-btn :to="'/im/u' + userId" flat style="color: #3b3847"
            ><q-icon name="chat"></q-icon>  Сообщение</q-btn
          >
        </div>
      </q-item>

      <q-item style="border-top: none">
        <q-item-section>
          <q-item-label
            ><div class="friendbox">
              <div class="friendcolumn">
                <router-link :to="'/friends/mutually/' + id">
                  <div class="friendButton">
                    Друзья: {{ mutuallyCount }}
                  </div>
                </router-link>
              </div>
              <div class="friendcolumn">
                <router-link :to="'/friends/subscribed/' + id">
                  <div class="friendButton">
                    Подписки: {{ subscribedCount }}
                  </div>
                </router-link>
              </div>
              <div class="friendcolumn">
                <router-link :to="'/friends/subscriber/' + id">
                  <div class="friendButton">
                    Подписчики: {{ subscriberCount }}
                  </div>
                </router-link>
              </div>
            </div></q-item-label
          >
        </q-item-section>
      </q-item>

      <q-item v-ripple>
        <q-item-section>
          <q-item-label>{{ lastSeenLabel }} </q-item-label>
          <q-item-label caption>{{ lastSeenSex }} в сети</q-item-label>
        </q-item-section>
      </q-item>

      <q-item
        clickable
        v-ripple
        :Xto="!isPrivileged ? '/account/roles/' + id : ''"
        v-if="isPrivileged || isPrivilegedUser"
      >
        <q-item-section>
          <q-item-label style="color: red">{{ rolesDescription }}</q-item-label>
          <q-item-label caption>Роли пользователя</q-item-label>
        </q-item-section>
      </q-item>
      <q-item
        clickable
        v-ripple
        :to="isOwner || isPrivileged ? '/profile/name/' + id : ''"
      >
        <q-item-section>
          <q-item-label>{{ firstName }} {{ lastName }}</q-item-label>
          <q-item-label caption>Имя, фамилия</q-item-label>
        </q-item-section>
        <q-item-section side v-if="isOwner || isPrivileged">

        </q-item-section>
      </q-item>
      <q-item
        clickable
        v-ripple
        :to="isOwner || isPrivileged ? '/profile/birthday/' + id : ''"
      >
        <q-item-section>
          <q-item-label>{{ profileBirthday }}</q-item-label>
          <q-item-label caption>День рождения</q-item-label>
        </q-item-section>
        <q-item-section side v-if="isOwner || isPrivileged">

        </q-item-section>
      </q-item>
      <q-item
        clickable
        v-ripple
        :to="isOwner || isPrivileged ? '/profile/sex/' + id : ''"
      >
        <q-item-section>
          <q-item-label>{{ profileGender }}</q-item-label>
          <q-item-label caption>Пол</q-item-label>
        </q-item-section>
        <q-item-section side v-if="isOwner || isPrivileged">

        </q-item-section>
      </q-item>

      <q-item
        clickable
        v-ripple
        :to="isOwner || isPrivileged ? '/profile/location/' + id : ''"
      >
        <q-item-section>
          <q-item-label
            >{{ country }}<span><span v-show="country && city">,</span></span>
            {{ city }}</q-item-label
          >
          <q-item-label caption>Страна, город</q-item-label>
        </q-item-section>
        <q-item-section side v-if="isOwner || isPrivileged">

        </q-item-section>
      </q-item>

      <q-item
        clickable
        v-ripple
        :to="isOwner || isPrivileged ? '/profile/about/' + id : ''"
      >
        <q-item-section>
          <q-item-label>{{ about }}</q-item-label>
          <q-item-label caption>О себе</q-item-label>
        </q-item-section>
        <q-item-section side v-if="isOwner || isPrivileged">

        </q-item-section>
      </q-item>

      <q-item
        v-if="isOwner || isPrivileged"
        clickable
        v-ripple
        :to="isOwner || isPrivileged ? '/account/' + id : ''"
      >
        <q-item-section>
          <q-item-label>Аккаунт</q-item-label>
          <q-item-label caption>Настройка почты, пароля, и др.</q-item-label>
        </q-item-section>
        <q-item-section