fix/feat: búsqueda móvil, chat solo amigos y bloqueo si se eliminó amistad

- Header: búsqueda móvil con panel deslizable (fixed bajo la topbar), comparte la lógica AJAX con desktop via initSearchWidget()
- ChatController: index muestra solo amigos; show pasa isFriend y friendship a la vista; send valida amistad con abort_unless
- chat/index: muestra amigos con @forelse y mensaje vacío si no tiene
- chat/show: si no son amigos muestra banner de bloqueo con botón contextual (enviar solicitud / cancelar / aceptar según estado)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-28 17:40:16 -06:00
parent e0205ec725
commit f81237b23c
4 changed files with 151 additions and 83 deletions
+19 -9
View File
@@ -10,16 +10,21 @@ use Illuminate\Support\Facades\Storage;
class ChatController extends Controller
{
// Lista de usuarios con quienes puedo chatear
// Lista de amigos con quienes puedo chatear
public function index()
{
$users = User::where('id', '!=', auth()->id())->get();
return view('chat.index', compact('users'));
$me = auth()->user();
$friends = User::whereIn('id', $me->friendIds())->orderBy('name')->get();
return view('chat.index', compact('friends'));
}
// Mensajes entre yo y otro usuario
public function show(User $user)
{
$me = auth()->user();
$isFriend = $me->isFriendWith($user);
$friendship = $me->friendshipWith($user);
$messages = Message::where(function ($q) use ($user) {
$q->where('user_id', auth()->id())
->where('receiver_id', $user->id);
@@ -32,13 +37,15 @@ class ChatController extends Controller
->orderBy('created_at', 'asc')
->get();
// marcar como leídos
Message::where('user_id', $user->id)
->where('receiver_id', auth()->id())
->whereNull('read_at')
->update(['read_at' => now()]);
// marcar como leídos solo si son amigos
if ($isFriend) {
Message::where('user_id', $user->id)
->where('receiver_id', $me->id)
->whereNull('read_at')
->update(['read_at' => now()]);
}
return view('chat.show', compact('user', 'messages'));
return view('chat.show', compact('user', 'messages', 'isFriend', 'friendship'));
}
// Enviar mensaje
@@ -50,6 +57,9 @@ class ChatController extends Controller
'attachment' => 'nullable|file|max:20480',
]);
$receiver = User::findOrFail($request->receiver_id);
abort_unless(auth()->user()->isFriendWith($receiver), 403, 'No puedes enviar mensajes a esta persona.');
$attachment = null;
$attachmentType = null;
+14 -6
View File
@@ -9,20 +9,28 @@
<h5 class="mb-0"><i class="fas fa-comments"></i> Chats</h5>
</div>
<div class="card-body p-0" style="overflow-y: auto;">
@foreach($users as $user)
<a href="{{ route('chat.show', $user) }}" class="text-decoration-none">
@forelse($friends as $friend)
<a href="{{ route('chat.show', $friend) }}" class="text-decoration-none">
<div class="d-flex align-items-center p-3 border-bottom chat-user-item">
<img src="{{ $user->profile_photo_url }}"
<img src="{{ $friend->profile_photo_url }}"
class="rounded-circle mr-3"
width="45" height="45"
style="object-fit:cover">
<div>
<div class="font-weight-bold text-dark">{{ $user->name }}</div>
<small class="text-muted">{{ $user->getRoleNames()->first() }}</small>
<div class="font-weight-bold text-dark">{{ $friend->name }}</div>
<small class="text-muted">{{ $friend->getRoleNames()->first() }}</small>
</div>
</div>
</a>
@endforeach
@empty
<div class="text-center text-muted py-5">
<i class="fas fa-user-friends fa-3x mb-3 d-block"></i>
<p class="mb-2">Aún no tienes amigos para chatear.</p>
<a href="{{ route('feed') }}" class="btn btn-primary btn-sm">
<i class="fas fa-search mr-1"></i> Buscar personas
</a>
</div>
@endforelse
</div>
</div>
</div>
+44 -16
View File
@@ -51,23 +51,51 @@
</div>
<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>
@if($isFriend)
<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 id="attachment-preview" class="mt-1 text-muted small"></div>
</form>
@else
<div class="text-center py-3">
<p class="text-muted small mb-2">
<i class="fas fa-lock mr-1"></i>
Ya no son amigos. No puedes enviar mensajes.
</p>
@if($friendship && $friendship->status === 'pending' && $friendship->sender_id === auth()->id())
<span class="btn btn-sm btn-outline-secondary disabled">
<i class="fas fa-user-clock mr-1"></i> Solicitud enviada
</span>
@elseif($friendship && $friendship->status === 'pending' && $friendship->receiver_id === auth()->id())
<form action="{{ route('friendships.accept', $friendship) }}" method="POST" class="d-inline">
@csrf
<button class="btn btn-sm btn-success">
<i class="fas fa-check mr-1"></i> Aceptar solicitud
</button>
</form>
@else
<form action="{{ route('friendships.send', $user) }}" method="POST" class="d-inline">
@csrf
<button class="btn btn-sm btn-primary">
<i class="fas fa-user-plus mr-1"></i> Enviar solicitud de amistad
</button>
</form>
@endif
</div>
<div id="attachment-preview" class="mt-1 text-muted small"></div>
</form>
@endif
</div>
</div>
</div>
@@ -28,24 +28,25 @@
<ul class="navbar-nav ml-auto">
<!-- Nav Item - Search (XS) -->
<li class="nav-item dropdown no-arrow d-sm-none">
<a class="nav-link dropdown-toggle" href="#" id="searchDropdown" role="button"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<li class="nav-item no-arrow d-sm-none position-relative">
<a class="nav-link" href="#" id="mobile-search-toggle" role="button">
<i class="fas fa-search fa-fw"></i>
</a>
<div class="dropdown-menu dropdown-menu-right p-3 shadow animated--grow-in"
aria-labelledby="searchDropdown">
<form class="form-inline mr-auto w-100 navbar-search">
<div class="input-group">
<input type="text" class="form-control bg-light border-0 small"
placeholder="Buscar..." aria-label="Search">
<div class="input-group-append">
<button class="btn btn-primary" type="button">
<i class="fas fa-search fa-sm"></i>
</button>
</div>
<div id="mobile-search-panel" class="d-none"
style="position:fixed; top:56px; left:0; right:0; z-index:9998;
background:#fff; padding:.75rem 1rem; box-shadow:0 4px 12px rgba(0,0,0,.15)">
<div class="input-group">
<input type="text" id="global-search-mobile"
class="form-control bg-light border-0"
placeholder="Buscar personas..." autocomplete="off">
<div class="input-group-append">
<span class="btn btn-primary"><i class="fas fa-search fa-sm"></i></span>
</div>
</form>
</div>
<div id="search-results-mobile" class="bg-white mt-1 d-none"
style="border:1px solid rgba(0,0,0,.1); border-radius:8px;
box-shadow:0 4px 16px rgba(0,0,0,.1);
max-height:280px; overflow-y:auto"></div>
</div>
</li>
@@ -306,34 +307,31 @@ function markAllNotifsRead() {
}
// ── Búsqueda de usuarios ─────────────────────────────────
(function () {
const input = document.getElementById('global-search');
const results = document.getElementById('search-results');
if (!input) return;
function friendshipLabel(f) {
if (f.status === 'accepted') return { label: 'Amigos', cls: 'btn-outline-secondary', icon: 'fa-user-check' };
if (f.status === 'pending' && !f.is_receiver) return { label: 'Solicitud enviada', cls: 'btn-outline-secondary', icon: 'fa-user-clock' };
if (f.status === 'pending' && f.is_receiver) return { label: 'Aceptar', cls: 'btn-success', icon: 'fa-user-plus' };
return { label: 'Agregar', cls: 'btn-primary', icon: 'fa-user-plus' };
}
function initSearchWidget(inputEl, resultsEl) {
if (!inputEl || !resultsEl) return;
let timer;
function friendshipLabel(f) {
if (f.status === 'accepted') return { label: 'Amigos', cls: 'btn-outline-secondary', icon: 'fa-user-check' };
if (f.status === 'pending' && !f.is_receiver) return { label: 'Solicitud enviada', cls: 'btn-outline-secondary', icon: 'fa-user-clock' };
if (f.status === 'pending' && f.is_receiver) return { label: 'Aceptar', cls: 'btn-success', icon: 'fa-user-plus' };
return { label: 'Agregar', cls: 'btn-primary', icon: 'fa-user-plus' };
}
function renderResults(users) {
if (!users.length) {
results.innerHTML = `
resultsEl.innerHTML = `
<div class="px-4 py-3 text-center">
<i class="fas fa-search text-muted mb-2 d-block" style="font-size:1.4rem"></i>
<span class="text-muted small">Sin resultados</span>
</div>`;
results.classList.remove('d-none');
resultsEl.classList.remove('d-none');
return;
}
results.innerHTML =
resultsEl.innerHTML =
`<div class="px-3 pt-2 pb-1" style="font-size:.7rem;font-weight:600;letter-spacing:.05em;color:#aaa;text-transform:uppercase">Personas</div>` +
users.map((u, i) => {
const btn = friendshipLabel(u.friendship);
const btn = friendshipLabel(u.friendship);
const isLast = i === users.length - 1;
return `
<div class="d-flex align-items-center px-3 py-2 search-result-item"
@@ -364,47 +362,71 @@ function markAllNotifsRead() {
</div>
</div>`;
}).join('');
results.classList.remove('d-none');
resultsEl.classList.remove('d-none');
// Bind toggle buttons
results.querySelectorAll('.friend-toggle-btn').forEach(btn => {
resultsEl.querySelectorAll('.friend-toggle-btn').forEach(btn => {
btn.addEventListener('click', function (e) {
e.stopPropagation();
const userId = this.dataset.userId;
axios.post(`/friends/${userId}/toggle`)
.then(res => {
// Re-search to refresh states
doSearch(input.value);
});
axios.post(`/friends/${this.dataset.userId}/toggle`)
.then(() => doSearch(inputEl.value));
});
});
}
function doSearch(q) {
if (q.length < 2) { results.classList.add('d-none'); return; }
if (q.length < 2) { resultsEl.classList.add('d-none'); return; }
axios.get('/search', { params: { q } }).then(res => renderResults(res.data));
}
input.addEventListener('input', function () {
inputEl.addEventListener('input', function () {
clearTimeout(timer);
timer = setTimeout(() => doSearch(this.value.trim()), 300);
});
// Evita que el dropdown se cierre al hacer mousedown dentro de él
// (mousedown dispara antes que click, y blur del input cierra antes de registrar la acción)
let interactingWithResults = false;
results.addEventListener('mousedown', () => { interactingWithResults = true; });
document.addEventListener('mouseup', () => { interactingWithResults = false; });
inputEl.addEventListener('focus', function () {
if (this.value.trim().length >= 2) resultsEl.classList.remove('d-none');
});
// Evita cierre al interactuar con el dropdown
let busy = false;
resultsEl.addEventListener('mousedown', () => { busy = true; });
document.addEventListener('mouseup', () => { busy = false; });
document.addEventListener('click', function (e) {
if (interactingWithResults) return;
if (!input.contains(e.target) && !results.contains(e.target)) {
results.classList.add('d-none');
if (busy) return;
if (!inputEl.contains(e.target) && !resultsEl.contains(e.target)) {
resultsEl.classList.add('d-none');
}
});
}
// Desktop
initSearchWidget(
document.getElementById('global-search'),
document.getElementById('search-results')
);
// Móvil: toggle del panel y widget de búsqueda
(function () {
const toggle = document.getElementById('mobile-search-toggle');
const panel = document.getElementById('mobile-search-panel');
const mInput = document.getElementById('global-search-mobile');
const mResult = document.getElementById('search-results-mobile');
if (!toggle) return;
toggle.addEventListener('click', function (e) {
e.preventDefault();
const isHidden = panel.classList.contains('d-none');
panel.classList.toggle('d-none', !isHidden);
if (!isHidden) { mInput.focus(); }
});
// Cerrar panel al tocar fuera
document.addEventListener('click', function (e) {
if (!panel.contains(e.target) && !toggle.contains(e.target)) {
panel.classList.add('d-none');
}
});
input.addEventListener('focus', function () {
if (this.value.trim().length >= 2) results.classList.remove('d-none');
});
initSearchWidget(mInput, mResult);
})();
</script>