From 23830a8019dd4d0302a985d7a2040c7ede598ee0 Mon Sep 17 00:00:00 2001 From: TR Tajim Date: Mon, 5 Oct 2026 20:29:52 +0600 Subject: [PATCH 1/3] feat: implement real-time unread badges, presence tracking, and chat seen status --- app/Http/Controllers/ChatController.php | 4 + .../Controllers/NotificationController.php | 14 +- app/Http/Middleware/HandleInertiaRequests.php | 24 +- app/Models/User.php | 11 + ...ifications_last_seen_at_to_users_table.php | 28 ++ ...0_add_chat_last_seen_at_to_users_table.php | 28 ++ ...0800_add_last_active_at_to_users_table.php | 28 ++ .../js/components/NotificationDropdown.vue | 44 +--- .../js/components/navigation/Navigation.tsx | 248 ++++++++++++------ resources/js/pages/Chat/Index.vue | 208 ++++++++++++--- routes/channels.php | 16 +- 11 files changed, 485 insertions(+), 168 deletions(-) create mode 100644 database/migrations/2026_10_05_080443_add_notifications_last_seen_at_to_users_table.php create mode 100644 database/migrations/2026_10_05_174110_add_chat_last_seen_at_to_users_table.php create mode 100644 database/migrations/2026_10_05_190800_add_last_active_at_to_users_table.php 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..177ec271 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 [ @@ -34,7 +40,6 @@ public function index(Request $request): JsonResponse 'notifications' => $notifications, 'has_more' => $paginator->hasMorePages(), 'current_page' => $paginator->currentPage(), - 'unread_count' => $request->user()->unreadNotifications()->count(), ]); } @@ -50,7 +55,6 @@ public function markAsRead(Request $request, string $id): JsonResponse return response()->json([ 'success' => true, - 'unread_count' => $request->user()->unreadNotifications()->count(), ]); } @@ -65,7 +69,6 @@ public function markAllRead(Request $request): JsonResponse return response()->json([ 'success' => true, - 'unread_count' => 0, ]); } @@ -78,7 +81,6 @@ public function clearAll(Request $request): JsonResponse return response()->json([ 'success' => true, - 'unread_count' => 0, ]); } } 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(() => {

{ + + +

+
+ +
+ +
+
+
+ +
+
+

+ Active Members +

+

+ {{ activeMembersList.length }} online now in + global chat +

+
+
+ +
+ + +
+
+ No members currently active. +
+ + +
+
+ + + {{ + member.name.charAt(0).toUpperCase() + }} + + +
+ +
+
+ + {{ member.name }} + + +
+

+ @{{ member.username }} +

+
+
+ + + View → + + +
+
+
+ + diff --git a/routes/channels.php b/routes/channels.php index 6c7ee340..d3ff4a41 100644 --- a/routes/channels.php +++ b/routes/channels.php @@ -15,9 +15,21 @@ // Presence channel for tracking active users on Global Chat. Broadcast::channel('global-chat', function ($user) { - return ['id' => $user->id, 'name' => $user->name, 'username' => $user->username]; + return [ + 'id' => $user->id, + 'name' => $user->name, + 'username' => $user->username, + 'image_url' => $user->image_url, + 'is_verified' => (bool) $user->is_verified, + ]; }); Broadcast::channel('{env}.global-chat', function ($user) { - return ['id' => $user->id, 'name' => $user->name, 'username' => $user->username]; + return [ + 'id' => $user->id, + 'name' => $user->name, + 'username' => $user->username, + 'image_url' => $user->image_url, + 'is_verified' => (bool) $user->is_verified, + ]; }); From 511b5fda6bb8d6a722e2431599f0b06c35e62d97 Mon Sep 17 00:00:00 2001 From: TR Tajim Date: Mon, 5 Oct 2026 21:18:56 +0600 Subject: [PATCH 2/3] fix(chat-notifications): restore unread_count response and clean referer-based seen updates --- app/Http/Controllers/NotificationController.php | 4 ++++ app/Http/Middleware/HandleInertiaRequests.php | 5 ----- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/app/Http/Controllers/NotificationController.php b/app/Http/Controllers/NotificationController.php index 177ec271..41538915 100644 --- a/app/Http/Controllers/NotificationController.php +++ b/app/Http/Controllers/NotificationController.php @@ -40,6 +40,7 @@ public function index(Request $request): JsonResponse 'notifications' => $notifications, 'has_more' => $paginator->hasMorePages(), 'current_page' => $paginator->currentPage(), + 'unread_count' => $request->user()->unreadNotifications()->count(), ]); } @@ -55,6 +56,7 @@ public function markAsRead(Request $request, string $id): JsonResponse return response()->json([ 'success' => true, + 'unread_count' => $request->user()->unreadNotifications()->count(), ]); } @@ -69,6 +71,7 @@ public function markAllRead(Request $request): JsonResponse return response()->json([ 'success' => true, + 'unread_count' => 0, ]); } @@ -81,6 +84,7 @@ public function clearAll(Request $request): JsonResponse return response()->json([ 'success' => true, + 'unread_count' => 0, ]); } } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index f4e2b2c9..923ab0ec 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -43,11 +43,6 @@ public function share(Request $request): array $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'), From 89233be0032ddf29ebe428ab81f3b75a01626976 Mon Sep 17 00:00:00 2001 From: TR Tajim Date: Mon, 5 Oct 2026 21:33:44 +0600 Subject: [PATCH 3/3] chore: restore referer-based chat_last_seen_at check in middleware --- app/Http/Middleware/HandleInertiaRequests.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 923ab0ec..f4e2b2c9 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -43,6 +43,11 @@ public function share(Request $request): array $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'),