Snapshot of main at 9f39a6e1e (2026-11-02)

This commit is contained in:
Ronald Huynen committed 2026-11-02 10:41:47 +01:00
commit 6ccf1792d7
2580 files changed
+1040202

No files matched your search

+416
View File
@@ -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);
}
}
+119
View File
@@ -0,0 +1,119 @@
<?php
namespace App\Http\Livewire\WireChat\Chats;
use Illuminate\Support\Facades\Auth;
use Namu\WireChat\Livewire\Chats\Chats as BaseChats;
class Chats extends BaseChats
{
use \App\Traits\ListensToProfileChats;
/**
* Get all listeners including parent's Echo listeners
*/
public function getListeners()
{
// Get parent's listeners (includes Echo channels)
$parentListeners = parent::getListeners();
// Debug: Log what listeners are being registered
\Log::info('WireChat Chats Listeners', [
'parent_listeners' => $parentListeners,
'active_guard' => session('active_guard', 'web'),
'auth_user_id' => $this->auth?->id,
'auth_user_class' => $this->auth ? get_class($this->auth) : null,
]);
// Add our custom listener. Also listen on the chats of all the user's profiles: the parent
// only lists the active profile's channel, which changes after a profile switch (see
// ListensToProfileChats).
return array_merge($parentListeners, $this->profileChatListeners('refreshComponent'), [
'refreshList' => 'handleRefreshList',
]);
}
// 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();
}
public function handleRefreshList()
{
// Simply dispatch a refresh event to update the component
$this->dispatch('$refresh');
}
/**
* Override loadConversations to fix lastMessage loading for non-web guards
* The parent uses auth()->user() which defaults to web guard, causing lastMessage to be null for admin/bank/org guards
*/
protected function loadConversations()
{
// Call parent to load conversations
parent::loadConversations();
// Manually load lastMessage for each conversation without global scopes
// This fixes the issue where WithoutRemovedMessages scope uses auth()->user() (web guard)
$this->conversations->each(function ($conversation) {
$lastMessage = \Namu\WireChat\Models\Message::withoutGlobalScopes()
->where('conversation_id', $conversation->id)
->latest()
->first();
if ($lastMessage) {
$conversation->setRelation('lastMessage', $lastMessage);
}
});
}
/**
* Mount component (override with authorization)
*/
public function mount(
$showNewChatModalButton = null,
$allowChatsSearch = null,
$showHomeRouteButton = null,
?string $title = null
) {
// CRITICAL SECURITY: Validate authorization on mount
$profile = getActiveProfile();
if (!$profile) {
abort(403, 'No active profile');
}
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
return parent::mount($showNewChatModalButton, $allowChatsSearch, $showHomeRouteButton, $title);
}
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,143 @@
<?php
namespace App\Http\Livewire\WireChat;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
use Namu\WireChat\Models\Conversation;
class DisappearingMessagesSettings extends Component
{
public $conversationId;
public $conversation;
public $isEnabled = false;
public $selectedDuration;
public $allowUsersToKeep = true;
public $userControl = false;
public $platformDuration;
public $platformEnabled = true;
protected $listeners = ['refreshDisappearing' => '$refresh'];
// Multi-guard authentication support
public function getAuthProperty()
{
return Auth::guard('admin')->user()
?: Auth::guard('bank')->user()
?: Auth::guard('organization')->user()
?: Auth::guard('web')->user();
}
public function mount($conversationId)
{
$this->conversationId = $conversationId;
$this->conversation = Conversation::findOrFail($conversationId);
// Check if user belongs to conversation
$user = $this->auth;
if (!$user || !$user->belongsToConversation($this->conversation)) {
abort(403, 'You do not belong to this conversation');
}
// Load configuration
$this->allowUsersToKeep = timebank_config('wirechat.disappearing_messages.allow_users_to_keep', true);
$this->userControl = false; // Always false (hardcoded)
$this->platformEnabled = true; // Always enabled (hardcoded)
// Get duration in days and convert to seconds
$durationInDays = timebank_config('wirechat.disappearing_messages.duration', 30);
$this->platformDuration = $durationInDays * 86400; // Convert days to seconds
// Load current settings
$this->isEnabled = $this->conversation->hasDisappearingTurnedOn();
$this->selectedDuration = $this->conversation->disappearing_duration ?? $this->platformDuration;
// Auto-enable if not yet enabled for this conversation (always enabled)
if (!$this->isEnabled) {
$this->enablePlatformDefault();
}
}
/**
* Enable disappearing messages with platform default settings
*/
private function enablePlatformDefault()
{
try {
$this->conversation->turnOnDisappearing($this->platformDuration);
$this->isEnabled = true;
$this->selectedDuration = $this->platformDuration;
} catch (\InvalidArgumentException $e) {
// Silently fail - this will be shown in the UI
\Log::warning("Failed to enable disappearing messages for conversation {$this->conversationId}: " . $e->getMessage());
}
}
/**
* Get human-readable duration
*/
public function getFormattedDurationProperty()
{
$seconds = $this->selectedDuration ?? $this->platformDuration;
$days = floor($seconds / 86400);
if ($days > 0) {
return trans_choice('messages.wirechat.duration.day', $days, ['count' => $days]);
}
$hours = floor($seconds / 3600);
if ($hours > 0) {
return trans_choice('messages.wirechat.duration.hour', $hours, ['count' => $hours]);
}
$minutes = floor($seconds / 60);
if ($minutes > 0) {
return trans_choice('messages.wirechat.duration.minute', $minutes, ['count' => $minutes]);
}
if ($seconds > 0) {
return trans_choice('messages.wirechat.duration.second', $seconds, ['count' => $seconds]);
}
return 'Unknown';
}
/**
* Get human-readable kept messages duration
*/
public function getFormattedKeptDurationProperty()
{
$daysFromConfig = timebank_config('wirechat.disappearing_messages.kept_messages_duration');
if ($daysFromConfig === null) {
return null; // Never delete
}
// Config is now in days, so use directly
$days = $daysFromConfig;
// Convert to months/years if applicable
// Check for years only if it's at least 2 full years (730 days)
if ($days >= 730) {
$years = floor($days / 365);
return trans_choice('messages.wirechat.duration.year', $years, ['count' => $years]);
}
// For 30+ days but less than 2 years, show in months
if ($days >= 30) {
$months = floor($days / 30);
return trans_choice('messages.wirechat.duration.month', $months, ['count' => $months]);
}
if ($days > 0) {
return trans_choice('messages.wirechat.duration.day', $days, ['count' => $days]);
}
return 'Unknown';
}
public function render()
{
return view('livewire.wire-chat.disappearing-messages-settings');
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Http\Livewire\WireChat\New;
use Illuminate\Support\Facades\Auth;
use Namu\WireChat\Livewire\New\Chat as NewChat;
class Chat extends NewChat
{
// 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();
}
/**
* Mount component (override with authorization)
*/
public function mount()
{
// CRITICAL SECURITY: Validate authorization on mount
$profile = getActiveProfile();
if (!$profile) {
abort(403, 'No active profile');
}
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
return parent::mount();
}
public function createConversation($id, string $class)
{
// CRITICAL SECURITY: Validate authorization before creating conversation
$profile = getActiveProfile();
if (!$profile) {
abort(403, 'No active profile');
}
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
$model = app($class);
$model = $model::find($id);
if ($model) {
$createdConversation = $this->auth->createConversationWith($model);
if ($createdConversation) {
$this->closeWireChatModal();
$this->handleComponentTermination(
redirectRoute: route(\Namu\WireChat\Facades\WireChat::viewRouteName(), [$createdConversation->id]),
events: [
\Namu\WireChat\Livewire\Widgets\WireChat::class => ['open-chat', ['conversation' => $createdConversation->id]],
]
);
}
}
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace App\Http\Livewire\WireChat\New;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Namu\WireChat\Facades\WireChat;
use Namu\WireChat\Livewire\New\Group as BaseGroup;
class Group extends BaseGroup
{
/**
* 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();
$maxMembers = WireChat::maxGroupMembers();
foreach ($linkedUsers as $linkedUser) {
// Stop if we've hit the group member limit
if (count($this->selectedMembers) >= $maxMembers) {
$this->dispatch('show-member-limit-error');
break;
}
$user = User::find($linkedUser->id);
if (!$user) {
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);
}
}
}
@@ -0,0 +1,339 @@
<?php
// app/Http/Livewire/WireChat/TypingIndicator.php
namespace App\Http\Livewire\WireChat;
use App\Services\PresenceService;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Redis;
use Livewire\Component;
class TypingIndicator extends Component
{
// Public properties
public $conversationId;
public $typingUsers = [];
public $currentUserId;
public $currentUserName;
public $currentUserType;
public $currentUserAvatar;
public $showAvatars = true;
public $maxDisplay = 3;
protected $typingTimeout = 2;
public function mount($conversationId, $showAvatars = true, $maxDisplay = 3)
{
$this->conversationId = $conversationId;
$this->showAvatars = $showAvatars;
$this->maxDisplay = $maxDisplay;
$this->typingUsers = [];
$user = auth()->user();
if ($user) {
$this->currentUserId = $user->id;
$this->currentUserName = $user->name;
$this->currentUserType = class_basename($user->getMorphClass());
$this->currentUserAvatar = ($user->getMorphClass())::find($this->currentUserId)->profile_photo_path ?? null;
}
$this->loadTypingUsers();
}
/**
* Get the cache key for this user's typing status
*/
private function getCacheKey()
{
// Simplified cache key format without special characters
return "wirechat_typing_{$this->conversationId}_{$this->currentUserType}_{$this->currentUserId}";
}
/**
* Get the pattern for finding all typing users in this conversation
*/
private function getCachePattern()
{
return "wirechat_typing_{$this->conversationId}_*";
}
/**
* Start typing - called from JavaScript
*/
public function startTyping()
{
if (!$this->currentUserId) {
\Log::warning('TypingIndicator: No current user ID');
return;
}
$cacheKey = $this->getCacheKey();
$userData = [
'user_id' => $this->currentUserId,
'user_name' => $this->currentUserName,
'user_type' => $this->currentUserType,
'avatar' => $this->currentUserAvatar,
'started_at' => now(),
'conversation_id' => $this->conversationId,
];
try {
// Store with expiration
Cache::put($cacheKey, $userData, now()->addSeconds($this->typingTimeout + 2));
// Refresh presence while typing, using the same throttle as the middleware (60s)
$guard = session('active_guard', 'web');
$presenceCacheKey = "presence_last_update_{$guard}_{$this->currentUserId}";
if (!Cache::has($presenceCacheKey)) {
app(PresenceService::class)->updatePresence(auth($guard)->user(), $guard);
}
// Immediately load typing users to update the display
$this->loadTypingUsers();
} catch (\Exception $e) {
\Log::error('TypingIndicator: Error starting typing', [
'error' => $e->getMessage(),
'cache_key' => $cacheKey
]);
}
}
/**
* Stop typing - called from JavaScript
*/
public function stopTyping()
{
if (!$this->currentUserId) {
return;
}
$cacheKey = $this->getCacheKey();
try {
Cache::forget($cacheKey);
$this->loadTypingUsers();
} catch (\Exception $e) {
\Log::error('TypingIndicator: Error stopping typing', [
'error' => $e->getMessage(),
'cache_key' => $cacheKey
]);
}
}
/**
* Load typing users from cache
*/
public function loadTypingUsers()
{
try {
$typingUsers = [];
// Get the correct Redis connection for cache
$cacheRedisConnection = config('cache.stores.redis.connection', 'default');
// From the exact debugging, the actual format is:
// Redis key: timebankcc_dev_on_thinkpad_p53_database_timebankcc_dev_on_thinkpad_p53_cache:wirechat_typing_1_User_161
// But we search with: timebankcc_dev_on_thinkpad_p53_cache:wirechat_typing_1_*
// And need to extract: wirechat_typing_1_User_161
$cachePrefix = config('cache.prefix', '');
$databasePrefix = config('database.redis.options.prefix', '');
$pattern = $this->getCachePattern();
// Use the working search pattern (cache prefix only)
$searchPattern = $cachePrefix . ':' . $pattern;
// But the actual key prefix includes database prefix
$actualKeyPrefix = $databasePrefix . $cachePrefix . ':';
if (config('cache.default') === 'redis') {
// Use the CACHE Redis connection (database 1)
$connection = Redis::connection($cacheRedisConnection);
$redisKeys = $connection->keys($searchPattern);
foreach ($redisKeys as $redisKey) {
// Extract the Laravel cache key by removing the ACTUAL prefix (database + cache)
if (strpos($redisKey, $actualKeyPrefix) === 0) {
$laravelCacheKey = substr($redisKey, strlen($actualKeyPrefix));
} else {
// Fallback - the key format might be different
\Log::warning('TypingIndicator: Unexpected key format', [
'redis_key' => $redisKey,
'expected_prefix' => $actualKeyPrefix
]);
continue;
}
// Use Laravel Cache to get the data (it handles serialization properly)
$userData = Cache::get($laravelCacheKey);
if ($userData && isset($userData['user_id']) && $userData['user_id'] != $this->currentUserId) {
if (isset($userData['started_at']) && (int) ($userData['started_at']->diffInSeconds(now(), true)) <= $this->typingTimeout) {
$typingUsers[] = $userData;
\Log::info('TypingIndicator: Added typing user', [
'user_id' => $userData['user_id'],
'user_name' => $userData['user_name']
]);
} else {
// Clean up expired entries
Cache::forget($laravelCacheKey);
}
} elseif ($userData && $userData['user_id'] == $this->currentUserId) {
\Log::info('TypingIndicator: Ignoring current user\'s typing', [
'current_user_id' => $this->currentUserId
]);
}
}
} else {
// Fallback for non-Redis cache drivers
$registryKey = "typing_registry_{$this->conversationId}";
$registry = Cache::get($registryKey, []);
foreach ($registry as $key) {
$userData = Cache::get($key);
if ($userData && isset($userData['user_id']) && $userData['user_id'] != $this->currentUserId) {
if (isset($userData['started_at']) && (int) ($userData['started_at']->diffInSeconds(now(), true)) <= $this->typingTimeout) {
$typingUsers[] = $userData;
}
}
}
}
$this->typingUsers = collect($typingUsers)->take($this->maxDisplay)->toArray();
} catch (\Exception $e) {
$this->typingUsers = [];
\Log::error('TypingIndicator: Error loading typing users', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
}
}
/**
* Get typing text for display
*/
public function getTypingText()
{
$count = is_array($this->typingUsers) ? count($this->typingUsers) : 0;
if ($count === 0) {
return '';
} elseif ($count === 1) {
return ($this->typingUsers[0]['user_name'] ?? 'Someone') . ' is typing...';
} elseif ($count === 2) {
$name1 = $this->typingUsers[0]['user_name'] ?? 'Someone';
$name2 = $this->typingUsers[1]['user_name'] ?? 'Someone';
return $name1 . ' and ' . $name2 . ' are typing...';
} else {
$name1 = $this->typingUsers[0]['user_name'] ?? 'Someone';
return $name1 . ' and ' . ($count - 1) . ' others are typing...';
}
}
/**
* Manual refresh method
*/
public function refresh()
{
$this->loadTypingUsers();
}
/**
* Enhanced debug method
*/
public function debug()
{
$cacheKey = $this->getCacheKey();
$pattern = $this->getCachePattern();
// Get Redis connection info
$cacheRedisConnection = config('cache.stores.redis.connection', 'default');
$cachePrefix = config('cache.prefix', '');
$databasePrefix = config('database.redis.options.prefix', '');
// Build the full pattern accounting for both prefixes
$fullPattern = '';
if ($databasePrefix) {
$fullPattern .= $databasePrefix;
}
if ($cachePrefix) {
$fullPattern .= $cachePrefix . ':';
}
$fullPattern .= $pattern;
$redisKeys = [];
$redisData = [];
if (config('cache.default') === 'redis') {
try {
$connection = Redis::connection($cacheRedisConnection);
// Check for typing keys with the full pattern
$allTypingKeys = $connection->keys($databasePrefix ? $databasePrefix . $cachePrefix . ':*typing*' : $cachePrefix . ':*typing*');
$patternKeys = $connection->keys($fullPattern);
$redisKeys = [
'all_typing_keys' => $allTypingKeys,
'pattern_keys' => $patternKeys,
'current_user_key' => $cacheKey,
'full_pattern_searched' => $fullPattern,
'cache_redis_connection' => $cacheRedisConnection
];
foreach ($patternKeys as $key) {
// Extract the Laravel cache key by removing both prefixes
$cleanKey = $key;
if ($databasePrefix && strpos($cleanKey, $databasePrefix) === 0) {
$cleanKey = substr($cleanKey, strlen($databasePrefix));
}
if ($cachePrefix && strpos($cleanKey, $cachePrefix . ':') === 0) {
$cleanKey = substr($cleanKey, strlen($cachePrefix . ':'));
}
$data = Cache::get($cleanKey);
if ($data) {
$redisData[] = [
'full_redis_key' => $key,
'clean_cache_key' => $cleanKey,
'data' => $data
];
}
}
} catch (\Exception $e) {
\Log::error('TypingIndicator: Redis debug error', ['error' => $e->getMessage()]);
}
}
$debugInfo = [
'conversationId' => $this->conversationId,
'typingUsers' => $this->typingUsers,
'typingUsersCount' => is_array($this->typingUsers) ? count($this->typingUsers) : 'not-array',
'currentUserId' => $this->currentUserId,
'currentUserName' => $this->currentUserName,
'currentUserType' => $this->currentUserType,
'showAvatars' => $this->showAvatars,
'cacheDriver' => config('cache.default'),
'cacheKey' => $cacheKey,
'cachePattern' => $pattern,
'cachePrefix' => $cachePrefix,
'databasePrefix' => $databasePrefix,
'fullPattern' => $fullPattern,
'cacheRedisConnection' => $cacheRedisConnection,
'redisKeys' => $redisKeys,
'redisData' => $redisData,
'typingTimeout' => $this->typingTimeout,
];
\Log::info('TypingIndicator: Debug info', $debugInfo);
return $debugInfo;
}
public function render()
{
return view('livewire.wire-chat.typing-indicator');
}
}