2025-05-21 22:13:09 +07:00
< template >
< div class = "chat-view-container d-flex flex-column" >
< div v-if = "loadingInitialMessages" class="flex-grow-1 d-flex justify-content-center align-items-center" >
< div class = "spinner-border text-primary" role = "status" >
< span class = "visually-hidden" > Загрузка сообщений ... < / span >
< / div >
< / div >
< div v-if = "error" class="alert alert-danger m-3" > {{ error }} < / div >
< div v-if = "!loadingInitialMessages && !conversationData" class="alert alert-warning m-3" >
Н е удалось загрузить данные диалога .
< / div >
< div v-if = "conversationData && !loadingInitialMessages" class="chat-header p-3 bg-light border-bottom" >
< h5 class = "mb-0" > Чат с { { getOtherParticipantName ( ) || 'Собеседник' } } < / h5 >
< / div >
< div ref = "messagesContainer" class = "messages-area flex-grow-1 p-3" style = "overflow-y: auto;" >
< template v-for = "item in messagesWithDateSeparators" :key="item.id" >
< div v-if = "item.type === 'date_separator'" class="date-separator text-center my-2" >
< span class = "badge bg-secondary-subtle text-dark-emphasis" > { { formatDateHeader ( item . timestamp ) } } < / span >
< / div >
< div v -else -if = " item.type = = = ' message ' "
: class = "['message-item-wrapper', item.data.sender?._id === currentUser?._id ? 'sent-wrapper' : 'received-wrapper']"
@ mouseenter = "!isTouchDevice.value && handleMouseEnter(item.data)"
@ mouseleave = "!isTouchDevice.value && handleMouseLeave()"
>
< div : class = "['message-bubble', item.data.sender?._id === currentUser?._id ? 'sent' : 'received']"
@ click . stop = "handleMessageTap(item.data, $event)"
@ contextmenu . stop = "handleNativeContextMenu($event, item.data)" >
< div class = "message-content" >
< p class = "mb-0" > { { item . data . text } } < / p >
< small class = "message-timestamp text-muted" >
{ { formatMessageTimestamp ( item . data . createdAt ) } }
2025-05-22 00:37:45 +07:00
< span v-if = "item.data.isEdited" class="ms-1 fst-italic" > ( изменено ) < / span >
2025-05-21 22:13:09 +07:00
< span v-if = "item.data.sender?._id === currentUser?._id && item.data.isSending" class="ms-1 fst-italic" > ( отправка... ) < / span >
< span v-if = "item.data.sender?._id === currentUser?._id && !item.data.isSending" class="ms-1" >
< i :class = "getMessageStatusIcon(item.data)" > < / i >
< / span >
< / small >
< / div >
< / div >
<!-- Кнопка удаления для ПК ( появляется при наведении ) -- >
2025-05-22 00:28:14 +07:00
< button v-if = "!isTouchDevice.value && hoveredMessageId === item.data._id && item.data.sender?._id === currentUser?._id && !editingMessageId"
2025-05-21 22:13:09 +07:00
@ click = "confirmDeleteMessage(item.data._id)"
class = "btn btn-sm btn-danger delete-message-btn"
title = "Удалить сообщение" >
< i class = "bi bi-trash" > < / i >
< / button >
2025-05-22 00:28:14 +07:00
< button v-if = "!isTouchDevice.value && hoveredMessageId === item.data._id && item.data.sender?._id === currentUser?._id && !editingMessageId"
@ click = "startEditMessage(item.data._id)"
class = "btn btn-sm btn-info edit-message-btn"
title = "Редактировать сообщение" >
< i class = "bi bi-pencil-fill" > < / i >
< / button >
2025-05-21 22:13:09 +07:00
< / div >
< / template >
< / div >
<!-- Контекстное меню для мобильных устройств -- >
< div v-if = "contextMenu && contextMenu.visible"
ref = "contextMenuRef"
class = "context-menu"
: style = "{ top: contextMenu.y + 'px', left: contextMenu.x + 'px' }" >
< ul >
2025-05-22 00:28:14 +07:00
< li @click ="startEditMessage(contextMenu.messageId)" >
< i class = "bi bi-pencil-fill" > < / i >
< span > Редактировать < / span >
< / li >
2025-05-21 22:13:09 +07:00
< li @click ="confirmDeleteMessage(contextMenu.messageId)" >
< i class = "bi bi-trash" > < / i >
< span > Удалить сообщение < / span >
< / li >
< / ul >
< / div >
<!-- Индикатор "печатает" -- >
< div v-if = "otherUserIsTyping" class="typing-indicator px-3 pb-2 text-muted fst-italic" >
{ { getOtherParticipantName ( ) || 'Собеседник' } } печатает ...
< / div >
< div v-if = "isAuthenticated" class="message-input-area p-3 border-top bg-light" >
2025-05-22 00:28:14 +07:00
< form @ submit.prevent = " editingMessageId ? saveEditedMessage ( ) : sendMessage ( ) " class = "d-flex" >
2025-05-21 22:13:09 +07:00
< input
type = "text"
v - model = "newMessageText"
class = "form-control me-2"
2025-05-22 00:28:14 +07:00
: placeholder = "editingMessageId ? 'Редактирование сообщения...' : 'Введите сообщение...'"
: disabled = "sendingMessage || savingEdit"
ref = "messageInputRef"
2025-05-21 22:13:09 +07:00
/ >
2025-05-22 00:28:14 +07:00
< button
type = "submit"
class = "btn btn-primary"
: disabled = "sendingMessage || savingEdit || !newMessageText.trim()"
>
< span v-if = "sendingMessage || savingEdit" class="spinner-border spinner-border-sm" role="status" aria-hidden="true" > < / span >
{ { editingMessageId ? 'Сохранить' : 'Отправить' } }
< / button >
< button
v - if = "editingMessageId"
type = "button"
class = "btn btn-secondary ms-2"
@ click = "cancelEditMessage"
: disabled = "savingEdit"
>
Отмена
2025-05-21 22:13:09 +07:00
< / button >
< / form >
< / div >
< / div >
< / template >
< script setup >
import { ref , onMounted , onUnmounted , nextTick , computed , watch } from 'vue' ;
import { useRoute } from 'vue-router' ;
import api from '@/services/api' ;
import { useAuth } from '@/auth' ;
import { getSocket , connectSocket , disconnectSocket } from '@/services/socketService' ;
const route = useRoute ( ) ;
const { user : currentUser , isAuthenticated } = useAuth ( ) ;
const isTouchDevice = ref ( false ) ;
const hoveredMessageId = ref ( null ) ;
const contextMenu = ref ( { visible : false , x : 0 , y : 0 , messageId : null } ) ;
const contextMenuRef = ref ( null ) ;
const handleMouseEnter = ( message ) => {
if ( ! isTouchDevice . value ) {
hoveredMessageId . value = message . _id ;
}
} ;
const handleMouseLeave = ( ) => {
if ( ! isTouchDevice . value ) {
hoveredMessageId . value = null ;
}
} ;
// New handler for tap/click
const handleMessageTap = ( message , event ) => {
if ( message . sender ? . _id === currentUser . value ? . _id ) {
// event.preventDefault(); // Already handled by contextmenu or default click behavior if not a link
// event.stopPropagation(); // Handled by .stop modifier in template
showContextMenu ( message , event ) ;
}
// For messages from other users, allow default behavior (e.g., text selection, link clicks)
} ;
const showContextMenu = ( message , eventSource ) => {
if ( message . sender ? . _id === currentUser . value ? . _id ) {
contextMenu . value . messageId = message . _id ;
let x = eventSource . clientX ;
let y = eventSource . clientY ;
// Set initial position and make visible to calculate dimensions
contextMenu . value . x = x ;
contextMenu . value . y = y ;
contextMenu . value . visible = true ;
nextTick ( ( ) => {
if ( contextMenuRef . value ) {
const menuWidth = contextMenuRef . value . offsetWidth ;
const menuHeight = contextMenuRef . value . offsetHeight ;
const screenWidth = window . innerWidth ;
const screenHeight = window . innerHeight ;
const PADDING = 10 ; // Screen edge padding
if ( x + menuWidth > screenWidth - PADDING ) {
x = screenWidth - menuWidth - PADDING ;
}
if ( x < PADDING ) {
x = PADDING ;
}
if ( y + menuHeight > screenHeight - PADDING ) {
y = screenHeight - menuHeight - PADDING ;
}
if ( y < PADDING ) {
y = PADDING ;
}
contextMenu . value . x = x ;
contextMenu . value . y = y ;
}
} ) ;
} else {
contextMenu . value . visible = false ;
}
} ;
const conversationId = ref ( route . params . conversationId ) ;
const conversationData = ref ( null ) ; // Для информации о диалоге (например, участники)
const messages = ref ( [ ] ) ;
const newMessageText = ref ( '' ) ;
const loadingInitialMessages = ref ( true ) ;
const sendingMessage = ref ( false ) ;
const error = ref ( '' ) ;
2025-05-22 00:28:14 +07:00
// Для редактирования сообщений
const editingMessageId = ref ( null ) ;
const originalMessageText = ref ( '' ) ;
const savingEdit = ref ( false ) ;
const messageInputRef = ref ( null ) ;
2025-05-21 22:13:09 +07:00
let socket = null ;
const otherUserIsTyping = ref ( false ) ; // Для отображения индикатора, что собеседник печатает
let stopTypingEmitTimer = null ; // Новое имя таймера для отправки stopTyping
const userHasSentTypingEvent = ref ( false ) ; // Отслеживает, отправляли ли 'typing' для текущей сессии набора
const TYPING _TIMER _LENGTH = 1500 ; // мс, через сколько отправлять stopTyping
const messagesWithDateSeparators = computed ( ( ) => {
if ( ! messages . value || messages . value . length === 0 ) return [ ] ;
const result = [ ] ;
let lastDate = null ;
messages . value . forEach ( message => {
const messageTimestamp = message . createdAt || Date . now ( ) ; // Fallback for temp messages if needed
const messageDate = new Date ( messageTimestamp ) . toDateString ( ) ;
if ( messageDate !== lastDate ) {
result . push ( { type : 'date_separator' , timestamp : messageTimestamp , id : ` date- ${ messageTimestamp } - ${ message . _id || Math . random ( ) } ` } ) ;
lastDate = messageDate ;
}
result . push ( { type : 'message' , data : message , id : message . _id || message . tempId || ` msg- ${ Math . random ( ) } ` } ) ;
} ) ;
return result ;
} ) ;
const formatDateHeader = ( timestamp ) => {
if ( ! timestamp ) return '' ;
const date = new Date ( timestamp ) ;
const today = new Date ( ) ;
const yesterday = new Date ( today ) ;
yesterday . setDate ( yesterday . getDate ( ) - 1 ) ;
if ( date . toDateString ( ) === today . toDateString ( ) ) {
return 'Сегодня' ;
} else if ( date . toDateString ( ) === yesterday . toDateString ( ) ) {
return 'Вчера' ;
} else {
return date . toLocaleDateString ( [ ] , { day : 'numeric' , month : 'long' , year : 'numeric' } ) ;
}
} ;
// Функция для отметки сообщений как прочитанных
const markMessagesAsReadOnServer = ( ) => {
if ( socket && conversationId . value && currentUser . value ? . _id ) {
// Проверим, есть ли вообще непрочитанные сообщения от собеседника
const unreadMessagesFromOther = messages . value . some (
msg => msg . sender ? . _id !== currentUser . value . _id &&
( ! msg . readBy || ! msg . readBy . includes ( currentUser . value . _id ) )
) ;
if ( unreadMessagesFromOther ) {
console . log ( '[ChatView] Отправка события markMessagesAsRead для диалога:' , conversationId . value ) ;
socket . emit ( 'markMessagesAsRead' , {
conversationId : conversationId . value ,
userId : currentUser . value . _id ,
} ) ;
}
}
} ;
// Функция для прокрутки к последнему сообщению
const messagesContainer = ref ( null ) ;
const scrollToBottom = ( ) => {
nextTick ( ( ) => {
if ( messagesContainer . value ) {
messagesContainer . value . scrollTop = messagesContainer . value . scrollHeight ;
}
} ) ;
} ;
const fetchInitialMessages = async ( ) => {
if ( ! conversationId . value ) return ;
loadingInitialMessages . value = true ;
error . value = '' ;
try {
console . log ( ` [ChatView] Запрос истории сообщений для диалога: ${ conversationId . value } ` ) ;
const response = await api . getMessagesForConversation ( conversationId . value ) ;
// Теперь API возвращает объект { conversation: {}, messages: [] }
messages . value = response . data . messages || [ ] ;
conversationData . value = response . data . conversation ; // <--- СОХРАНЯЕМ ДАННЫЕ ДИАЛОГА
console . log ( '[ChatView] Сообщения загружены:' , messages . value ) ;
console . log ( '[ChatView] Данные диалога загружены:' , conversationData . value ) ; // Для отладки
scrollToBottom ( ) ;
markMessagesAsReadOnServer ( ) ; // Отмечаем как прочитанные при загрузке
} catch ( err ) {
console . error ( '[ChatView] Ошибка загрузки сообщений:' , err ) ;
error . value = err . response ? . data ? . message || 'Н е удалось загрузить сообщения.' ;
} finally {
loadingInitialMessages . value = false ;
}
} ;
const getOtherParticipant = ( ) => {
if ( conversationData . value && conversationData . value . participants && currentUser . value ) {
return conversationData . value . participants . find ( p => p . _id !== currentUser . value . _id ) ;
}
return null ;
} ;
const getOtherParticipantName = ( ) => getOtherParticipant ( ) ? . name ;
const sendMessage = ( ) => {
2025-05-22 00:28:14 +07:00
if ( ! newMessageText . value . trim ( ) || ! socket || ! currentUser . value || sendingMessage . value || editingMessageId . value ) return ;
2025-05-21 22:13:09 +07:00
const otherParticipant = getOtherParticipant ( ) ; // Нужно получить ID получателя!
// Это слабое место, если conversationData не загружено правильно.
// Для теста можно временно захардкодить receiverId, если знаешь е г о .
// Или передать е г о из ChatListView.
let receiverId ;
// ВАЖНО: Логика получения receiverId должна быть надежной.
// Например, если мы перешли из списка чатов, мы можем передать инфо о собеседнике.
// Или при загрузке диалога, API должен вернуть участников.
// Для примера, если бы conversationData было установлено:
if ( otherParticipant ) {
receiverId = otherParticipant . _id ;
} else {
console . error ( "Н е удалось определить получателя!" ) ;
error . value = "Н е удалось определить получателя для отправки сообщения." ;
return ;
}
const messageData = {
senderId : currentUser . value . _id ,
receiverId : receiverId , // ID получателя
text : newMessageText . value ,
conversationId : conversationId . value , // Можно отправлять, если нужно на сервере
// tempId: Date.now().toString() // Временный ID для оптимистичного обновления UI
} ;
// Оптимистичное добавление (можно улучшить, добавив статус isSending)
// const tempMessage = { ...messageData, createdAt: new Date().toISOString(), sender: currentUser.value, tempId: messageData.tempId, isSending: true };
// messages.value.push(tempMessage);
// scrollToBottom();
console . log ( '[ChatView] Отправка сообщения через сокет:' , messageData ) ;
sendingMessage . value = true ;
socket . emit ( 'sendMessage' , messageData ) ;
// sendingMessage будет false, когда придет подтверждение или ошибка от сервера (пока не реализовано)
// Пока просто сбросим через некоторое время или после получения своего же сообщения
// setTimeout(() => { sendingMessage.value = false; }, 1000);
newMessageText . value = '' ; // Очищаем поле ввода
2025-05-22 00:28:14 +07:00
if ( messageInputRef . value ) messageInputRef . value . focus ( ) ; // Возвращаем фокус в поле ввода
2025-05-21 22:13:09 +07:00
} ;
const handleNewMessage = ( message ) => {
console . log ( '[ChatView] Получено новое сообщение:' , message ) ;
// Убедимся, что сообщение для текущего открытого диалога
if ( message . conversationId === conversationId . value ) {
messages . value . push ( message ) ;
scrollToBottom ( ) ;
// Если это сообщение от другого пользователя, и мы в фокусе, отмечаем как прочитанное
if ( message . sender ? . _id !== currentUser . value ? . _id && document . hasFocus ( ) ) {
markMessagesAsReadOnServer ( ) ;
}
}
sendingMessage . value = false ;
} ;
const handleMessageDeletedBySocket = ( data ) => {
console . log ( '[ChatView] Получено событие удаления сообщения от сокета:' , data ) ;
const { messageId , conversationId : convId } = data ;
if ( convId === conversationId . value ) {
messages . value = messages . value . filter ( msg => msg . _id !== messageId ) ;
// Дополнительно можно обновить UI, если это необходимо
// Например, если удаленное сообщение было последним, или для обновления счетчиков
}
} ;
2025-05-22 00:28:14 +07:00
// --- НОВЫЕ ФУНКЦИИ И ЛОГИКА ДЛЯ РЕДАКТИРОВАНИЯ ---
const startEditMessage = ( messageId ) => {
const messageToEdit = messages . value . find ( msg => msg . _id === messageId ) ;
if ( messageToEdit && messageToEdit . sender ? . _id === currentUser . value ? . _id ) {
editingMessageId . value = messageId ;
originalMessageText . value = messageToEdit . text ;
newMessageText . value = messageToEdit . text ;
contextMenu . value . visible = false ;
if ( messageInputRef . value ) messageInputRef . value . focus ( ) ;
console . log ( '[ChatView] Начало редактирования сообщения:' , messageId ) ;
}
} ;
const cancelEditMessage = ( ) => {
editingMessageId . value = null ;
newMessageText . value = '' ;
originalMessageText . value = '' ;
console . log ( '[ChatView] Редактирование отменено' ) ;
} ;
const saveEditedMessage = async ( ) => {
if ( ! editingMessageId . value || ! newMessageText . value . trim ( ) || savingEdit . value ) return ;
const newText = newMessageText . value . trim ( ) ;
if ( newText === originalMessageText . value ) {
// Текст не изменился, просто отменяем редактирование
cancelEditMessage ( ) ;
return ;
}
savingEdit . value = true ;
error . value = '' ;
try {
console . log ( ` [ChatView] Сохранение отредактированного сообщения ${ editingMessageId . value } с текстом: " ${ newText } " ` ) ;
// API запрос на сервер для обновления сообщения
const updatedMessage = await api . editMessage ( conversationId . value , editingMessageId . value , newText ) ;
// Обновляем сообщение в локальном массиве messages.value
// Сервер должен вернуть обновленное сообщение, включая isEdited и editedAt
const index = messages . value . findIndex ( msg => msg . _id === editingMessageId . value ) ;
if ( index !== - 1 ) {
messages . value [ index ] = { ... messages . value [ index ] , ... updatedMessage . data } ;
console . log ( '[ChatView] Сообщение локально обновлено:' , messages . value [ index ] ) ;
}
cancelEditMessage ( ) ; // Сбрасываем состояние редактирования
// Фокус вернется в sendMessage или останется, если пользователь кликнул куда-то еще
} catch ( err ) {
console . error ( '[ChatView] Ошибка при сохранении отредактированного сообщения:' , err ) ;
error . value = err . response ? . data ? . message || 'Н е удалось сохранить изменения.' ;
} finally {
savingEdit . value = false ;
}
} ;
// Обработчик события отредактированного сообщения от сокета
const handleMessageEditedBySocket = ( { message : editedMessage , conversationId : convId } ) => {
2025-05-22 00:44:28 +07:00
console . log ( '[ChatView] Получено событие messageEdited от сокета. Данные:' , JSON . stringify ( editedMessage ) , 'Для диалога:' , convId ) ; // Более детальный лог
2025-05-22 00:28:14 +07:00
if ( convId === conversationId . value ) {
const index = messages . value . findIndex ( msg => msg . _id === editedMessage . _id ) ;
if ( index !== - 1 ) {
2025-05-22 00:44:28 +07:00
console . log ( ` [ChatView] Найдено сообщение для обновления по ID ${ editedMessage . _id } по индексу ${ index } . Старые данные: ` , JSON . stringify ( messages . value [ index ] ) ) ;
2025-05-22 00:28:14 +07:00
// Обновляем существующее сообщение новыми данными
2025-05-22 00:44:28 +07:00
// Убедитесь, что editedMessage от сервера содержит поле isEdited: true и, возможно, editedAt
2025-05-22 00:28:14 +07:00
messages . value [ index ] = { ... messages . value [ index ] , ... editedMessage } ;
2025-05-22 00:44:28 +07:00
console . log ( '[ChatView] Сообщение обновлено через сокет. Новые данные:' , JSON . stringify ( messages . value [ index ] ) ) ;
2025-05-22 00:28:14 +07:00
} else {
2025-05-22 00:44:28 +07:00
console . warn ( '[ChatView] Получено отредактированное сообщение, которое не найдено локально:' , editedMessage . _id , 'Данные:' , JSON . stringify ( editedMessage ) ) ;
// Если сообщение по какой-то причине отсутствует, но должно быть (например, из-за задержки синхронизации),
// можно рассмотреть е г о добавление, но это требует осторожности.
// messages.value.push(editedMessage);
// messages.value.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt)); // Поддерживать порядок
2025-05-22 00:28:14 +07:00
// scrollToBottom();
}
2025-05-22 00:44:28 +07:00
} else {
console . log ( ` [ChatView] Событие messageEdited для другого диалога ( ${ convId } ), текущий открытый: ${ conversationId . value } ` ) ;
2025-05-22 00:28:14 +07:00
}
} ;
// --- КОНЕЦ ЛОГИКИ РЕДАКТИРОВАНИЯ ---
2025-05-21 22:13:09 +07:00
const confirmDeleteMessage = ( messageId ) => {
// Логика подтверждения удаления (например, показать модальное окно)
// А затем, если пользователь подтвердил, вызвать deleteMessage
console . log ( 'Confirm delete messageId:' , messageId ) ;
// Пока просто вызываем deleteMessage напрямую для примера
deleteMessage ( messageId ) ;
} ;
const deleteMessage = async ( messageId ) => {
try {
// Оптимистичное удаление из UI
messages . value = messages . value . filter ( msg => msg . _id !== messageId ) ;
// Отправка запроса на сервер
// ВАЖНО: Убедитесь, что conversationId.value передается в api.deleteMessage
await api . deleteMessage ( conversationId . value , messageId ) ;
console . log ( ` [ChatView] Сообщение ${ messageId } удалено локально и на сервере. ` ) ;
// Сокет событие messageDeleted должно прийти от сервера и подтвердить удаление
// или обработать е г о для других клиентов. Если вы не хотите дублирования,
// можно не удалять из messages.value здесь, а дождаться события от сокета.
// Однако, оптимистичное удаление улучшает UX.
// Закрыть контекстное меню, если оно было открыто для этого сообщения
if ( contextMenu . value && contextMenu . value . messageId === messageId ) {
contextMenu . value . visible = false ;
}
} catch ( err ) {
console . error ( ` [ChatView] Ошибка удаления сообщения ${ messageId } : ` , err ) ;
error . value = err . response ? . data ? . message || 'Н е удалось удалить сообщение.' ;
// Возможно, стоит вернуть сообщение в список, если удаление на сервере не удалось
// fetchInitialMessages(); // или более тонкое обновление
}
} ;
const handleMessagesReadByOther = ( { conversationId : readConversationId , readerId } ) => {
if ( readConversationId === conversationId . value ) {
console . log ( ` [ChatView] Сообщения в диалоге ${ readConversationId } прочитаны пользователем ${ readerId } ` ) ;
messages . value = messages . value . map ( msg => {
if ( msg . sender ? . _id === currentUser . value ? . _id ) {
const updatedReadBy = msg . readBy ? [ ... msg . readBy ] : [ ] ;
if ( ! updatedReadBy . includes ( readerId ) ) {
updatedReadBy . push ( readerId ) ;
}
return { ... msg , readBy : updatedReadBy } ;
}
return msg ;
} ) ;
}
} ;
const handleVisibilityChange = ( ) => {
if ( document . visibilityState === 'visible' ) {
markMessagesAsReadOnServer ( ) ;
}
} ;
watch ( newMessageText , ( newValue /*, oldValue */ ) => { // oldValue больше не используется напрямую для этой логики
if ( ! socket || ! isAuthenticated . value || ! conversationData . value || ! currentUser . value ) return ;
const otherParticipant = getOtherParticipant ( ) ;
if ( ! otherParticipant ? . _id ) {
console . warn ( "[ChatView] Н е удалось определить собеседника для событий печати." ) ;
return ;
}
const receiverId = otherParticipant . _id ;
const payload = {
conversationId : conversationId . value ,
receiverId : receiverId ,
senderId : currentUser . value . _id
} ;
clearTimeout ( stopTypingEmitTimer ) ;
if ( newValue . trim ( ) . length > 0 ) {
if ( ! userHasSentTypingEvent . value ) {
2025-05-22 00:28:14 +07:00
if ( socket . connected ) {
2025-05-21 22:13:09 +07:00
socket . emit ( 'typing' , payload ) ;
userHasSentTypingEvent . value = true ;
console . log ( '[ChatView] Emit typing (new burst)' ) ;
}
}
stopTypingEmitTimer = setTimeout ( ( ) => {
2025-05-22 00:28:14 +07:00
if ( socket . connected ) {
2025-05-21 22:13:09 +07:00
socket . emit ( 'stopTyping' , payload ) ;
2025-05-22 00:28:14 +07:00
userHasSentTypingEvent . value = false ;
2025-05-21 22:13:09 +07:00
console . log ( '[ChatView] Emit stopTyping (timeout)' ) ;
}
} , TYPING _TIMER _LENGTH ) ;
} else {
if ( userHasSentTypingEvent . value ) {
2025-05-22 00:28:14 +07:00
if ( socket . connected ) {
2025-05-21 22:13:09 +07:00
socket . emit ( 'stopTyping' , payload ) ;
userHasSentTypingEvent . value = false ;
console . log ( '[ChatView] Emit stopTyping (cleared input)' ) ;
}
}
}
} ) ;
onMounted ( async ( ) => {
isTouchDevice . value = ( 'ontouchstart' in window ) || ( navigator . maxTouchPoints > 0 ) ;
if ( ! isAuthenticated . value ) {
// router.push('/login'); // Оставил закомментированным, т.к. в оригинале так
return ;
}
await fetchInitialMessages ( ) ;
socket = getSocket ( ) ;
if ( ! socket ) {
socket = connectSocket ( ) ;
}
if ( socket ) {
socket . on ( 'getMessage' , handleNewMessage ) ;
socket . on ( 'messagesRead' , handleMessagesReadByOther ) ;
socket . on ( 'userTyping' , ( { conversationId : incomingConvId , senderId } ) => {
const otherP = getOtherParticipant ( ) ;
if ( incomingConvId === conversationId . value && otherP && senderId === otherP . _id ) {
otherUserIsTyping . value = true ;
}
} ) ;
socket . on ( 'userStopTyping' , ( { conversationId : incomingConvId , senderId } ) => {
const otherP = getOtherParticipant ( ) ;
if ( incomingConvId === conversationId . value && otherP && senderId === otherP . _id ) {
otherUserIsTyping . value = false ;
}
} ) ;
socket . on ( 'messageDeleted' , handleMessageDeletedBySocket ) ;
2025-05-22 00:28:14 +07:00
socket . on ( 'messageEdited' , handleMessageEditedBySocket ) ; // <--- Подписываемся на событие редактирования
2025-05-21 22:13:09 +07:00
if ( conversationId . value ) {
socket . emit ( 'joinConversationRoom' , conversationId . value ) ;
}
markMessagesAsReadOnServer ( ) ;
document . addEventListener ( 'visibilitychange' , handleVisibilityChange ) ;
window . addEventListener ( 'focus' , markMessagesAsReadOnServer ) ;
document . addEventListener ( 'click' , handleClickOutsideContextMenu , true ) ;
}
} ) ;
onUnmounted ( ( ) => {
if ( socket ) {
socket . off ( 'getMessage' , handleNewMessage ) ;
socket . off ( 'messagesRead' , handleMessagesReadByOther ) ;
socket . off ( 'userTyping' ) ;
socket . off ( 'userStopTyping' ) ;
socket . off ( 'messageDeleted' , handleMessageDeletedBySocket ) ;
2025-05-22 00:28:14 +07:00
socket . off ( 'messageEdited' , handleMessageEditedBySocket ) ; // <--- Отписываемся
2025-05-21 22:13:09 +07:00
if ( conversationId . value ) {
socket . emit ( 'leaveConversationRoom' , conversationId . value ) ;
}
document . removeEventListener ( 'visibilitychange' , handleVisibilityChange ) ;
window . removeEventListener ( 'focus' , markMessagesAsReadOnServer ) ;
document . removeEventListener ( 'click' , handleClickOutsideContextMenu , true ) ; // Ensure this is removed
}
clearTimeout ( stopTypingEmitTimer ) ;
} ) ;
const formatMessageTimestamp = ( timestamp ) => {
if ( ! timestamp ) return '' ;
return new Date ( timestamp ) . toLocaleTimeString ( [ ] , { hour : '2-digit' , minute : '2-digit' } ) ;
} ;
const getMessageStatusIcon = ( message ) => {
if ( message . isSending ) {
return 'bi bi-clock' ; // Отправка (часы)
}
const otherParticipant = getOtherParticipant ( ) ;
if ( otherParticipant && message . readBy && message . readBy . includes ( otherParticipant . _id ) ) {
return 'bi bi-check2-all' ; // Прочитано (две галочки)
}
return 'bi bi-check2' ; // Доставлено (одна галочка)
} ;
const handleNativeContextMenu = ( event , message ) => {
if ( message . sender ? . _id === currentUser . value ? . _id ) {
event . preventDefault ( ) ; // Prevent native context menu for own messages to show custom one
}
// For other users' messages, allow native context menu (e.g., for copying text).
} ;
const handleClickOutsideContextMenu = ( event ) => {
if ( contextMenu . value . visible && contextMenuRef . value && ! contextMenuRef . value . contains ( event . target ) ) {
contextMenu . value . visible = false ;
}
} ;
< / script >
< style scoped >
. chat - view - container {
height : calc ( 100 vh - 56 px - 3 rem ) ; /* Примерная высота: 100% высоты вьюпорта минус шапка и отступы */
/* Стилизуй это под свою шапку и футер */
}
. messages - area {
display : flex ;
flex - direction : column ;
gap : 0.75 rem ;
}
. message - bubble {
padding : 0.5 rem 1 rem ;
border - radius : 1.25 rem ;
max - width : 70 % ;
word - wrap : break - word ;
}
. message - bubble . sent {
background - color : # 0 d6efd ; /* Bootstrap primary */
color : white ;
margin - left : auto ; /* Прижимаем к правому краю */
border - bottom - right - radius : 0.5 rem ;
}
. message - bubble . received {
background - color : # e9ecef ; /* Bootstrap light grey */
color : # 212529 ;
margin - right : auto ; /* Прижимаем к левому краю */
border - bottom - left - radius : 0.5 rem ;
}
. message - content p {
margin - bottom : 0.25 rem ;
}
. message - timestamp {
font - size : 0.75 em ;
display : block ;
text - align : right ;
}
. message - bubble . received . message - timestamp {
text - align : left ;
color : # 6 c757d ! important ; /* Немного темнее для с е р о г о фона */
}
. typing - indicator {
font - size : 0.85 em ;
min - height : 1.2 em ; /* Резервируем место, чтобы чат не прыгал */
}
. date - separator {
font - size : 0.85 em ;
}
. date - separator . badge {
padding : 0.4 em 0.8 em ;
font - weight : 500 ;
}
/* Новые стили для удаления сообщений и контекстного меню */
. message - item - wrapper {
position : relative ;
display : flex ;
align - items : flex - start ; /* Выравнивание по верху для bubble и кнопки */
/* Добавляем небольшой отступ снизу для ясности, если нужно */
/* margin-bottom: 4px; */
}
. message - item - wrapper . sent - wrapper {
justify - content : flex - end ;
}
. message - item - wrapper . sent - wrapper . delete - message - btn {
margin - left : 8 px ;
order : 1 ; /* Кнопка справа от сообщения */
}
2025-05-22 00:28:14 +07:00
. message - item - wrapper . sent - wrapper . edit - message - btn {
margin - left : 4 px ; /* Меньше отступ чем у кнопки удаления */
order : 0 ; /* Кнопка редактирования левее кнопки удаления */
}
2025-05-21 22:13:09 +07:00
. message - item - wrapper . received - wrapper {
justify - content : flex - start ;
}
. delete - message - btn {
align - self : center ; /* Вертикальное центрирование кнопки относительно bubble */
opacity : 0.6 ; /* Немного прозрачнее по умолчанию */
transition : opacity 0.2 s ease - in - out ;
padding : 0.2 rem 0.4 rem ; /* Меньше padding */
font - size : 0.8 rem ; /* Меньше шрифт иконки */
line - height : 1 ; /* Убрать лишнюю высоту */
background : transparent ;
border : none ;
color : # dc3545 ; /* Цвет иконки (Bootstrap danger) */
}
. delete - message - btn : hover ,
. delete - message - btn : focus {
opacity : 1 ;
background : rgba ( 220 , 53 , 69 , 0.1 ) ; /* Легкий фон при наведении/фокусе */
}
. delete - message - btn i {
vertical - align : middle ;
}
2025-05-22 00:28:14 +07:00
. edit - message - btn {
align - self : center ;
opacity : 0.6 ;
transition : opacity 0.2 s ease - in - out ;
padding : 0.2 rem 0.4 rem ;
font - size : 0.8 rem ;
line - height : 1 ;
background : transparent ;
border : none ;
color : # 0 dcaf0 ; /* Bootstrap info color */
}
. edit - message - btn : hover ,
. edit - message - btn : focus {
opacity : 1 ;
background : rgba ( 13 , 202 , 240 , 0.1 ) ;
}
. edit - message - btn i {
vertical - align : middle ;
}
2025-05-21 22:13:09 +07:00
/* Стили для контекстного меню */
. context - menu {
position : fixed ;
background - color : white ;
border : 1 px solid # e0e0e0 ;
box - shadow : 0 2 px 10 px rgba ( 0 , 0 , 0 , 0.15 ) ;
border - radius : 6 px ;
z - index : 1050 ; /* Выше большинства элементов Bootstrap */
padding : 0 ;
min - width : 180 px ;
}
. context - menu ul {
list - style : none ;
padding : 5 px 0 ; /* Adjusted padding for ul */
margin : 0 ;
}
. context - menu li {
padding : 10 px 15 px ;
cursor : pointer ;
font - size : 0.95 rem ;
color : # 333 ;
display : flex ;
align - items : center ;
}
. context - menu li : hover {
background - color : # f5f5f5 ;
color : # 000 ;
}
. context - menu li i . bi - trash {
margin - right : 10 px ;
color : # dc3545 ; /* Bootstrap danger color */
font - size : 1.1 em ; /* Slightly larger icon */
}
. context - menu li : hover i . bi - trash {
color : # a71d2a ; /* Darker danger color on hover */
}
2025-05-22 00:28:14 +07:00
. context - menu li i . bi - pencil - fill {
margin - right : 10 px ;
color : # 0 d6efd ; /* Bootstrap primary color */
font - size : 1.0 em ;
}
. context - menu li : hover i . bi - pencil - fill {
color : # 0 a58ca ; /* Darker primary color on hover */
}
2025-05-21 22:13:09 +07:00
/* ... other existing styles ... */
< / style >