В этом материале я опишу общую архитектуру и некоторые детали реализации клиентской части 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:
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:
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
:
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
:
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
. Приведу его содержимое полностью:
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);
}
}
Компоненты
Для начала рассмотрим главный компонент, служащий основой для большей части приложения:
<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:
Полностью реализовано самостоятельно. Код целиком:
<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:
Полностью реализовано самостоятельно. Код целиком:
<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
. Этот компонент позволяет удобно определить координаты обрезки для изображения аватара, а затем уже на сервере, будет произведена обрезка. Ниже привожу код страницы загрузки аватара полностью:
<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), не смотря на имеющиеся недочеты:
<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
:
<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
Приведу ее код целиком:
<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 side v-if="isOwner || isPrivileged">
〉
</q-item-section>
</q-item>
</q-list>
</div>
<Overlay-User-404 v-show="!isLoaded" />
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import UserAvatar from '../UserAvatar.vue';
import FriendButton from '../../friend/FriendButton.vue';
import OverlayUser404 from '../OverlayUser404.vue';
import { useAppStore } from '../../common/stores/app.store';
import { useUserStore } from '../user.store';
import { useFriendStore } from '../../friend/friend.store';
import { Sex } from '../../params/const';
import { date } from 'quasar';
import dateTokens from '../../i18n/ru/date';
import { DATE_FORMAT } from '../../params/const';
const appStore = useAppStore();
const userStore = useUserStore();
const friendStore = useFriendStore();
export default defineComponent({
components: { FriendButton, OverlayUser404, UserAvatar },
name: 'ProfilePage',
props: ['id'],
data() {
return {
isLoaded: true,
tabsBar: 'profile',
userId: 0,
firstName: '',
lastName: '',
about: '',
birthday: '',
sex: 0,
country: '',
city: '',
createdAt: '',
updatedAt: '',
userName: '',
avatar: '',
mutuallyCount: 0,
subscribedCount: 0,
subscriberCount: 0,
rolesDescription: '',
isPrivilegedUser: false,
lastSeen: '',
};
},
async mounted() {
await this.getUserProfile();
await this.getCountFriends();
appStore.setCaption('Профиль', '/im');
},
methods: {
async getCountFriends() {
const count = await friendStore.countFriends(this.id);
if (count) {
this.mutuallyCount = count.mutually;
this.subscribedCount = count.subscribed;
this.subscriberCount = count.subscriber;
}
},
async getUserProfile() {
const profile = await userStore.getProfile(this.id);
if (profile) {
this.userId = profile.userId;
this.firstName = profile.firstName ?? '';
this.lastName = profile.lastName ?? '';
this.about = profile.about ?? '';
this.birthday = profile.birthday ?? '';
this.sex = profile.sex ?? 0;
this.country = profile.country ?? '';
this.city = profile.city ?? '';
this.createdAt = profile.createdAt;
this.updatedAt = profile.updatedAt;
this.userName = profile.user.userName;
this.avatar = profile.user.avatar;
this.lastSeen = profile.user.lastSeen;
this.rolesDescription = profile.user.roles
.map(function (role) {
return role.description;
})
.join(', ');
const privilegedRoles = ['ADMIN', 'MODERATOR'];
this.isPrivilegedUser = profile.user.roles.some((role) =>
privilegedRoles.includes(role.value)
);
this.isLoaded = true;
} else this.isLoaded = false;
},
},
computed: {
lastSeenLabel() {
return date.formatDate(this.lastSeen, 'D MMMM YYYY, в H:mm', dateTokens);
},
profileBirthday() {
return date.formatDate(this.birthday, 'D MMMM YYYY', dateTokens);
},
profileGender() {
switch (this.sex) {
case Sex.none:
return 'Не указан';
case Sex.male:
return 'Мужской';
case Sex.female:
return 'Женский';
case Sex.other:
return 'Другой';
default:
return 'Не указан';
}
},
lastSeenSex() {
switch (this.sex) {
case Sex.none:
return 'Был(а)';
case Sex.male:
return 'Был';
case Sex.female:
return 'Была';
case Sex.other:
return 'Был(а)';
default:
return 'Был(а)';
}
},
isOwner() {
if (userStore.userId === this.id) {
return true;
} else {
return false;
}
},
isPrivileged() {
return userStore.isPrivileged();
},
},
watch: {
async id() {
await this.getUserProfile();
await this.getCountFriends();
},
},
});
</script>
<style scoped>
.profile {
padding: 10px 0 5px 0;
display: flex;
justify-content: center;
}
.tabsBar {
background: rgba(0, 0, 0, 0.541);
padding: 0.25rem;
position: fixed;
bottom: 0;
left: 0;
right: 0;
}
.friendbox {
display: flex;
width: 100%;
}
.friendcolumn {
flex: 1;
text-align: center;
margin-left: -10px;
box-sizing: border-box;
}
.friendButton {
padding: 10px;
margin: 8px;
border: 1px solid transparent;
}
a {
text-decoration: none;
color: black;
}
.friendButton:hover {
border: 1px solid rgb(240, 240, 240);
background-color: rgba(230, 230, 230, 0.301);
}
.friendButton:active {
border: 1px solid rgb(240, 240, 240);
background-color: rgba(209, 209, 209, 0.301);
}
</style>
Форма EditPassword
Если активировать видимость пароля, форма изменится соответственно:
Приведу код целиком:
<template>
<center-page>
<q-form @submit.prevent.stop="onSubmit" class="q-gutter-md">
<q-input
v-if="isPasswordSet"
filled
class="full-width"
v-model="oldPassword"
label="Введите старый пароль"
:hint="hint"
lazy-rules
:rules="passwordRules"
:type="isPwd ? 'password' : 'text'"
>
<template v-slot:append>
<q-icon
:name="isPwd ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="isPwd = !isPwd"
/>
</template>
</q-input>
<q-input
filled
class="full-width"
v-model="newPassword"
label="Новый пароль"
:hint="hint"
lazy-rules
:rules="passwordRules"
:type="isPwd ? 'password' : 'text'"
>
<template v-slot:append>
<q-icon
v-if="!isPasswordSet"
:name="isPwd ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="isPwd = !isPwd"
/>
</template>
</q-input>
<q-input
v-if="isPwd"
filled
class="full-width"
v-model="confirmPassword"
label="Повторите пароль"
:hint="hint"
lazy-rules
:rules="passwordRules"
type="password"
/>
<br />
<div class="buttonBar">
<q-btn
unelevated
label="Сохранить"
type="submit"
color="grey-10"
class="full-width"
size="lg"
/>
</div>
<p class="text-red-6" style="height: 50px; text-align: center">
{{ error }}
</p>
</q-form>
</center-page>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import CenterPage from '../../components/UI/CenterPage.vue';
import { useAppStore } from '../../common/stores/app.store';
import { useUserStore } from '../../user/user.store';
const appStore = useAppStore();
const userStore = useUserStore();
const passwordRules = [
(val: string) => (val && val.length > 7) || 'Не менее 8 символов',
(val: string) => (val && val.length <= 50) || 'Не более 50 символов',
];
export default defineComponent({
name: 'EditPassword',
components: { CenterPage },
props: ['id'],
data() {
return {
hint: '8 - 50 символов',
passwordRules,
oldPassword: '',
newPassword: '',
confirmPassword: '',
error: '',
isPasswordSet: false,
isPwd: true,
};
},
mounted() {
appStore.setCaption('Пароль', `/account/${this.id}`);
this.getAccount();
},
methods: {
async getAccount() {
const user = await userStore.getUserSelf(this.id);
if (user) {
this.isPasswordSet = user.isPasswordSet;
}
},
async onSubmit() {
if (this.newPassword === this.confirmPassword || !this.isPwd) {
const editPassword = await userStore.editPassword(
this.id,
this.oldPassword,
this.newPassword
);
if (editPassword) {
this.$router.push(`/account/${this.id}`);
} else {
this.error = userStore.error;
}
} else {
this.error = 'Пароли не совпадают';
}
},
cancel() {
this.$router.push(`/account/${this.id}`);
},
},
});
</script>
<style scoped>
.buttonBar {
position: fixed;
bottom: 0;
margin: 0;
left: 0;
right: 0;
padding: 10px;
padding-bottom: 40px;
}
</style>
Если вам интересен наш проект, есть вопросы, замечания, или предложения — оставляйте комментарии или пишите на почту: checkerwars@mail.ru
Кроме того, автор проекта ищет работу. Мое резюме.