diff --git a/app/Http/Controllers/ChatController.php b/app/Http/Controllers/ChatController.php index 09cf4d41..15322c7a 100644 --- a/app/Http/Controllers/ChatController.php +++ b/app/Http/Controllers/ChatController.php @@ -24,6 +24,10 @@ public function index(Request $request) { $user = $request->user(); + if ($user) { + $user->updateQuietly(['chat_last_seen_at' => now()]); + } + // Get Chat Config Status $isEnabled = AppSetting::get('global_chat_enabled', true); $audience = AppSetting::get('global_chat_audience', 'verified_members'); // 'verified_members', 'all', 'disabled' diff --git a/app/Http/Controllers/NotificationController.php b/app/Http/Controllers/NotificationController.php index 975d8d80..41538915 100644 --- a/app/Http/Controllers/NotificationController.php +++ b/app/Http/Controllers/NotificationController.php @@ -12,12 +12,18 @@ class NotificationController extends Controller */ public function index(Request $request): JsonResponse { - $perPage = min(max((int) $request->query('per_page', 10), 5), 50); + $page = (int) $request->query('page', 1); + + if ($page === 1) { + $request->user()->updateQuietly([ + 'notifications_last_seen_at' => now(), + ]); + } $paginator = $request->user() ->notifications() ->latest() - ->simplePaginate($perPage); + ->simplePaginate(10); $notifications = collect($paginator->items())->map(function ($notification) { return [ diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index d4c619ae..f4e2b2c9 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -2,6 +2,7 @@ namespace App\Http\Middleware; +use App\Models\ChatMessage; use Illuminate\Http\Request; use Inertia\Middleware; @@ -35,13 +36,32 @@ public function version(Request $request): ?string */ public function share(Request $request): array { + $user = $request->user(); + + if ($user && (! $user->last_active_at || $user->last_active_at->diffInMinutes(now()) >= 5)) { + $user->updateQuietly(['last_active_at' => now()]); + $user->last_active_at = now(); + } + + if ($user && str_contains((string) $request->header('referer', ''), '/chat')) { + $user->updateQuietly(['chat_last_seen_at' => now()]); + $user->chat_last_seen_at = now(); + } + return [ ...parent::share($request), 'name' => config('app.name'), 'app_version' => config('app.version'), 'auth' => [ - 'user' => $request->user(), - 'unread_notifications_count' => $request->user()?->unreadNotifications()->count() ?? 0, + 'user' => $user, + 'unread_notifications_count' => $request->user()?->unreadNotifications()->when($request->user()?->notifications_last_seen_at, fn ($q, $seen) => $q->where('created_at', '>', $seen))->take(10)->count() ?? 0, + 'unread_chat_messages_count' => $request->user() + ? ChatMessage::where('user_id', '!=', $request->user()->id) + ->when( + $request->user()->chat_last_seen_at, + fn ($q, $seen) => $q->where('created_at', '>', $seen) + )->take(10)->count() + : 0, 'can_access_admin' => $request->user()?->can('view admin') ?? false, 'permissions' => $request->user()?->getAllPermissions()->pluck('name')->toArray() ?? [], ], diff --git a/app/Models/User.php b/app/Models/User.php index b10f7e74..652732df 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -56,6 +56,9 @@ class User extends Authenticatable 'facebook', 'instagram', 'github', + 'notifications_last_seen_at', + 'chat_last_seen_at', + 'last_active_at', ]; protected static function booted(): void @@ -73,6 +76,11 @@ protected static function booted(): void 'is_banned', ]; + public function isOnline(): bool + { + return $this->last_active_at && $this->last_active_at->greaterThan(now()->subMinutes(5)); + } + public function isBanned(): bool { return $this->banned_until !== null && $this->banned_until->isFuture(); @@ -104,6 +112,9 @@ protected function casts(): array return [ 'email_verified_at' => 'datetime', 'banned_until' => 'datetime', + 'notifications_last_seen_at' => 'datetime', + 'chat_last_seen_at' => 'datetime', + 'last_active_at' => 'datetime', 'password' => 'hashed', 'receive_emails' => 'boolean', 'is_verified' => 'boolean', diff --git a/database/migrations/2026_10_05_080443_add_notifications_last_seen_at_to_users_table.php b/database/migrations/2026_10_05_080443_add_notifications_last_seen_at_to_users_table.php new file mode 100644 index 00000000..29de90b1 --- /dev/null +++ b/database/migrations/2026_10_05_080443_add_notifications_last_seen_at_to_users_table.php @@ -0,0 +1,28 @@ +timestamp('notifications_last_seen_at')->nullable()->after('remember_token'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('notifications_last_seen_at'); + }); + } +}; diff --git a/database/migrations/2026_10_05_174110_add_chat_last_seen_at_to_users_table.php b/database/migrations/2026_10_05_174110_add_chat_last_seen_at_to_users_table.php new file mode 100644 index 00000000..fe09e6fd --- /dev/null +++ b/database/migrations/2026_10_05_174110_add_chat_last_seen_at_to_users_table.php @@ -0,0 +1,28 @@ +timestamp('chat_last_seen_at')->nullable()->after('notifications_last_seen_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('chat_last_seen_at'); + }); + } +}; diff --git a/database/migrations/2026_10_05_190800_add_last_active_at_to_users_table.php b/database/migrations/2026_10_05_190800_add_last_active_at_to_users_table.php new file mode 100644 index 00000000..bfd51bdf --- /dev/null +++ b/database/migrations/2026_10_05_190800_add_last_active_at_to_users_table.php @@ -0,0 +1,28 @@ +timestamp('last_active_at')->nullable()->index()->after('remember_token'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('last_active_at'); + }); + } +}; diff --git a/resources/js/components/NotificationDropdown.vue b/resources/js/components/NotificationDropdown.vue index b62e67fb..ffdf07cd 100644 --- a/resources/js/components/NotificationDropdown.vue +++ b/resources/js/components/NotificationDropdown.vue @@ -74,7 +74,7 @@ const fetchNotifications = async () => { currentPage.value = 1; try { - const res = await fetch('/notifications?per_page=10&page=1', { + const res = await fetch('/notifications?page=1', { headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest', @@ -86,10 +86,6 @@ const fetchNotifications = async () => { notifications.value = data.notifications || []; hasMore.value = Boolean(data.has_more); currentPage.value = data.current_page || 1; - - if (typeof data.unread_count === 'number') { - unreadCount.value = data.unread_count; - } } } catch (e) { console.error('Failed to fetch notifications:', e); @@ -107,7 +103,7 @@ const loadMore = async () => { const nextPage = currentPage.value + 1; try { - const res = await fetch(`/notifications?per_page=10&page=${nextPage}`, { + const res = await fetch(`/notifications?page=${nextPage}`, { headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest', @@ -120,10 +116,6 @@ const loadMore = async () => { notifications.value = [...notifications.value, ...newItems]; hasMore.value = Boolean(data.has_more); currentPage.value = data.current_page || nextPage; - - if (typeof data.unread_count === 'number') { - unreadCount.value = data.unread_count; - } } } catch (e) { console.error('Failed to load more notifications:', e); @@ -154,6 +146,12 @@ const toggleDropdown = () => { isOpen.value = !isOpen.value; if (isOpen.value) { + unreadCount.value = 0; + + if (page.props.auth) { + (page.props.auth as any).unread_notifications_count = 0; + } + updatePanelPosition(); fetchNotifications(); } @@ -211,14 +209,6 @@ const markSingleAsRead = async (notification: NotificationItem, e?: Event) => { // Optimistically mark as read in local state notification.read_at = new Date().toISOString(); - if (unreadCount.value > 0) { - unreadCount.value--; - } - - if (page.props.auth) { - (page.props.auth as any).unread_notifications_count = unreadCount.value; - } - try { await fetch(`/notifications/${notification.id}/read`, { method: 'POST', @@ -239,15 +229,6 @@ const markAsRead = async (notification: NotificationItem) => { // Optimistically mark as read in local state notification.read_at = new Date().toISOString(); - if (unreadCount.value > 0) { - unreadCount.value--; - } - - if (page.props.auth) { - (page.props.auth as any).unread_notifications_count = - unreadCount.value; - } - try { await fetch(`/notifications/${notification.id}/read`, { method: 'POST', @@ -271,7 +252,7 @@ const markAsRead = async (notification: NotificationItem) => { }; const markAllAsRead = async () => { - if (isMarkingAll.value || unreadCount.value === 0) { + if (isMarkingAll.value || !notifications.value.some((n) => !n.read_at)) { return; } @@ -282,11 +263,6 @@ const markAllAsRead = async () => { n.read_at = new Date().toISOString(); } }); - unreadCount.value = 0; - - if (page.props.auth) { - (page.props.auth as any).unread_notifications_count = 0; - } try { await fetch('/notifications/mark-all-read', { @@ -437,7 +413,7 @@ onBeforeUnmount(() => {
+ {{ activeMembersList.length }} online now in + global chat +
++ @{{ member.username }} +
+