Snapshot of main at 9f39a6e1e (2026-11-02)
This commit is contained in:
commit
6ccf1792d7
2580 files changed
+1040202
No files matched your search
@@ -0,0 +1,416 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\WireChat\Chat;
|
||||
|
||||
use App\Http\Livewire\WireChat\Chats\Chats;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Namu\WireChat\Events\MessageCreated;
|
||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||
use Namu\WireChat\Livewire\Chat\Chat as BaseChat;
|
||||
|
||||
class Chat extends BaseChat
|
||||
{
|
||||
|
||||
// This magic accessor will be called for $this->auth
|
||||
public function getAuthProperty()
|
||||
{
|
||||
// Use the active guard from session first, then fallback to checking all guards
|
||||
$activeGuard = session('active_guard', 'web');
|
||||
$user = Auth::guard($activeGuard)->user();
|
||||
|
||||
if ($user) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
// Fallback to checking all guards if active guard doesn't have a user
|
||||
return Auth::guard('admin')->user()
|
||||
?: Auth::guard('bank')->user()
|
||||
?: Auth::guard('organization')->user()
|
||||
?: Auth::guard('web')->user();
|
||||
}
|
||||
|
||||
/**
|
||||
* WireChat overrides Livewire's _finishUpload() to append uploads, but its copy predates
|
||||
* the signed upload references of Livewire 3.8: it used "token:/path" as the file path.
|
||||
* Those files pointed nowhere (sending in the same request as the upload failed with
|
||||
* "Unable to retrieve the file_size ... livewire-tmp/{token}:...") and the signature was
|
||||
* never checked. Verify and strip it like Livewire does, keep WireChat's append behaviour.
|
||||
*/
|
||||
public function _finishUpload($name, $tmpPath, $isMultiple, $append = false)
|
||||
{
|
||||
$this->cleanupOldUploads();
|
||||
|
||||
$files = collect($tmpPath)->map(function ($signedPath) {
|
||||
$path = TemporaryUploadedFile::extractPathFromSignedPath((string) $signedPath);
|
||||
abort_if($path === false, 403, 'Invalid upload reference.');
|
||||
|
||||
return TemporaryUploadedFile::createFromLivewire($path);
|
||||
})->toArray();
|
||||
$this->dispatch('upload:finished', name: $name, tmpFilenames: collect($files)->map->getFilename()->toArray())->self();
|
||||
|
||||
// If the property is an array, APPEND the upload to the array.
|
||||
$currentValue = $this->getPropertyValue($name);
|
||||
$files = is_array($currentValue) ? array_merge($currentValue, $files) : $files[0];
|
||||
|
||||
app('livewire')->updateProperty($this, $name, $files);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to check authorization and show error toast if unauthorized
|
||||
*/
|
||||
private function checkAuthorization(): bool
|
||||
{
|
||||
$profile = getActiveProfile();
|
||||
if (!$profile) {
|
||||
$this->dispatch('wirechat-toast', type: 'error', message: __('Unauthorized access'));
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
|
||||
return true;
|
||||
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
|
||||
$this->dispatch('wirechat-toast', type: 'error', message: __('Unauthorized access'));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Override sendMessage to use the correct sendable_id.
|
||||
*/
|
||||
public function sendMessage()
|
||||
{
|
||||
abort_unless($this->auth(), 401);
|
||||
|
||||
// CRITICAL SECURITY: Validate authorization before sending message
|
||||
$profile = getActiveProfile();
|
||||
if (!$profile) {
|
||||
abort(403, 'No active profile');
|
||||
}
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
|
||||
|
||||
// rate limit
|
||||
$this->rateLimit();
|
||||
|
||||
$attachments = array_merge($this->media, $this->files);
|
||||
|
||||
if (empty($attachments)) {
|
||||
$this->validate(['body' => 'required|string']);
|
||||
}
|
||||
|
||||
if (count($attachments) != 0) {
|
||||
$maxUploads = config('wirechat.attachments.max_uploads');
|
||||
$fileMimes = implode(',', config('wirechat.attachments.file_mimes'));
|
||||
$fileMaxUploadSize = (int) config('wirechat.attachments.file_max_upload_size');
|
||||
$mediaMimes = implode(',', config('wirechat.attachments.media_mimes'));
|
||||
$mediaMaxUploadSize = (int) config('wirechat.attachments.media_max_upload_size');
|
||||
|
||||
try {
|
||||
$this->validate([
|
||||
'files' => "array|max:$maxUploads|nullable",
|
||||
'files.*' => "max:$fileMaxUploadSize|mimes:$fileMimes",
|
||||
'media' => "array|max:$maxUploads|nullable",
|
||||
'media.*' => "max:$mediaMaxUploadSize|mimes:$mediaMimes",
|
||||
]);
|
||||
} catch (\Illuminate\Validation\ValidationException $th) {
|
||||
$errors = $th->errors();
|
||||
foreach ($errors as $field => $messages) {
|
||||
$this->addError($field, $messages[0]);
|
||||
}
|
||||
return $this->dispatch('wirechat-toast', type: 'warning', message: $th->getMessage());
|
||||
}
|
||||
|
||||
$createdMessages = [];
|
||||
foreach ($attachments as $key => $attachment) {
|
||||
$path = $attachment->store(
|
||||
\Namu\WireChat\Facades\WireChat::storageFolder(),
|
||||
\Namu\WireChat\Facades\WireChat::storageDisk()
|
||||
);
|
||||
|
||||
$replyId = ($key === 0 && $this->replyMessage) ? $this->replyMessage->id : null;
|
||||
|
||||
$message = \Namu\WireChat\Models\Message::create([
|
||||
'reply_id' => $replyId,
|
||||
'conversation_id' => $this->conversation->id,
|
||||
'sendable_type' => $this->auth->getMorphClass(), // Polymorphic sender type
|
||||
'sendable_id' => $this->auth->id, // Polymorphic sender ID
|
||||
'type' => \Namu\WireChat\Enums\MessageType::ATTACHMENT,
|
||||
]);
|
||||
|
||||
$attachment = $message->attachment()->create([
|
||||
'file_path' => $path,
|
||||
'file_name' => basename($path),
|
||||
'original_name' => $attachment->getClientOriginalName(),
|
||||
'mime_type' => $attachment->getMimeType(),
|
||||
'url' => \Storage::disk(\Namu\WireChat\Facades\WireChat::storageDisk())->url($path),
|
||||
]);
|
||||
|
||||
$createdMessages[] = $message;
|
||||
$this->conversation->updated_at = now();
|
||||
$this->conversation->save();
|
||||
$this->dispatch('refresh')->to(Chats::class);
|
||||
$this->dispatch('refresh')->to(\App\Http\Livewire\UnreadIndicator::class);
|
||||
$this->dispatchMessageCreatedEvent($message);
|
||||
}
|
||||
|
||||
foreach ($createdMessages as $key => $message) {
|
||||
|
||||
$this->pushMessage($message);
|
||||
}
|
||||
$this->dispatch('scroll-bottom');
|
||||
}
|
||||
|
||||
if ($this->body != null) {
|
||||
$createdMessage = \Namu\WireChat\Models\Message::create([
|
||||
'reply_id' => $this->replyMessage?->id,
|
||||
'conversation_id' => $this->conversation->id,
|
||||
'sendable_type' => $this->auth->getMorphClass(), // Polymorphic sender type
|
||||
'sendable_id' => $this->auth->id, // Polymorphic sender ID
|
||||
'body' => $this->body,
|
||||
'type' => \Namu\WireChat\Enums\MessageType::TEXT,
|
||||
]);
|
||||
|
||||
$this->pushMessage($createdMessage);
|
||||
$this->conversation->touch();
|
||||
$this->dispatchMessageCreatedEvent($createdMessage);
|
||||
$this->dispatch('refresh')->to(Chats::class);
|
||||
$this->dispatch('refreshList')->to(Chats::class);
|
||||
|
||||
// Also dispatch to UnreadIndicator to update the red dot
|
||||
$this->dispatch('refresh')->to(\App\Http\Livewire\UnreadIndicator::class);
|
||||
|
||||
}
|
||||
|
||||
$this->reset('media', 'files', 'body');
|
||||
$this->dispatch('scroll-bottom');
|
||||
|
||||
$this->removeReply();
|
||||
}
|
||||
|
||||
|
||||
private function pushMessage(\Namu\WireChat\Models\Message $message)
|
||||
{
|
||||
$groupKey = $this->messageGroupKey($message);
|
||||
|
||||
// Ensure loadedMessages is a Collection
|
||||
$this->loadedMessages = collect($this->loadedMessages);
|
||||
|
||||
// Use tap to create a new group if it doesn’t exist, then push the message
|
||||
$this->loadedMessages->put($groupKey, $this->loadedMessages->get($groupKey, collect())->push($message));
|
||||
}
|
||||
|
||||
|
||||
private function messageGroupKey(\Namu\WireChat\Models\Message $message): string
|
||||
{
|
||||
$messageDate = $message->created_at;
|
||||
$groupKey = '';
|
||||
if ($messageDate->isToday()) {
|
||||
$groupKey = __('wirechat::chat.message_groups.today');
|
||||
} elseif ($messageDate->isYesterday()) {
|
||||
$groupKey = __('wirechat::chat.message_groups.yesterday');
|
||||
} elseif ($messageDate->greaterThanOrEqualTo(now()->subDays(7))) {
|
||||
$groupKey = $messageDate->format('l'); // Day name
|
||||
} else {
|
||||
$groupKey = $messageDate->format('d/m/Y'); // Older than 7 days, dd/mm/yyyy
|
||||
}
|
||||
|
||||
return $groupKey;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Keep a message from auto-deletion (mark as kept)
|
||||
*/
|
||||
public function keepMessage(string $id): void
|
||||
{
|
||||
if (!$this->checkAuthorization()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->auth()) {
|
||||
$this->dispatch('wirechat-toast', type: 'error', message: __('Unauthorized access'));
|
||||
return;
|
||||
}
|
||||
|
||||
$id = decrypt($id);
|
||||
$message = \Namu\WireChat\Models\Message::findOrFail($id);
|
||||
|
||||
// Verify user belongs to the conversation
|
||||
if (!$this->auth->belongsToConversation($message->conversation)) {
|
||||
$this->dispatch('wirechat-toast', type: 'error', message: __('Unauthorized access'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if disappearing messages is enabled and allow_users_to_keep is true
|
||||
if (!timebank_config('wirechat.disappearing_messages.enabled', true) ||
|
||||
!timebank_config('wirechat.disappearing_messages.allow_users_to_keep', true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle kept status
|
||||
if ($message->kept_at) {
|
||||
// Unkeep the message
|
||||
$message->kept_at = null;
|
||||
} else {
|
||||
// Keep the message
|
||||
$message->kept_at = now();
|
||||
}
|
||||
|
||||
$message->save();
|
||||
|
||||
// Update the message in loadedMessages collection to trigger Livewire re-render
|
||||
if ($this->loadedMessages) {
|
||||
$this->loadedMessages = collect($this->loadedMessages)->map(function ($messageGroup) use ($message) {
|
||||
return $messageGroup->map(function ($loadedMessage) use ($message) {
|
||||
if ($loadedMessage->id === $message->id) {
|
||||
$loadedMessage->kept_at = $message->kept_at;
|
||||
}
|
||||
return $loadedMessage;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete conversation (override with authorization)
|
||||
*/
|
||||
public function deleteConversation()
|
||||
{
|
||||
if (!$this->checkAuthorization()) {
|
||||
return;
|
||||
}
|
||||
|
||||
return parent::deleteConversation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear conversation history (override with authorization)
|
||||
*/
|
||||
public function clearConversation()
|
||||
{
|
||||
if (!$this->checkAuthorization()) {
|
||||
return;
|
||||
}
|
||||
|
||||
return parent::clearConversation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit conversation (override with authorization)
|
||||
*/
|
||||
public function exitConversation()
|
||||
{
|
||||
if (!$this->checkAuthorization()) {
|
||||
return;
|
||||
}
|
||||
|
||||
return parent::exitConversation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete message for me (override with authorization)
|
||||
*/
|
||||
public function deleteForMe(string $id): void
|
||||
{
|
||||
if (!$this->checkAuthorization()) {
|
||||
return;
|
||||
}
|
||||
|
||||
parent::deleteForMe($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete message for everyone (override with authorization)
|
||||
*/
|
||||
public function deleteForEveryone(string $id): void
|
||||
{
|
||||
if (!$this->checkAuthorization()) {
|
||||
return;
|
||||
}
|
||||
|
||||
parent::deleteForEveryone($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send like (override with authorization)
|
||||
*/
|
||||
public function sendLike()
|
||||
{
|
||||
if (!$this->checkAuthorization()) {
|
||||
return;
|
||||
}
|
||||
|
||||
return parent::sendLike();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set reply (override with authorization)
|
||||
*/
|
||||
public function setReply(string $id): void
|
||||
{
|
||||
if (!$this->checkAuthorization()) {
|
||||
return;
|
||||
}
|
||||
|
||||
parent::setReply($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Override to fire the app event so SendEmailNewMessage listener is triggered.
|
||||
* Using event() on a ShouldBroadcast event handles both WebSocket broadcasting
|
||||
* and listener dispatch in a single call, preventing double-firing.
|
||||
*/
|
||||
protected function dispatchMessageCreatedEvent(\Namu\WireChat\Models\Message $message): void
|
||||
{
|
||||
if ($this->conversation->isSelf()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
event(new MessageCreated($message));
|
||||
\Namu\WireChat\Jobs\NotifyParticipants::dispatch($this->conversation, $message);
|
||||
} catch (\Throwable $th) {
|
||||
\Illuminate\Support\Facades\Log::error($th->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount component (override with authorization)
|
||||
*/
|
||||
public function mount($conversation = null)
|
||||
{
|
||||
// CRITICAL SECURITY: Validate authorization on mount
|
||||
$profile = getActiveProfile();
|
||||
if (!$profile) {
|
||||
abort(403, 'No active profile');
|
||||
}
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
|
||||
|
||||
parent::mount($conversation);
|
||||
|
||||
$prefill = request()->query('prefill');
|
||||
if ($prefill) {
|
||||
$this->body = $prefill;
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
// CRITICAL SECURITY: Re-validate authorization on every render
|
||||
$profile = getActiveProfile();
|
||||
if (!$profile) {
|
||||
return view('errors.unauthorized-component');
|
||||
}
|
||||
|
||||
try {
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
|
||||
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
|
||||
return view('errors.unauthorized-component');
|
||||
}
|
||||
|
||||
return parent::render();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\WireChat\Chat\Group;
|
||||
|
||||
use App\Models\User;
|
||||
use Namu\WireChat\Facades\WireChat;
|
||||
use Namu\WireChat\Livewire\Chat\Group\AddMembers as BaseAddMembers;
|
||||
use Namu\WireChat\Livewire\Chat\Group\Info;
|
||||
|
||||
class AddMembers extends BaseAddMembers
|
||||
{
|
||||
/**
|
||||
* Override save to dispatch the refreshed participant count after persisting members.
|
||||
* The base save() dispatches before the DB count is re-queried; we re-query to ensure
|
||||
* the Info header always shows the correct number.
|
||||
*/
|
||||
public function save()
|
||||
{
|
||||
parent::save();
|
||||
|
||||
// Re-query the actual participant count from DB and re-dispatch so Info updates correctly
|
||||
$actualCount = $this->conversation->participants()->count();
|
||||
$this->dispatch('participantsCountUpdated', $actualCount)->to(Info::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add all linked users of the active Organization, Bank, or Admin profile
|
||||
* to the selected members list, up to the group member limit.
|
||||
*/
|
||||
public function addLinkedMembers(): void
|
||||
{
|
||||
$activeProfile = getActiveProfile();
|
||||
if (!$activeProfile) {
|
||||
return;
|
||||
}
|
||||
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($activeProfile);
|
||||
|
||||
$activeProfileType = getActiveProfileType();
|
||||
|
||||
if (!in_array($activeProfileType, ['Organization', 'Bank', 'Admin'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$relationMethod = $activeProfileType === 'Bank' ? 'managers' : 'users';
|
||||
$linkedUsers = $activeProfile->$relationMethod()
|
||||
->select('users.id')
|
||||
->get();
|
||||
|
||||
foreach ($linkedUsers as $linkedUser) {
|
||||
// Stop if we've hit the group member limit
|
||||
if ($this->newTotalCount >= WireChat::maxGroupMembers()) {
|
||||
$this->dispatch('show-member-limit-error');
|
||||
break;
|
||||
}
|
||||
|
||||
$user = User::find($linkedUser->id);
|
||||
if (!$user) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if already a participant in the conversation
|
||||
if ($user->belongsToConversation($this->conversation)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if already selected
|
||||
if ($this->selectedMembers->contains(fn ($m) => $m->id == $user->id && get_class($m) == get_class($user))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->selectedMembers->push($user);
|
||||
$this->newTotalCount = count($this->selectedMembers) + $this->exitingMembersCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\WireChat\Chat\Group;
|
||||
|
||||
use App\Models\User;
|
||||
use Namu\WireChat\Enums\Actions;
|
||||
use Namu\WireChat\Facades\WireChat;
|
||||
use Namu\WireChat\Livewire\Chat\Group\Info;
|
||||
use Namu\WireChat\Livewire\Chat\Group\Members as BaseMembers;
|
||||
use Namu\WireChat\Models\Action;
|
||||
use Namu\WireChat\Models\Participant;
|
||||
|
||||
class Members extends BaseMembers
|
||||
{
|
||||
public bool $confirmingSync = false;
|
||||
|
||||
/**
|
||||
* Show the sync confirmation modal.
|
||||
* Profile name is resolved server-side — no client input trusted.
|
||||
*/
|
||||
public function confirmSync(): void
|
||||
{
|
||||
abort_unless(auth()->check(), 401);
|
||||
abort_unless(auth()->user()->isAdminIn($this->conversation), 403);
|
||||
|
||||
$activeProfile = getActiveProfile();
|
||||
if (!$activeProfile) {
|
||||
return;
|
||||
}
|
||||
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($activeProfile);
|
||||
|
||||
$activeProfileType = getActiveProfileType();
|
||||
if (!in_array($activeProfileType, ['Organization', 'Bank', 'Admin'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->confirmingSync = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync conversation members to match the linked users of the active profile.
|
||||
* - Adds linked users not yet in the group
|
||||
* - Removes participants who are no longer linked (except the owner and auth user)
|
||||
*/
|
||||
public function syncLinkedMembers(): void
|
||||
{
|
||||
abort_unless(auth()->check(), 401);
|
||||
abort_unless(auth()->user()->isAdminIn($this->conversation), 403);
|
||||
|
||||
$activeProfile = getActiveProfile();
|
||||
if (!$activeProfile) {
|
||||
$this->confirmingSync = false;
|
||||
return;
|
||||
}
|
||||
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($activeProfile);
|
||||
|
||||
$activeProfileType = getActiveProfileType();
|
||||
if (!in_array($activeProfileType, ['Organization', 'Bank', 'Admin'])) {
|
||||
$this->confirmingSync = false;
|
||||
return;
|
||||
}
|
||||
|
||||
$this->confirmingSync = false;
|
||||
|
||||
$relationMethod = $activeProfileType === 'Bank' ? 'managers' : 'users';
|
||||
$linkedUserIds = $activeProfile->$relationMethod()
|
||||
->select('users.id')
|
||||
->pluck('users.id')
|
||||
->toArray();
|
||||
|
||||
$authParticipant = $this->conversation->participant(auth()->user());
|
||||
$maxMembers = WireChat::maxGroupMembers();
|
||||
|
||||
// --- Add linked users not yet in the group ---
|
||||
foreach ($linkedUserIds as $userId) {
|
||||
if ($this->conversation->participants()->count() >= $maxMembers) {
|
||||
break;
|
||||
}
|
||||
|
||||
$user = User::find($userId);
|
||||
if (!$user) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$user->belongsToConversation($this->conversation)) {
|
||||
$this->conversation->addParticipant($user, undoAdminRemovalAction: $authParticipant?->isAdmin());
|
||||
}
|
||||
}
|
||||
|
||||
// --- Remove participants no longer linked ---
|
||||
$participantsToRemove = $this->conversation->participants()
|
||||
->with('participantable')
|
||||
->get()
|
||||
->filter(function (Participant $participant) use ($linkedUserIds) {
|
||||
// Never remove the owner
|
||||
if ($participant->isOwner()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Never remove the currently authenticated user
|
||||
if (
|
||||
$participant->participantable_id == auth()->id() &&
|
||||
$participant->participantable_type == auth()->user()->getMorphClass()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove if not in the linked users list
|
||||
return !in_array($participant->participantable_id, $linkedUserIds);
|
||||
});
|
||||
|
||||
foreach ($participantsToRemove as $participant) {
|
||||
Action::create([
|
||||
'actionable_id' => $participant->id,
|
||||
'actionable_type' => Participant::class,
|
||||
'actor_id' => auth()->id(),
|
||||
'actor_type' => auth()->user()->getMorphClass(),
|
||||
'type' => Actions::REMOVED_BY_ADMIN,
|
||||
]);
|
||||
}
|
||||
|
||||
// Reload participants and dispatch updated count
|
||||
$this->page = 1;
|
||||
$this->participants = collect();
|
||||
$this->loadParticipants();
|
||||
|
||||
$actualCount = $this->conversation->participants()->count();
|
||||
$this->totalMembersCount = $actualCount;
|
||||
$this->dispatch('participantsCountUpdated', $actualCount)->to(Info::class);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user