feat: chat en tiempo real bidireccional funcionando con Reverb
This commit is contained in:
+143
-212
@@ -1,231 +1,162 @@
|
||||
@extends('layouts.landing')
|
||||
|
||||
@section('title',"Chat")
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('content')
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="card shadow mb-4">
|
||||
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
Mensajes
|
||||
</h6>
|
||||
<div class="card-header bg-primary text-white d-flex align-items-center justify-content-between">
|
||||
<div class="d-flex align-items-center">
|
||||
<img src="{{ $user->profile_photo_url }}"
|
||||
class="rounded-circle mr-2"
|
||||
width="38" height="38"
|
||||
style="object-fit:cover">
|
||||
<strong>{{ $user->name }}</strong>
|
||||
</div>
|
||||
<a href="{{ route('chat.index') }}" class="btn btn-sm btn-light shadow-sm">
|
||||
<i class="fas fa-arrow-left fa-sm"></i> Regresar
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
|
||||
{{-- CONTENEDOR CHAT --}}
|
||||
<div id="chat-app"
|
||||
data-conversation-id="{{ $conversation->id }}">
|
||||
|
||||
<div id="messages"
|
||||
style="height:70vh;overflow-y:auto;padding:10px;background:#f5f5f5;border-radius:10px;">
|
||||
|
||||
@foreach($conversation->messages->sortBy('created_at') as $message)
|
||||
|
||||
<div class="message {{ $message->user_id == auth()->id() ? 'mine' : 'theirs' }}">
|
||||
<div class="bubble">
|
||||
<strong>{{ $message->user->name }}</strong><br>
|
||||
{{ $message->message }}
|
||||
</div>
|
||||
<div class="card-body overflow-auto flex-grow-1 p-3" id="chat-messages">
|
||||
@foreach($messages as $msg)
|
||||
<div class="d-flex mb-2 {{ $msg->user_id === auth()->id() ? 'justify-content-end' : 'justify-content-start' }}">
|
||||
<div class="px-3 py-2 rounded {{ $msg->user_id === auth()->id() ? 'bg-primary text-white' : 'bg-light' }}"
|
||||
style="max-width:65%">
|
||||
@if($msg->body)
|
||||
<div>{{ $msg->body }}</div>
|
||||
@endif
|
||||
@if($msg->attachment)
|
||||
@if($msg->attachment_type === 'image')
|
||||
<img src="{{ Storage::url($msg->attachment) }}" class="img-fluid rounded mt-1">
|
||||
@elseif($msg->attachment_type === 'audio')
|
||||
<audio controls class="mt-1" style="max-width:250px">
|
||||
<source src="{{ Storage::url($msg->attachment) }}">
|
||||
</audio>
|
||||
@elseif($msg->attachment_type === 'video')
|
||||
<video controls class="mt-1 rounded" style="max-width:250px">
|
||||
<source src="{{ Storage::url($msg->attachment) }}">
|
||||
</video>
|
||||
@else
|
||||
<a href="{{ Storage::url($msg->attachment) }}" target="_blank" class="btn btn-sm btn-light mt-1">
|
||||
<i class="fas fa-file"></i> Ver archivo
|
||||
</a>
|
||||
@endif
|
||||
@endif
|
||||
<div class="text-right mt-1" style="font-size:0.7rem; opacity:0.7">
|
||||
{{ $msg->created_at->format('H:i') }}
|
||||
</div>
|
||||
|
||||
@endforeach
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div id="typingIndicator"
|
||||
style="font-size:12px;color:#888;padding:8px 5px;height:20px;">
|
||||
</div>
|
||||
|
||||
{{-- INPUT --}}
|
||||
<div style="margin-top:10px;display:flex;gap:10px;">
|
||||
<input id="messageInput"
|
||||
class="form-control"
|
||||
placeholder="Escribe mensaje...">
|
||||
|
||||
<button onclick="sendMessage()"
|
||||
class="btn btn-primary">
|
||||
Enviar
|
||||
<div class="card-footer p-2">
|
||||
<form id="chat-form" enctype="multipart/form-data">
|
||||
@csrf
|
||||
<input type="hidden" name="receiver_id" value="{{ $user->id }}">
|
||||
<div class="input-group">
|
||||
<input type="text" name="body" id="chat-input"
|
||||
class="form-control rounded-pill mr-2"
|
||||
placeholder="Escribe un mensaje..." autocomplete="off">
|
||||
<label class="btn btn-light mr-1 mb-0" title="Adjuntar archivo">
|
||||
<i class="fas fa-paperclip"></i>
|
||||
<input type="file" name="attachment" id="attachment" hidden>
|
||||
</label>
|
||||
<button type="submit" class="btn btn-primary rounded-pill px-4">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div id="attachment-preview" class="mt-1 text-muted small"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@endsection
|
||||
|
||||
@section('scripts')
|
||||
|
||||
<style>
|
||||
.sticky-footer { display: none !important; }
|
||||
.card { height: calc(100vh - 160px); display: flex; flex-direction: column; }
|
||||
#chat-messages { flex: 1; overflow-y: auto; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const receiverId = {{ $user->id }};
|
||||
const authId = {{ auth()->id() }};
|
||||
const chatMessages = document.getElementById('chat-messages');
|
||||
|
||||
// scroll al fondo
|
||||
function scrollBottom() {
|
||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||
}
|
||||
scrollBottom();
|
||||
|
||||
// preview archivo
|
||||
document.getElementById('attachment').addEventListener('change', function() {
|
||||
const name = this.files[0]?.name ?? '';
|
||||
document.getElementById('attachment-preview').textContent = name ? '📎 ' + name : '';
|
||||
});
|
||||
|
||||
// enviar mensaje
|
||||
document.getElementById('chat-form').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
const form = this;
|
||||
const data = new FormData(form);
|
||||
|
||||
axios.post('{{ route("chat.send") }}', data, {
|
||||
headers: {
|
||||
'X-Socket-ID': window.Echo.socketId()
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
appendMessage({
|
||||
body: response.data.body,
|
||||
created_at: response.data.created_at,
|
||||
}, true);
|
||||
scrollBottom();
|
||||
form.reset();
|
||||
document.getElementById('attachment-preview').textContent = '';
|
||||
});
|
||||
});
|
||||
|
||||
function appendMessage(msg, isMine) {
|
||||
const div = document.createElement('div');
|
||||
div.className = `d-flex mb-2 ${isMine ? 'justify-content-end' : 'justify-content-start'}`;
|
||||
|
||||
// formatear hora
|
||||
const time = msg.created_at.length > 5
|
||||
? new Date(msg.created_at).toLocaleTimeString('es-MX', {hour:'2-digit', minute:'2-digit'})
|
||||
: msg.created_at;
|
||||
|
||||
div.innerHTML = `
|
||||
<div class="px-3 py-2 rounded ${isMine ? 'bg-primary text-white' : 'bg-light'}" style="max-width:65%">
|
||||
${msg.body ? `<div>${msg.body}</div>` : ''}
|
||||
<div class="text-right mt-1" style="font-size:0.7rem; opacity:0.7">${time}</div>
|
||||
</div>`;
|
||||
chatMessages.appendChild(div);
|
||||
}
|
||||
|
||||
// esperar a que Echo esté disponible
|
||||
function waitForEcho(callback) {
|
||||
if (window.Echo) {
|
||||
callback();
|
||||
} else {
|
||||
setTimeout(() => waitForEcho(callback), 100);
|
||||
}
|
||||
}
|
||||
|
||||
waitForEcho(() => {
|
||||
window.Echo.channel(`chat.${authId}`)
|
||||
.listen('MessageSent', (e) => {
|
||||
if (e.sender.id !== receiverId) return;
|
||||
appendMessage(e, false);
|
||||
scrollBottom();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@endsection
|
||||
|
||||
|
||||
@push('scripts')
|
||||
|
||||
<script>
|
||||
window.USER_ID = {{ auth()->id() }};
|
||||
</script>
|
||||
|
||||
<script>
|
||||
let conversationId;
|
||||
let typingTimeout;
|
||||
let typingActive = false;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
|
||||
const chatApp = document.getElementById('chat-app');
|
||||
const messagesDiv = document.getElementById('messages');
|
||||
const input = document.getElementById('messageInput');
|
||||
|
||||
if (!chatApp || !messagesDiv) {
|
||||
console.error('Chat DOM no encontrado');
|
||||
return;
|
||||
}
|
||||
|
||||
conversationId = chatApp.dataset.conversationId;
|
||||
|
||||
console.log('Chat conectado a:', conversationId);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| ESCUCHAR "USUARIO ESCRIBIENDO"
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
window.Echo.private(`chat.${conversationId}`)
|
||||
.listen('.UserTyping', (e) => {
|
||||
|
||||
if (e.user.id == window.USER_ID) return;
|
||||
|
||||
document.getElementById('typingIndicator').innerText =
|
||||
e.user.name + ' está escribiendo...';
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| ESCUCHAR "USUARIO DEJÓ DE ESCRIBIR"
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
window.Echo.private(`chat.${conversationId}`)
|
||||
.listen('.UserStoppedTyping', (e) => {
|
||||
|
||||
if (e.user.id == window.USER_ID) return;
|
||||
|
||||
document.getElementById('typingIndicator').innerText = '';
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| ESCUCHAR MENSAJES
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
window.Echo.private(`chat.${conversationId}`)
|
||||
.listen('.ChatMessageSent', (e) => {
|
||||
|
||||
if (e.message.user_id == window.USER_ID) return;
|
||||
|
||||
appendMessage(e.message);
|
||||
|
||||
document.getElementById('typingIndicator').innerText = '';
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| DETECTAR ESCRITURA
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
|
||||
if (!typingActive) {
|
||||
|
||||
typingActive = true;
|
||||
|
||||
axios.post('/chat/typing', {
|
||||
conversation_id: conversationId
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
clearTimeout(typingTimeout);
|
||||
|
||||
typingTimeout = setTimeout(() => {
|
||||
|
||||
typingActive = false;
|
||||
|
||||
axios.post('/chat/stop-typing', {
|
||||
conversation_id: conversationId
|
||||
});
|
||||
|
||||
}, 700);
|
||||
|
||||
});
|
||||
|
||||
scrollChat();
|
||||
});
|
||||
|
||||
|
||||
function sendMessage() {
|
||||
|
||||
let input = document.getElementById('messageInput');
|
||||
|
||||
if (!input.value.trim()) return;
|
||||
|
||||
typingActive = false;
|
||||
|
||||
axios.post('/chat/stop-typing', {
|
||||
conversation_id: conversationId
|
||||
});
|
||||
|
||||
axios.post('/chat/send', {
|
||||
conversation_id: conversationId,
|
||||
message: input.value
|
||||
})
|
||||
.then(response => {
|
||||
|
||||
appendMessage(response.data.message);
|
||||
|
||||
document.getElementById('typingIndicator').innerText = '';
|
||||
|
||||
});
|
||||
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
|
||||
function appendMessage(message) {
|
||||
|
||||
const messagesDiv = document.getElementById('messages');
|
||||
|
||||
let mine = message.user_id == window.USER_ID;
|
||||
|
||||
let div = document.createElement('div');
|
||||
|
||||
div.className = 'message ' + (mine ? 'mine' : 'theirs');
|
||||
|
||||
div.innerHTML = `
|
||||
<div class="bubble">
|
||||
<strong>${message.user.name}</strong><br>
|
||||
${message.message}
|
||||
</div>
|
||||
`;
|
||||
|
||||
messagesDiv.appendChild(div);
|
||||
|
||||
scrollChat();
|
||||
}
|
||||
|
||||
|
||||
function scrollChat() {
|
||||
|
||||
const container = document.getElementById('messages');
|
||||
|
||||
container.scrollTop = container.scrollHeight;
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
@endpush
|
||||
|
||||
Reference in New Issue
Block a user