Files

685 lines
25 KiB
PHP

<?php
namespace App\Http\Livewire;
use App\Services\PresenceService;
use Cog\Laravel\Love\ReactionType\Models\ReactionType;
use Cog\Laravel\Love\Reaction\Models\Reaction;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\On;
use Livewire\Component;
use Namu\WireChat\Enums\ConversationType;
class OnlineReactedProfiles extends Component
{
// Configuration properties
public $reactionTypes = []; // ['Bookmark', 'Star', 'Like'] or single 'Bookmark'
public $guards = ['web']; // Which guards to check for online users
public $authGuard; // Current user's guard
public $showCount = true;
public $showAvatars = true;
public $maxDisplay = 20;
public $headerText; // Header text for the component
public $refreshInterval = 60; // Fallback poll — primary updates come via Reverb echo-presence event
public $groupByModel = true; // Group results by model type
public $lastSeen = false; // Show last seen time
public $modelLabels = [
'App\Models\User' => 'Persons',
'App\Models\Organization' => 'Organizations',
'App\Models\Bank' => 'Banks',
'App\Models\Admin' => 'Admins',
];
// Data properties
public $onlineReactedProfiles = [];
public $totalCount = 0;
public $countByType = [];
// Logout modal properties
public $showLogoutModal = false;
public $selectedProfileId = null;
public $selectedProfileType = null;
public $selectedProfileName = null;
public function mount(
$reactionTypes = null,
$guards = null,
$showCount = true,
$showAvatars = true,
$maxDisplay = 20,
$header = true,
$headerText = null,
$groupByModel = false,
$lastSeen = false
) {
// Setup reaction types
if ($reactionTypes !== null) {
if (is_string($reactionTypes)) {
$this->reactionTypes = array_map('trim', explode(',', $reactionTypes));
} else {
$this->reactionTypes = is_array($reactionTypes) ? $reactionTypes : [$reactionTypes];
}
} else {
// Keep as null to show all online users without filtering
$this->reactionTypes = null;
}
// Setup guards to check
if ($guards) {
$this->guards = is_array($guards) ? $guards : [$guards];
} else {
// Check all guards by default
$this->guards = ['web', 'organization', 'bank'];
}
// Determine current auth guard
$this->authGuard = session('active_guard') ?? 'web';
// Set display options
$this->showCount = $showCount;
$this->showAvatars = $showAvatars;
$this->maxDisplay = $maxDisplay;
$this->groupByModel = $groupByModel;
$this->lastSeen = $lastSeen;
// Load initial data
$this->loadOnlineReactedProfiles();
// Set header text if required
if ($header) {
if ($headerText) {
$this->headerText = $headerText;
} else {
$this->headerText = $this->getHeaderText($this->reactionTypes);
}
} else {
$this->headerText = null;
}
}
/**
* Get the header text based on reaction types.
* If no reaction types, return a default message.
*/
public function getHeaderText($reactionTypes)
{
if ($this->reactionTypes === null) {
$isAdmin = session('activeProfileType') === 'App\Models\Admin';
$key = $isAdmin ? 'messages.profiles_online' : 'messages.reactions_contacts_online';
return trans_choice($key, $this->totalCount, ['count' => $this->totalCount]);
} elseif (!empty($reactionTypes)) {
if (count($reactionTypes) === 1) {
return trans_choice('messages.' . strtolower($reactionTypes[0]) . '_contacts_online', $this->totalCount, ['count' => $this->totalCount]);
} elseif (count($reactionTypes) > 1) {
return trans_choice('messages.reactions_contacts_online', $this->totalCount, ['count' => $this->totalCount]);
}
}
return 'Error: no reaction types found.';
}
public function loadOnlineReactedProfiles()
{
try {
// Get authenticated user/profile
$authUser = auth($this->authGuard)->user();
if (!$authUser) {
$this->onlineReactedProfiles = [];
$this->totalCount = 0;
return;
}
// Only require viaLoveReacter if filtering by reactions
if ($this->reactionTypes !== null && !method_exists($authUser, 'viaLoveReacter')) {
$this->onlineReactedProfiles = [];
$this->totalCount = 0;
return;
}
// Collect all online users from specified guards
$allOnlineProfiles = collect();
$locationRelations = [
'locations.city.translations',
'locations.division.translations',
'locations.country.translations',
];
foreach ($this->guards as $guard) {
try {
$presenceService = app(PresenceService::class);
$onlineUsers = $presenceService->getOnlineUsers($guard);
// Batch-load all models for this guard in one query
$modelClass = $this->getModelClassForGuard($guard);
if (!$modelClass || $onlineUsers->isEmpty()) continue;
$ids = $onlineUsers->map(fn($u) => is_array($u) ? ($u['id'] ?? null) : ($u->id ?? null))->filter();
$modelsById = $modelClass::with($locationRelations)->whereIn('id', $ids)->get()->keyBy('id');
foreach ($onlineUsers as $userData) {
$userId = is_array($userData) ? ($userData['id'] ?? null) : ($userData->id ?? null);
if (!$userId) continue;
$model = $modelsById->get($userId);
if ($model && ($this->reactionTypes === null || method_exists($model, 'viaLoveReactant'))) {
$profileData = $this->buildProfileData($model, $userData, $guard);
if ($profileData) {
$allOnlineProfiles->push($profileData);
}
}
}
} catch (\Exception $e) {
\Log::error('Error loading online profiles for guard ' . $guard . ': ' . $e->getMessage());
}
}
// Exclude the current active profile from results
$activeProfileType = session('activeProfileType');
$activeProfileId = session('activeProfileId');
$allOnlineProfiles = $allOnlineProfiles->filter(function ($profile) use ($activeProfileType, $activeProfileId) {
return !($profile['model_type'] === $activeProfileType && $profile['id'] === $activeProfileId);
});
// Filter profiles based on active profile type
$activeProfileType = session('activeProfileType');
if ($activeProfileType === 'App\Models\Admin') {
// Admins see all online profiles
$reactedProfiles = $allOnlineProfiles;
} else {
// Non-admins see only their contacts (reactions + transactions + conversations)
$contactKeys = $this->getContactProfileKeys();
$reactedProfiles = $allOnlineProfiles->filter(function ($profile) use ($contactKeys) {
$key = $profile['model_type'] . '_' . $profile['id'];
return in_array($key, $contactKeys);
});
}
// Apply display limit
$limitedProfiles = $reactedProfiles->take($this->maxDisplay);
// Group by model if requested
if ($this->groupByModel) {
$this->onlineReactedProfiles = $limitedProfiles
->groupBy('model_type')
->toArray();
} else {
$this->onlineReactedProfiles = $limitedProfiles->values()->toArray();
}
// Update counts
$this->totalCount = $reactedProfiles->count();
$this->countByType = $reactedProfiles->groupBy('model_type')->map->count()->toArray();
// Dispatch event for other components
$this->dispatch('online-reacted-profiles-updated', [
'count' => $this->totalCount,
'profiles' => $this->onlineReactedProfiles
]);
} catch (\Exception $e) {
\Log::error('Error loading online reacted profiles: ' . $e->getMessage());
$this->onlineReactedProfiles = [];
$this->totalCount = 0;
}
}
protected function getModelFromUserData($userData, $guard)
{
try {
// Get the model class for this guard
$modelClass = $this->getModelClassForGuard($guard);
if (!$modelClass) {
return null;
}
// Handle both array and object formats
$userId = is_array($userData) ? ($userData['id'] ?? null) : ($userData->id ?? null);
if (!$userId) {
return null;
}
return $modelClass::find($userId);
} catch (\Exception $e) {
\Log::error('Error getting model from user data: ' . $e->getMessage());
return null;
}
}
protected function getModelClassForGuard($guard)
{
// Map guards to model classes
$guardModelMap = [
'web' => \App\Models\User::class,
'organization' => \App\Models\Organization::class,
'bank' => \App\Models\Bank::class,
'admin' => \App\Models\Admin::class,
];
return $guardModelMap[$guard] ?? null;
}
protected function buildProfileData($model, $userData, $guard)
{
try {
// Get reactions for this profile
$reactions = $this->getReactionsForProfile($model);
// Build profile data array
return [
'id' => $model->id,
'model' => $model,
'model_type' => get_class($model),
'model_label' => $this->modelLabels[get_class($model)] ?? class_basename($model),
'guard' => $guard,
'name' => $model->name ?? 'Unknown',
'location' => $model->getLocationFirst()['name_short'] ?? '',
'avatar' => $model->profile_photo_path ?? null,
'last_seen' => is_array($userData) ?
($userData['last_seen'] ?? now()) : ($userData->last_seen ?? now()),
'reactions' => $reactions,
'profile_url' => route('profile.show_by_type_and_id', ['type' => __(strtolower(class_basename($model))), 'id' => $model->id]),
'is_online' => true,
];
} catch (\Exception $e) {
\Log::error('Error building profile data: ' . $e->getMessage());
return null;
}
}
protected function hasAuthUserReacted($model)
{
try {
// If no reaction types specified, return true (show all)
if ($this->reactionTypes === null) {
return true;
}
if (!method_exists($model, 'viaLoveReactant')) {
return false;
}
$reactantFacade = $model->viaLoveReactant();
// Get the authenticated user directly
$authUser = auth($this->authGuard)->user();
if (!$authUser) {
return false;
}
// Check if empty array (different from null)
if (is_array($this->reactionTypes) && empty($this->reactionTypes)) {
return false;
}
foreach ($this->reactionTypes as $reactionType) {
if ($reactantFacade->isReactedBy($authUser, $reactionType)) {
return true;
}
}
return false;
} catch (\Exception $e) {
\Log::error('Error checking reactions: ' . $e->getMessage());
return false;
}
}
protected function getReactionsForProfile($model)
{
try {
if (!method_exists($model, 'viaLoveReactant')) {
return [];
}
$reactantFacade = $model->viaLoveReactant();
$reactions = [];
// Get the authenticated user directly
$authUser = auth($this->authGuard)->user();
if (!$authUser) {
return [];
}
if ($this->reactionTypes !== null) {
foreach ($this->reactionTypes as $reactionType) {
if ($reactantFacade->isReactedBy($authUser, $reactionType)) {
$reactions[] = [
'type' => $reactionType,
];
}
}
}
return $reactions;
} catch (\Exception $e) {
\Log::error('Error getting reactions for profile: ' . $e->getMessage());
return [];
}
}
public function toggleReaction($profileId, $modelType, $reactionType)
{
try {
$authUser = auth($this->authGuard)->user();
if (!$authUser) {
return;
}
$model = $modelType::find($profileId);
if (!$model) {
return;
}
$reacterFacade = $authUser->viaLoveReacter();
$reactantFacade = $model->viaLoveReactant();
if ($reactantFacade->isReactedBy($authUser, $reactionType)) {
$reacterFacade->unreactTo($model, $reactionType);
} else {
$reacterFacade->reactTo($model, $reactionType);
}
$this->loadOnlineReactedProfiles();
} catch (\Exception $e) {
\Log::error('Error toggling reaction: ' . $e->getMessage());
}
}
#[On('presence-updated')]
#[On('echo-presence:presence-web-users,.UserPresenceUpdated')]
public function refreshProfiles()
{
$this->loadOnlineReactedProfiles();
}
#[On('user-activity')]
public function onUserActivity()
{
$this->loadOnlineReactedProfiles();
}
public function isCurrentUserProfile($profileId, $modelType)
{
// Get the active profile using getActiveProfile() helper
$activeProfile = getActiveProfile();
if (!$activeProfile) {
return false;
}
// Check if this is the current active profile (admin or any other type)
if (get_class($activeProfile) === $modelType || get_class($activeProfile) === '\\' . $modelType) {
if ($activeProfile->id === $profileId) {
return true;
}
}
// If the active profile is an Admin, check for related users
if (get_class($activeProfile) === \App\Models\Admin::class) {
if (method_exists($activeProfile, 'users')) {
$relatedUsers = $activeProfile->users()->pluck('users.id')->toArray();
if ($modelType === 'App\Models\User' || $modelType === \App\Models\User::class) {
return in_array($profileId, $relatedUsers);
}
}
}
return false;
}
public function openLogoutModal($profileId, $modelType)
{
// Verify admin access
if (getActiveProfileType() !== 'Admin') {
$this->dispatch('notify', [
'type' => 'error',
'message' => 'Unauthorized action. Only administrators can log out users.'
]);
return;
}
// Prevent logging out current admin or their related profiles
if ($this->isCurrentUserProfile($profileId, $modelType)) {
$this->dispatch('notify', [
'type' => 'error',
'message' => 'You cannot log out your own profile or related user accounts.'
]);
return;
}
$this->selectedProfileId = $profileId;
$this->selectedProfileType = $modelType;
// Get profile name for display
try {
$model = $modelType::find($profileId);
$this->selectedProfileName = $model ? $model->name : null;
} catch (\Exception $e) {
$this->selectedProfileName = null;
}
$this->showLogoutModal = true;
}
public function closeLogoutModal()
{
$this->showLogoutModal = false;
$this->selectedProfileId = null;
$this->selectedProfileType = null;
$this->selectedProfileName = null;
}
public function logoutUser()
{
// Verify admin access
if (getActiveProfileType() !== 'Admin') {
$this->dispatch('notify', [
'type' => 'error',
'message' => 'Unauthorized action. Only administrators can log out users.'
]);
$this->closeLogoutModal();
return;
}
try {
if (!$this->selectedProfileId || !$this->selectedProfileType) {
throw new \Exception('Invalid profile data');
}
$model = $this->selectedProfileType::find($this->selectedProfileId);
if (!$model) {
throw new \Exception('User not found');
}
// Get the guard for this model type
$guard = $this->getGuardForModelType($this->selectedProfileType);
if (!$guard) {
throw new \Exception('Invalid guard');
}
// Broadcast logout event via websocket - this will force the user's browser to logout
broadcast(new \App\Events\UserForcedLogout($this->selectedProfileId, $guard));
// Delete all sessions for this user from the database
\DB::connection(config('session.connection'))
->table(config('session.table', 'sessions'))
->where('user_id', $this->selectedProfileId)
->delete();
// Clear any cached authentication data
\Cache::forget('auth_' . $guard . '_' . $this->selectedProfileId);
// Clear presence cache
\Cache::forget("presence_{$guard}_{$this->selectedProfileId}");
// Clear online users cache to force refresh
\Cache::forget("online_users_{$guard}_" . \App\Services\PresenceService::ONLINE_THRESHOLD_MINUTES);
// Set user offline in presence service
$presenceService = app(\App\Services\PresenceService::class);
$presenceService->setUserOffline($model, $guard);
$this->dispatch('notify', [
'type' => 'success',
'message' => __('User has been logged out successfully.')
]);
// Refresh the online profiles list
$this->loadOnlineReactedProfiles();
} catch (\Exception $e) {
\Log::error('Error logging out user: ' . $e->getMessage());
$this->dispatch('notify', [
'type' => 'error',
'message' => __('Failed to log out user. Please try again.')
]);
}
$this->closeLogoutModal();
}
protected function getGuardForModelType($modelType)
{
// Map model types to guards
$modelGuardMap = [
\App\Models\User::class => 'web',
'App\Models\User' => 'web',
\App\Models\Organization::class => 'organization',
'App\Models\Organization' => 'organization',
\App\Models\Bank::class => 'bank',
'App\Models\Bank' => 'bank',
\App\Models\Admin::class => 'admin',
'App\Models\Admin' => 'admin',
];
return $modelGuardMap[$modelType] ?? null;
}
/**
* Get profile keys (ModelClass_id) for all contacts of the active profile.
* Mirrors the logic in Contacts.php: reactions (Star/Bookmark) + transactions + conversations.
*/
protected function getContactProfileKeys(): array
{
$activeProfile = getActiveProfile();
if (!$activeProfile) {
return [];
}
$keys = collect();
$reacterId = $activeProfile->love_reacter_id;
// 1. Reactions (Star + Bookmark)
if ($reacterId) {
$reacted = DB::table('love_reactions')
->join('love_reactants', 'love_reactions.reactant_id', '=', 'love_reactants.id')
->where('love_reactions.reacter_id', $reacterId)
->whereIn(DB::raw('SUBSTRING_INDEX(love_reactants.type, "\\\\", -1)'), ['User', 'Organization', 'Bank'])
->select('love_reactants.type as reactant_type', 'love_reactants.id as reactant_id')
->get();
foreach ($reacted as $row) {
// Look up the profile id via the reactant
$profileId = DB::table('love_reactants')
->join(DB::raw('(' . $this->buildProfileSubquery($row->reactant_type) . ') as p'), 'love_reactants.id', '=', 'p.love_reactant_id')
->where('love_reactants.id', $row->reactant_id)
->value('p.id');
if ($profileId) {
$modelClass = $this->resolveModelClassFromReactantType($row->reactant_type);
if ($modelClass) {
$keys->push($modelClass . '_' . $profileId);
}
}
}
}
// 2. Transactions
$accountIds = DB::table('accounts')
->where('accountable_type', get_class($activeProfile))
->where('accountable_id', $activeProfile->id)
->pluck('id');
if ($accountIds->isNotEmpty()) {
$counterAccountIds = DB::table('transactions')
->where(function ($q) use ($accountIds) {
$q->whereIn('from_account_id', $accountIds)->whereNotIn('to_account_id', $accountIds);
})
->orWhere(function ($q) use ($accountIds) {
$q->whereIn('to_account_id', $accountIds)->whereNotIn('from_account_id', $accountIds);
})
->get()
->map(fn($t) => $accountIds->contains($t->from_account_id) ? $t->to_account_id : $t->from_account_id)
->unique();
DB::table('accounts')
->whereIn('id', $counterAccountIds)
->select('accountable_type', 'accountable_id')
->get()
->each(fn($a) => $keys->push($a->accountable_type . '_' . $a->accountable_id));
}
// 3. WireChat private conversations
$participantType = get_class($activeProfile);
$participantId = $activeProfile->id;
$myConversationIds = DB::table('wirechat_participants')
->join('wirechat_conversations', 'wirechat_participants.conversation_id', '=', 'wirechat_conversations.id')
->where('wirechat_participants.participantable_type', $participantType)
->where('wirechat_participants.participantable_id', $participantId)
->where('wirechat_conversations.type', ConversationType::PRIVATE->value)
->whereNull('wirechat_participants.deleted_at')
->pluck('wirechat_participants.conversation_id');
if ($myConversationIds->isNotEmpty()) {
DB::table('wirechat_participants')
->whereIn('conversation_id', $myConversationIds)
->where(function ($q) use ($participantType, $participantId) {
$q->where('participantable_type', '!=', $participantType)
->orWhere('participantable_id', '!=', $participantId);
})
->whereNull('deleted_at')
->select('participantable_type', 'participantable_id')
->get()
->each(fn($p) => $keys->push($p->participantable_type . '_' . $p->participantable_id));
}
return $keys->unique()->values()->toArray();
}
private function buildProfileSubquery(string $reactantType): string
{
$modelName = class_basename($reactantType);
$table = strtolower($modelName) . 's';
return "SELECT id, love_reactant_id FROM {$table}";
}
private function resolveModelClassFromReactantType(string $reactantType): ?string
{
$modelName = class_basename($reactantType);
if (!in_array($modelName, ['User', 'Organization', 'Bank'])) {
return null;
}
return "App\\Models\\{$modelName}";
}
public function render()
{
return view('livewire.online-reacted-profiles');
}
}