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:
@@ -10,16 +10,21 @@ use Illuminate\Support\Facades\Storage;
|
|||||||
|
|
||||||
class ChatController extends Controller
|
class ChatController extends Controller
|
||||||
{
|
{
|
||||||
// Lista de usuarios con quienes puedo chatear
|
// Lista de amigos con quienes puedo chatear
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
$users = User::where('id', '!=', auth()->id())->get();
|
$me = auth()->user();
|
||||||
return view('chat.index', compact('users'));
|
$friends = User::whereIn('id', $me->friendIds())->orderBy('name')->get();
|
||||||
|
return view('chat.index', compact('friends'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mensajes entre yo y otro usuario
|
// Mensajes entre yo y otro usuario
|
||||||
public function show(User $user)
|
public function show(User $user)
|
||||||
{
|
{
|
||||||
|
$me = auth()->user();
|
||||||
|
$isFriend = $me->isFriendWith($user);
|
||||||
|
$friendship = $me->friendshipWith($user);
|
||||||
|
|
||||||
$messages = Message::where(function ($q) use ($user) {
|
$messages = Message::where(function ($q) use ($user) {
|
||||||
$q->where('user_id', auth()->id())
|
$q->where('user_id', auth()->id())
|
||||||
->where('receiver_id', $user->id);
|
->where('receiver_id', $user->id);
|
||||||
@@ -32,13 +37,15 @@ class ChatController extends Controller
|
|||||||
->orderBy('created_at', 'asc')
|
->orderBy('created_at', 'asc')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
// marcar como leídos
|
// marcar como leídos solo si son amigos
|
||||||
|
if ($isFriend) {
|
||||||
Message::where('user_id', $user->id)
|
Message::where('user_id', $user->id)
|
||||||
->where('receiver_id', auth()->id())
|
->where('receiver_id', $me->id)
|
||||||
->whereNull('read_at')
|
->whereNull('read_at')
|
||||||
->update(['read_at' => now()]);
|
->update(['read_at' => now()]);
|
||||||
|
}
|
||||||
|
|
||||||
return view('chat.show', compact('user', 'messages'));
|
return view('chat.show', compact('user', 'messages', 'isFriend', 'friendship'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enviar mensaje
|
// Enviar mensaje
|
||||||
@@ -50,6 +57,9 @@ class ChatController extends Controller
|
|||||||
'attachment' => 'nullable|file|max:20480',
|
'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;
|
$attachment = null;
|
||||||
$attachmentType = null;
|
$attachmentType = null;
|
||||||
|
|
||||||
|
|||||||
@@ -9,20 +9,28 @@
|
|||||||
<h5 class="mb-0"><i class="fas fa-comments"></i> Chats</h5>
|
<h5 class="mb-0"><i class="fas fa-comments"></i> Chats</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-0" style="overflow-y: auto;">
|
<div class="card-body p-0" style="overflow-y: auto;">
|
||||||
@foreach($users as $user)
|
@forelse($friends as $friend)
|
||||||
<a href="{{ route('chat.show', $user) }}" class="text-decoration-none">
|
<a href="{{ route('chat.show', $friend) }}" class="text-decoration-none">
|
||||||
<div class="d-flex align-items-center p-3 border-bottom chat-user-item">
|
<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"
|
class="rounded-circle mr-3"
|
||||||
width="45" height="45"
|
width="45" height="45"
|
||||||
style="object-fit:cover">
|
style="object-fit:cover">
|
||||||
<div>
|
<div>
|
||||||
<div class="font-weight-bold text-dark">{{ $user->name }}</div>
|
<div class="font-weight-bold text-dark">{{ $friend->name }}</div>
|
||||||
<small class="text-muted">{{ $user->getRoleNames()->first() }}</small>
|
<small class="text-muted">{{ $friend->getRoleNames()->first() }}</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-footer p-2">
|
<div class="card-footer p-2">
|
||||||
|
@if($isFriend)
|
||||||
<form id="chat-form" enctype="multipart/form-data">
|
<form id="chat-form" enctype="multipart/form-data">
|
||||||
@csrf
|
@csrf
|
||||||
<input type="hidden" name="receiver_id" value="{{ $user->id }}">
|
<input type="hidden" name="receiver_id" value="{{ $user->id }}">
|
||||||
@@ -68,6 +69,33 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="attachment-preview" class="mt-1 text-muted small"></div>
|
<div id="attachment-preview" class="mt-1 text-muted small"></div>
|
||||||
</form>
|
</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>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,24 +28,25 @@
|
|||||||
<ul class="navbar-nav ml-auto">
|
<ul class="navbar-nav ml-auto">
|
||||||
|
|
||||||
<!-- Nav Item - Search (XS) -->
|
<!-- Nav Item - Search (XS) -->
|
||||||
<li class="nav-item dropdown no-arrow d-sm-none">
|
<li class="nav-item no-arrow d-sm-none position-relative">
|
||||||
<a class="nav-link dropdown-toggle" href="#" id="searchDropdown" role="button"
|
<a class="nav-link" href="#" id="mobile-search-toggle" role="button">
|
||||||
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
|
||||||
<i class="fas fa-search fa-fw"></i>
|
<i class="fas fa-search fa-fw"></i>
|
||||||
</a>
|
</a>
|
||||||
<div class="dropdown-menu dropdown-menu-right p-3 shadow animated--grow-in"
|
<div id="mobile-search-panel" class="d-none"
|
||||||
aria-labelledby="searchDropdown">
|
style="position:fixed; top:56px; left:0; right:0; z-index:9998;
|
||||||
<form class="form-inline mr-auto w-100 navbar-search">
|
background:#fff; padding:.75rem 1rem; box-shadow:0 4px 12px rgba(0,0,0,.15)">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input type="text" class="form-control bg-light border-0 small"
|
<input type="text" id="global-search-mobile"
|
||||||
placeholder="Buscar..." aria-label="Search">
|
class="form-control bg-light border-0"
|
||||||
|
placeholder="Buscar personas..." autocomplete="off">
|
||||||
<div class="input-group-append">
|
<div class="input-group-append">
|
||||||
<button class="btn btn-primary" type="button">
|
<span class="btn btn-primary"><i class="fas fa-search fa-sm"></i></span>
|
||||||
<i class="fas fa-search fa-sm"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
<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>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
@@ -306,31 +307,28 @@ function markAllNotifsRead() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Búsqueda de usuarios ─────────────────────────────────
|
// ── Búsqueda de usuarios ─────────────────────────────────
|
||||||
(function () {
|
function friendshipLabel(f) {
|
||||||
const input = document.getElementById('global-search');
|
|
||||||
const results = document.getElementById('search-results');
|
|
||||||
if (!input) return;
|
|
||||||
|
|
||||||
let timer;
|
|
||||||
|
|
||||||
function friendshipLabel(f) {
|
|
||||||
if (f.status === 'accepted') return { label: 'Amigos', cls: 'btn-outline-secondary', icon: 'fa-user-check' };
|
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: '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' };
|
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' };
|
return { label: 'Agregar', cls: 'btn-primary', icon: 'fa-user-plus' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initSearchWidget(inputEl, resultsEl) {
|
||||||
|
if (!inputEl || !resultsEl) return;
|
||||||
|
let timer;
|
||||||
|
|
||||||
function renderResults(users) {
|
function renderResults(users) {
|
||||||
if (!users.length) {
|
if (!users.length) {
|
||||||
results.innerHTML = `
|
resultsEl.innerHTML = `
|
||||||
<div class="px-4 py-3 text-center">
|
<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>
|
<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>
|
<span class="text-muted small">Sin resultados</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
results.classList.remove('d-none');
|
resultsEl.classList.remove('d-none');
|
||||||
return;
|
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>` +
|
`<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) => {
|
users.map((u, i) => {
|
||||||
const btn = friendshipLabel(u.friendship);
|
const btn = friendshipLabel(u.friendship);
|
||||||
@@ -364,47 +362,71 @@ function markAllNotifsRead() {
|
|||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
results.classList.remove('d-none');
|
resultsEl.classList.remove('d-none');
|
||||||
|
|
||||||
// Bind toggle buttons
|
resultsEl.querySelectorAll('.friend-toggle-btn').forEach(btn => {
|
||||||
results.querySelectorAll('.friend-toggle-btn').forEach(btn => {
|
|
||||||
btn.addEventListener('click', function (e) {
|
btn.addEventListener('click', function (e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const userId = this.dataset.userId;
|
axios.post(`/friends/${this.dataset.userId}/toggle`)
|
||||||
axios.post(`/friends/${userId}/toggle`)
|
.then(() => doSearch(inputEl.value));
|
||||||
.then(res => {
|
|
||||||
// Re-search to refresh states
|
|
||||||
doSearch(input.value);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function doSearch(q) {
|
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));
|
axios.get('/search', { params: { q } }).then(res => renderResults(res.data));
|
||||||
}
|
}
|
||||||
|
|
||||||
input.addEventListener('input', function () {
|
inputEl.addEventListener('input', function () {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
timer = setTimeout(() => doSearch(this.value.trim()), 300);
|
timer = setTimeout(() => doSearch(this.value.trim()), 300);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Evita que el dropdown se cierre al hacer mousedown dentro de él
|
inputEl.addEventListener('focus', function () {
|
||||||
// (mousedown dispara antes que click, y blur del input cierra antes de registrar la acción)
|
if (this.value.trim().length >= 2) resultsEl.classList.remove('d-none');
|
||||||
let interactingWithResults = false;
|
});
|
||||||
results.addEventListener('mousedown', () => { interactingWithResults = true; });
|
|
||||||
document.addEventListener('mouseup', () => { interactingWithResults = false; });
|
|
||||||
|
|
||||||
|
// 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) {
|
document.addEventListener('click', function (e) {
|
||||||
if (interactingWithResults) return;
|
if (busy) return;
|
||||||
if (!input.contains(e.target) && !results.contains(e.target)) {
|
if (!inputEl.contains(e.target) && !resultsEl.contains(e.target)) {
|
||||||
results.classList.add('d-none');
|
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 () {
|
initSearchWidget(mInput, mResult);
|
||||||
if (this.value.trim().length >= 2) results.classList.remove('d-none');
|
|
||||||
});
|
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user