Merge branch 'fix-estilos-vite': chat multimedia
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,29 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Events;
|
|
||||||
|
|
||||||
use App\Models\Message;
|
|
||||||
use Illuminate\Broadcasting\PrivateChannel;
|
|
||||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
|
||||||
|
|
||||||
class ChatMessageSent implements ShouldBroadcastNow
|
|
||||||
{
|
|
||||||
public $message;
|
|
||||||
|
|
||||||
public function __construct(Message $message)
|
|
||||||
{
|
|
||||||
$this->message = $message->load('user');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function broadcastOn()
|
|
||||||
{
|
|
||||||
return new PrivateChannel(
|
|
||||||
'chat.' . $this->message->conversation_id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function broadcastAs()
|
|
||||||
{
|
|
||||||
return 'ChatMessageSent';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+21
-12
@@ -2,30 +2,39 @@
|
|||||||
|
|
||||||
namespace App\Events;
|
namespace App\Events;
|
||||||
|
|
||||||
|
use App\Models\Message;
|
||||||
use Illuminate\Broadcasting\Channel;
|
use Illuminate\Broadcasting\Channel;
|
||||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||||
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||||
use Illuminate\Foundation\Events\Dispatchable;
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
|
||||||
|
|
||||||
class MessageSent implements ShouldBroadcast
|
class MessageSent implements ShouldBroadcastNow
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
public $message;
|
public function __construct(public Message $message){}
|
||||||
|
|
||||||
public function __construct($message)
|
public function broadcastOn(): array
|
||||||
{
|
{
|
||||||
$this->message = $message;
|
return [
|
||||||
|
new Channel('chat.' . $this->message->receiver_id),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function broadcastOn()
|
public function broadcastWith(): array
|
||||||
{
|
{
|
||||||
return new Channel('chat');
|
return [
|
||||||
}
|
'id' => $this->message->id,
|
||||||
|
'body' => $this->message->body,
|
||||||
public function broadcastAs()
|
'attachment' => $this->message->attachment,
|
||||||
{
|
'attachment_type' => $this->message->attachment_type,
|
||||||
return 'MessageSent';
|
'created_at' => $this->message->created_at->format('H:i'),
|
||||||
|
'sender' => [
|
||||||
|
'id' => $this->message->sender->id,
|
||||||
|
'name' => $this->message->sender->name,
|
||||||
|
'avatar' => $this->message->sender->profile_photo_url ?? null,
|
||||||
|
],
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Events;
|
|
||||||
|
|
||||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
|
||||||
use Illuminate\Broadcasting\PrivateChannel;
|
|
||||||
|
|
||||||
class UserStoppedTyping implements ShouldBroadcast
|
|
||||||
{
|
|
||||||
public $conversationId;
|
|
||||||
public $user;
|
|
||||||
|
|
||||||
public function __construct($conversationId, $user)
|
|
||||||
{
|
|
||||||
$this->conversationId = $conversationId;
|
|
||||||
$this->user = $user;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function broadcastOn()
|
|
||||||
{
|
|
||||||
return new PrivateChannel('chat.' . $this->conversationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function broadcastAs()
|
|
||||||
{
|
|
||||||
return 'UserStoppedTyping';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Events;
|
|
||||||
|
|
||||||
use Illuminate\Broadcasting\PrivateChannel;
|
|
||||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
|
||||||
|
|
||||||
class UserTyping implements ShouldBroadcast
|
|
||||||
{
|
|
||||||
public $conversationId;
|
|
||||||
public $user;
|
|
||||||
|
|
||||||
public function __construct($conversationId, $user)
|
|
||||||
{
|
|
||||||
$this->conversationId = $conversationId;
|
|
||||||
$this->user = $user;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function broadcastOn()
|
|
||||||
{
|
|
||||||
return new PrivateChannel('chat.' . $this->conversationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function broadcastAs()
|
|
||||||
{
|
|
||||||
return 'UserTyping';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,74 +2,82 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Events\ChatMessageSent;
|
use App\Events\MessageSent;
|
||||||
use App\Models\Conversation;
|
|
||||||
use App\Models\Message;
|
use App\Models\Message;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
class ChatController extends Controller
|
class ChatController extends Controller
|
||||||
{
|
{
|
||||||
|
// Lista de usuarios con quienes puedo chatear
|
||||||
public function send(Request $request)
|
public function index()
|
||||||
{
|
|
||||||
$request->validate([
|
|
||||||
'conversation_id' => 'required|exists:conversations,id',
|
|
||||||
'message' => 'required|string'
|
|
||||||
]);
|
|
||||||
|
|
||||||
$message = Message::create([
|
|
||||||
'conversation_id' => $request->conversation_id,
|
|
||||||
'user_id' => auth()->id(),
|
|
||||||
'message' => $request->message
|
|
||||||
]);
|
|
||||||
|
|
||||||
// cargar usuario para que venga en el JSON
|
|
||||||
$message->load('user');
|
|
||||||
|
|
||||||
// enviar a los demás usuarios por websocket
|
|
||||||
broadcast(new ChatMessageSent($message))->toOthers();
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'message' => $message
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function start(User $user)
|
|
||||||
{
|
{
|
||||||
$authUser = auth()->user();
|
$users = User::where('id', '!=', auth()->id())->get();
|
||||||
|
return view('chat.index', compact('users'));
|
||||||
|
}
|
||||||
|
|
||||||
// buscar conversación existente
|
// Mensajes entre yo y otro usuario
|
||||||
$conversation = Conversation::whereHas('users', function ($q) use ($authUser) {
|
public function show(User $user)
|
||||||
$q->where('user_id', $authUser->id);
|
{
|
||||||
})
|
$messages = Message::where(function ($q) use ($user) {
|
||||||
->whereHas('users', function ($q) use ($user) {
|
$q->where('user_id', auth()->id())
|
||||||
$q->where('user_id', $user->id);
|
->where('receiver_id', $user->id);
|
||||||
})
|
})
|
||||||
->first();
|
->orWhere(function ($q) use ($user) {
|
||||||
|
$q->where('user_id', $user->id)
|
||||||
|
->where('receiver_id', auth()->id());
|
||||||
|
})
|
||||||
|
->with('sender')
|
||||||
|
->orderBy('created_at', 'asc')
|
||||||
|
->get();
|
||||||
|
|
||||||
// si no existe → crear
|
// marcar como leídos
|
||||||
if (!$conversation) {
|
Message::where('user_id', $user->id)
|
||||||
$conversation = Conversation::create();
|
->where('receiver_id', auth()->id())
|
||||||
|
->whereNull('read_at')
|
||||||
|
->update(['read_at' => now()]);
|
||||||
|
|
||||||
$conversation->users()->attach([
|
return view('chat.show', compact('user', 'messages'));
|
||||||
$authUser->id,
|
}
|
||||||
$user->id
|
|
||||||
]);
|
// Enviar mensaje
|
||||||
|
public function send(Request $request)
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'receiver_id' => 'required|exists:users,id',
|
||||||
|
'body' => 'nullable|string',
|
||||||
|
'attachment' => 'nullable|file|max:20480',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$attachment = null;
|
||||||
|
$attachmentType = null;
|
||||||
|
|
||||||
|
if ($request->hasFile('attachment')) {
|
||||||
|
$file = $request->file('attachment');
|
||||||
|
$attachment = $file->store('chat', 'public');
|
||||||
|
$mime = $file->getMimeType();
|
||||||
|
|
||||||
|
if (str_starts_with($mime, 'image')) $attachmentType = 'image';
|
||||||
|
elseif (str_starts_with($mime, 'video')) $attachmentType = 'video';
|
||||||
|
elseif (str_starts_with($mime, 'audio')) $attachmentType = 'audio';
|
||||||
|
else $attachmentType = 'file';
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json($conversation);
|
$message = Message::create([
|
||||||
}
|
'user_id' => auth()->id(),
|
||||||
|
'receiver_id' => $request->receiver_id,
|
||||||
|
'body' => $request->body,
|
||||||
|
'attachment' => $attachment,
|
||||||
|
'attachment_type' => $attachmentType,
|
||||||
|
]);
|
||||||
|
|
||||||
public function show($id)
|
$message->load('sender');
|
||||||
{
|
|
||||||
$conversation = Conversation::with([
|
|
||||||
'messages' => function ($query) {
|
|
||||||
$query->orderBy('created_at', 'asc');
|
|
||||||
},
|
|
||||||
'messages.user'
|
|
||||||
])->findOrFail($id);
|
|
||||||
|
|
||||||
return view('chat.show', compact('conversation'));
|
broadcast(new MessageSent($message))->toOthers();
|
||||||
|
|
||||||
|
$message->created_at = $message->created_at->format('H:i');
|
||||||
|
|
||||||
|
return response()->json($message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class Friendship extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'sender_id',
|
||||||
|
'receiver_id',
|
||||||
|
'status',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function sender()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'sender_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function receiver()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'receiver_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
-4
@@ -7,13 +7,25 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
class Message extends Model
|
class Message extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'conversation_id',
|
|
||||||
'user_id',
|
'user_id',
|
||||||
'message'
|
'receiver_id',
|
||||||
|
'body',
|
||||||
|
'attachment',
|
||||||
|
'attachment_type',
|
||||||
|
'read_at',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function user()
|
protected $casts = [
|
||||||
|
'read_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function sender()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class);
|
return $this->belongsTo(User::class, 'user_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function receiver()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'receiver_id');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-3
@@ -13,9 +13,12 @@ return new class extends Migration
|
|||||||
{
|
{
|
||||||
Schema::create('messages', function (Blueprint $table) {
|
Schema::create('messages', function (Blueprint $table) {
|
||||||
$table->id();
|
$table->id();
|
||||||
$table->foreignId('conversation_id')->constrained()->cascadeOnDelete();
|
$table->foreignId('user_id')->constrained()->onDelete('cascade');
|
||||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
$table->foreignId('receiver_id')->constrained('users')->onDelete('cascade');
|
||||||
$table->text('message');
|
$table->text('body')->nullable(); // nullable porque puede ser solo archivo
|
||||||
|
$table->string('attachment')->nullable(); // ruta del archivo
|
||||||
|
$table->string('attachment_type')->nullable(); // 'image', 'audio', 'video'
|
||||||
|
$table->timestamp('read_at')->nullable();
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('friendships', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('sender_id')->constrained('users')->onDelete('cascade');
|
||||||
|
$table->foreignId('receiver_id')->constrained('users')->onDelete('cascade');
|
||||||
|
$table->enum('status', ['pending', 'accepted', 'rejected', 'blocked'])->default('pending');
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('friendships');
|
||||||
|
}
|
||||||
|
};
|
||||||
Generated
+26
-1457
File diff suppressed because it is too large
Load Diff
+3
-10
@@ -1,24 +1,17 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://www.schemastore.org/package.json",
|
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"dev": "vite",
|
||||||
"dev": "vite",
|
"build": "vite build",
|
||||||
"dev:all": "concurrently \"php artisan serve\" \"php artisan queue:work\" \"php artisan reverb:start\" \"npm run dev\""
|
"dev:all": "concurrently \"php artisan serve\" \"php artisan queue:work\" \"php artisan reverb:start\" \"npm run dev\""
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/forms": "^0.5.7",
|
|
||||||
"@tailwindcss/typography": "^0.5.10",
|
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
|
||||||
"autoprefixer": "^10.4.16",
|
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"concurrently": "^9.0.1",
|
"concurrently": "^9.0.1",
|
||||||
"laravel-echo": "^2.3.0",
|
"laravel-echo": "^2.3.0",
|
||||||
"laravel-vite-plugin": "^2.0.0",
|
"laravel-vite-plugin": "^2.0.0",
|
||||||
"postcss": "^8.4.32",
|
|
||||||
"pusher-js": "^8.4.0",
|
"pusher-js": "^8.4.0",
|
||||||
"tailwindcss": "^3.4.0",
|
|
||||||
"vite": "^7.0.7"
|
"vite": "^7.0.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
[x-cloak] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card shadow" style="height:calc(100vh - 120px); display:flex; flex-direction:column;">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<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">
|
||||||
|
<div class="d-flex align-items-center p-3 border-bottom chat-user-item">
|
||||||
|
<img src="{{ $user->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>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@section('scripts')
|
||||||
|
<style>
|
||||||
|
#content { overflow: hidden !important; }
|
||||||
|
.container-fluid { height: calc(100vh - 120px); }
|
||||||
|
.sticky-footer { display: none !important; }
|
||||||
|
</style>
|
||||||
|
@endsection
|
||||||
+160
-212
@@ -1,231 +1,179 @@
|
|||||||
@extends('layouts.landing')
|
@extends('layouts.app')
|
||||||
|
|
||||||
@section('title',"Chat")
|
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
|
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<div class="card shadow mb-4">
|
<div class="card shadow mb-4">
|
||||||
|
<div class="card-header bg-primary text-white d-flex align-items-center justify-content-between">
|
||||||
<div class="card-header py-3">
|
<div class="d-flex align-items-center">
|
||||||
<h6 class="m-0 font-weight-bold text-primary">
|
<img src="{{ $user->profile_photo_url }}"
|
||||||
Mensajes
|
class="rounded-circle mr-2"
|
||||||
</h6>
|
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>
|
||||||
|
|
||||||
<div class="card-body">
|
<div class="card-body overflow-auto flex-grow-1 p-3" id="chat-messages">
|
||||||
|
@foreach($messages as $msg)
|
||||||
{{-- CONTENEDOR CHAT --}}
|
<div class="d-flex mb-2 {{ $msg->user_id === auth()->id() ? 'justify-content-end' : 'justify-content-start' }}">
|
||||||
<div id="chat-app"
|
<div class="px-3 py-2 rounded {{ $msg->user_id === auth()->id() ? 'bg-primary text-white' : 'bg-light' }}"
|
||||||
data-conversation-id="{{ $conversation->id }}">
|
style="max-width:65%">
|
||||||
|
@if($msg->body)
|
||||||
<div id="messages"
|
<div>{{ $msg->body }}</div>
|
||||||
style="height:70vh;overflow-y:auto;padding:10px;background:#f5f5f5;border-radius:10px;">
|
@endif
|
||||||
|
@if($msg->attachment)
|
||||||
@foreach($conversation->messages->sortBy('created_at') as $message)
|
@if($msg->attachment_type === 'image')
|
||||||
|
<img src="{{ Storage::url($msg->attachment) }}" class="img-fluid rounded mt-1">
|
||||||
<div class="message {{ $message->user_id == auth()->id() ? 'mine' : 'theirs' }}">
|
@elseif($msg->attachment_type === 'audio')
|
||||||
<div class="bubble">
|
<audio controls class="mt-1" style="max-width:250px">
|
||||||
<strong>{{ $message->user->name }}</strong><br>
|
<source src="{{ Storage::url($msg->attachment) }}">
|
||||||
{{ $message->message }}
|
</audio>
|
||||||
</div>
|
@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>
|
</div>
|
||||||
|
</div>
|
||||||
@endforeach
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="typingIndicator"
|
<div class="card-footer p-2">
|
||||||
style="font-size:12px;color:#888;padding:8px 5px;height:20px;">
|
<form id="chat-form" enctype="multipart/form-data">
|
||||||
</div>
|
@csrf
|
||||||
|
<input type="hidden" name="receiver_id" value="{{ $user->id }}">
|
||||||
{{-- INPUT --}}
|
<div class="input-group">
|
||||||
<div style="margin-top:10px;display:flex;gap:10px;">
|
<input type="text" name="body" id="chat-input"
|
||||||
<input id="messageInput"
|
class="form-control rounded-pill mr-2"
|
||||||
class="form-control"
|
placeholder="Escribe un mensaje..." autocomplete="off">
|
||||||
placeholder="Escribe mensaje...">
|
<label class="btn btn-light mr-1 mb-0" title="Adjuntar archivo">
|
||||||
|
<i class="fas fa-paperclip"></i>
|
||||||
<button onclick="sendMessage()"
|
<input type="file" name="attachment" id="attachment" hidden>
|
||||||
class="btn btn-primary">
|
</label>
|
||||||
Enviar
|
<button type="submit" class="btn btn-primary rounded-pill px-4">
|
||||||
|
<i class="fas fa-paper-plane"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="attachment-preview" class="mt-1 text-muted small"></div>
|
||||||
</div>
|
</form>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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(response.data, true);
|
||||||
|
scrollBottom();
|
||||||
|
form.reset();
|
||||||
|
document.getElementById('attachment-preview').textContent = '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildAttachmentHtml(msg, isMine) {
|
||||||
|
if (!msg.attachment) return '';
|
||||||
|
const url = '/storage/' + msg.attachment;
|
||||||
|
const linkClass = isMine ? 'text-white' : 'text-dark';
|
||||||
|
switch (msg.attachment_type) {
|
||||||
|
case 'image':
|
||||||
|
return `<img src="${url}" class="img-fluid rounded mt-1" style="max-width:240px; cursor:pointer"
|
||||||
|
onclick="window.open('${url}','_blank')">`;
|
||||||
|
case 'video':
|
||||||
|
return `<video controls class="mt-1 rounded" style="max-width:240px">
|
||||||
|
<source src="${url}">Tu navegador no soporta video.
|
||||||
|
</video>`;
|
||||||
|
case 'audio':
|
||||||
|
return `<audio controls class="mt-1" style="max-width:240px">
|
||||||
|
<source src="${url}">Tu navegador no soporta audio.
|
||||||
|
</audio>`;
|
||||||
|
default:
|
||||||
|
return `<a href="${url}" target="_blank" class="btn btn-sm ${isMine ? 'btn-light' : 'btn-secondary'} mt-1">
|
||||||
|
<i class="fas fa-file mr-1"></i>Ver archivo
|
||||||
|
</a>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendMessage(msg, isMine) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = `d-flex mb-2 ${isMine ? 'justify-content-end' : 'justify-content-start'}`;
|
||||||
|
|
||||||
|
const time = typeof msg.created_at === 'string' && 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>` : ''}
|
||||||
|
${buildAttachmentHtml(msg, isMine)}
|
||||||
|
<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
|
@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
|
|
||||||
|
|||||||
@@ -197,11 +197,11 @@
|
|||||||
<!-- Heading -->
|
<!-- Heading -->
|
||||||
<div class="sidebar-heading" style="color:white">Adicionales</div>
|
<div class="sidebar-heading" style="color:white">Adicionales</div>
|
||||||
|
|
||||||
{{-- <li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="chats.php">
|
<a class="nav-link" href="{{ route('chat.index') }}">
|
||||||
<i class="fas fa-fw fa-comments" style="color: #e8e8e8;"></i>
|
<i class="fas fa-fw fa-comments" style="color: #e8e8e8;"></i>
|
||||||
<span>Chats</span></a>
|
<span>Chats</span></a>
|
||||||
</li> --}}
|
</li>
|
||||||
|
|
||||||
|
|
||||||
@can('asistencia')
|
@can('asistencia')
|
||||||
|
|||||||
@@ -4,11 +4,7 @@
|
|||||||
<html lang="es">
|
<html lang="es">
|
||||||
<head>
|
<head>
|
||||||
@include('layouts._partials.head')
|
@include('layouts._partials.head')
|
||||||
{{-- @vite([ 'resources/js/app.js']) --}}
|
|
||||||
|
|
||||||
{{-- Livewire styles --}}
|
|
||||||
@livewireStyles
|
@livewireStyles
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body id="page-top" class="sidebar-toggled">
|
<body id="page-top" class="sidebar-toggled">
|
||||||
@@ -26,13 +22,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{{-- JS base --}}
|
{{-- JS base --}}
|
||||||
@include('layouts._partials.scripts')
|
@include('layouts._partials.scripts')
|
||||||
|
|
||||||
{{-- Livewire --}}
|
{{-- Livewire --}}
|
||||||
@livewireScripts
|
@livewireScripts
|
||||||
|
|
||||||
|
{{-- Vite (Echo/Reverb) --}}
|
||||||
|
@vite(['resources/js/app.js'])
|
||||||
|
|
||||||
{{-- scripts por vista --}}
|
{{-- scripts por vista --}}
|
||||||
@yield('scripts')
|
@yield('scripts')
|
||||||
|
|
||||||
|
|||||||
+3
-9
@@ -1,17 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Broadcast;
|
use Illuminate\Support\Facades\Broadcast;
|
||||||
use App\Models\Conversation;
|
|
||||||
|
|
||||||
Broadcast::channel('App.Models.User.{id}', function ($user, $id) {
|
Broadcast::channel('App.Models.User.{id}', function ($user, $id) {
|
||||||
return (int) $user->id === (int) $id;
|
return (int) $user->id === (int) $id;
|
||||||
});
|
});
|
||||||
|
|
||||||
Broadcast::channel('chat.{conversationId}', function ($user, $conversationId) {
|
Broadcast::channel('chat.{receiverId}', function ($user, $receiverId) {
|
||||||
|
return (int) $user->id === (int) $receiverId;
|
||||||
return Conversation::where('id', $conversationId)
|
|
||||||
->whereHas('users', function ($q) use ($user) {
|
|
||||||
$q->where('user_id', $user->id);
|
|
||||||
})
|
|
||||||
->exists();
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
+3
-6
@@ -52,12 +52,9 @@ Route::middleware(['auth:sanctum',config('jetstream.auth_session'),'verified',
|
|||||||
Route::resource('/carrera',CarreraController::class);
|
Route::resource('/carrera',CarreraController::class);
|
||||||
Route::resource('/code',CodeController::class);
|
Route::resource('/code',CodeController::class);
|
||||||
Route::resource('/concepto',ConceptoController::class);
|
Route::resource('/concepto',ConceptoController::class);
|
||||||
// Route::get('/chat/start/{user}',[ChatController::class, 'start']);
|
Route::get('/chat', [ChatController::class, 'index'])->name('chat.index');
|
||||||
// Route::get('/chat/{conversation}',[ChatController::class, 'show']);
|
Route::get('/chat/{user}', [ChatController::class, 'show'])->name('chat.show');
|
||||||
// Route::post('/chat/send',[ChatController::class,'send']);
|
Route::post('/chat/send', [ChatController::class, 'send'])->name('chat.send');Route::resource('/docente',DocenteController::class);
|
||||||
// Route::post('/chat/typing', function (Request $request) {broadcast(new \App\Events\UserTyping($request->conversation_id,auth()->user()))->toOthers();return response()->json();});
|
|
||||||
// Route::post('/chat/stop-typing', function (Request $request) {broadcast(new \App\Events\UserStoppedTyping($request->conversation_id,auth()->user()))->toOthers();return response()->json();});
|
|
||||||
Route::resource('/docente',DocenteController::class);
|
|
||||||
Route::resource('/documento',DocumentoController::class);
|
Route::resource('/documento',DocumentoController::class);
|
||||||
Route::get('/documento/ver/{id}', function ($id) { $doc = Documento::findOrFail($id); $alumno = auth()->user()->alumnos()->first(); if (!$alumno || $doc->alumno_id !== $alumno->id) { abort(403); } return response()->file( storage_path('app/public/'.$doc->archivo) ); });
|
Route::get('/documento/ver/{id}', function ($id) { $doc = Documento::findOrFail($id); $alumno = auth()->user()->alumnos()->first(); if (!$alumno || $doc->alumno_id !== $alumno->id) { abort(403); } return response()->file( storage_path('app/public/'.$doc->archivo) ); });
|
||||||
Route::get('/documento/ver/{id}', [DocumentoController::class, 'ver'])->name('documento.ver');
|
Route::get('/documento/ver/{id}', [DocumentoController::class, 'ver'])->name('documento.ver');
|
||||||
|
|||||||
Reference in New Issue
Block a user