From 45be2f435a0d8fbffb3e8aa94938284987d001b0 Mon Sep 17 00:00:00 2001 From: Logan Lindquist Land Date: Tue, 15 Sep 2026 14:03:56 -0500 Subject: [PATCH 1/2] Add user profile support --- README.md | 1 + app/Config/Routes.php | 10 + app/Config/Services.php | 2 + app/Contracts/UserRepository.php | 11 + app/Controllers/Profile.php | 232 ++++++++++++++++++ ...6-09-15-000000_add_user_profile_fields.php | 79 ++++++ app/Helpers/UserHelper.php | 17 +- app/Libraries/ChatWebSocketServer.php | 92 ++++++- app/Models/UserModel.php | 51 +++- app/Views/chat/svelteView.php | 7 +- app/Views/chat/vueView.php | 7 +- app/Views/profile/index.php | 15 ++ composer.json | 2 + composer.lock | 4 +- docs/user-profiles.md | 56 +++++ src/css/chat.scss | 146 +++++++++++ src/js/profile.js | 31 +++ src/svelte/App.svelte | 91 ++++++- src/svelte/ProfilePanel.svelte | 132 ++++++++++ src/svelte/main.js | 3 + src/vue/App.vue | 87 ++++++- src/vue/ProfilePanel.vue | 134 ++++++++++ src/vue/main.js | 3 + .../Integration/ProfileIntegrationTest.php | 212 ++++++++++++++++ .../UserProfileRepositoryIntegrationTest.php | 60 +++++ .../WebSocketFlowIntegrationTest.php | 45 ++++ 26 files changed, 1504 insertions(+), 26 deletions(-) create mode 100644 app/Controllers/Profile.php create mode 100644 app/Database/Migrations/2026-09-15-000000_add_user_profile_fields.php create mode 100644 app/Views/profile/index.php create mode 100644 docs/user-profiles.md create mode 100644 src/js/profile.js create mode 100644 src/svelte/ProfilePanel.svelte create mode 100644 src/vue/ProfilePanel.vue create mode 100644 tests/Tests/Integration/ProfileIntegrationTest.php create mode 100644 tests/Tests/Integration/UserProfileRepositoryIntegrationTest.php diff --git a/README.md b/README.md index 270b787..eac0f77 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,7 @@ Start your web server and navigate to: See: - `docs/public-api.md` (HTTP + WebSocket + frontend entrypoints + reusable PHP APIs) +- `docs/user-profiles.md` (profile settings, avatars, themes, and WebSocket presence) ## Troubleshooting diff --git a/app/Config/Routes.php b/app/Config/Routes.php index f18c9b7..e24278c 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -14,6 +14,12 @@ $routes->post('auth/processLogin', 'Auth::processLogin', ['filter' => ['rate:auth', 'validate:login']]); $routes->get('auth/logout', 'Auth::logout'); +// Profile routes +$routes->get('profile', 'Profile::index', ['filter' => 'auth']); +$routes->post('profile', 'Profile::update', ['filter' => ['auth', 'rate:write']]); +$routes->post('profile/avatar', 'Profile::uploadAvatar', ['filter' => ['auth', 'rate:write']]); +$routes->get('profile/avatar/(:num)', 'Profile::avatar/$1', ['filter' => 'auth']); + // Chat routes $routes->get('chat', 'Chat::index'); $routes->post('chat/update', 'Chat::update', ['filter' => ['rate:write', 'validate:message']]); @@ -32,6 +38,10 @@ $routes->get('messages/xml', 'MessagesController::listXml', ['filter' => 'auth']); $routes->post('push-subscriptions', 'PushSubscriptionsController::create', ['filter' => ['apiFormat', 'auth', 'rate:write']]); $routes->delete('push-subscriptions', 'PushSubscriptionsController::delete', ['filter' => ['apiFormat', 'auth', 'rate:write']]); + $routes->get('profile', '\\App\\Controllers\\Profile::show', ['filter' => ['apiFormat', 'auth']]); + $routes->post('profile', '\\App\\Controllers\\Profile::update', ['filter' => ['apiFormat', 'auth', 'rate:write']]); + $routes->post('profile/avatar', '\\App\\Controllers\\Profile::uploadAvatar', ['filter' => ['apiFormat', 'auth', 'rate:write']]); + $routes->get('profiles', '\\App\\Controllers\\Profile::profiles', ['filter' => ['apiFormat', 'auth']]); }); // Compatibility shims: same v1 implementation, with machine-readable sunset metadata. diff --git a/app/Config/Services.php b/app/Config/Services.php index fd67421..aefc557 100644 --- a/app/Config/Services.php +++ b/app/Config/Services.php @@ -16,6 +16,7 @@ use App\Controllers\Chat; use App\Controllers\CspReport; use App\Controllers\Home; +use App\Controllers\Profile; use App\Core\Application; use App\Libraries\ErrorHandler; use App\Models\AuditLogModel; @@ -69,6 +70,7 @@ public static function controller(string $controllerClass): Controller MessagesController::class => new MessagesController(static::chatRepository(), static::chatFormatter()), PushSubscriptionsController::class => new PushSubscriptionsController(static::pushSubscriptionRepository()), Auth::class => new Auth(static::userRepository(), static::auditLogger()), + Profile::class => new Profile(static::userRepository()), CspReport::class => new CspReport(static::cspReportRepository()), AuditLog::class => new AuditLog(static::auditLogRepository()), default => throw new \InvalidArgumentException('Controller is not registered: ' . $controllerClass), diff --git a/app/Contracts/UserRepository.php b/app/Contracts/UserRepository.php index 082aa50..2c55f3e 100644 --- a/app/Contracts/UserRepository.php +++ b/app/Contracts/UserRepository.php @@ -10,8 +10,19 @@ public function findUserByUsername(string $username): ?array; /** @return array|null */ public function findUserByEmail(string $email): ?array; + /** @return array|null */ + public function findUserById(int $userId): ?array; + public function createUser(string $username, string $email, string $password): int|false; /** @return array|null */ public function verifyCredentials(string $username, string $password): ?array; + + /** @param array $profile */ + public function updateProfile(int $userId, array $profile): bool; + + /** @return list> */ + public function findPublicProfilesByUsernames(array $usernames): array; + + public function updatePresence(int $userId, string $presence, ?string $lastSeenAt): bool; } diff --git a/app/Controllers/Profile.php b/app/Controllers/Profile.php new file mode 100644 index 0000000..4d9d63a --- /dev/null +++ b/app/Controllers/Profile.php @@ -0,0 +1,232 @@ +respondWithView('profile/index', [ + 'profile' => $this->currentProfile(), + ]); + } + + public function show(): ResponseInterface + { + $profile = $this->currentProfile(); + if ($profile === null) { + return $this->profileResponse(['error' => ['type' => 'not_found', 'message' => 'Profile not found.']], 404); + } + + return $this->profileResponse(['profile' => $profile]); + } + + public function update(): ResponseInterface + { + $input = $this->request->getJSON(true); + if (! is_array($input)) { + $input = $this->request->getPost(); + } + + $profile = $this->normalizeProfile($input); + if (isset($profile['error'])) { + return $this->profileResponse(['error' => ['type' => 'validation', 'message' => $profile['error']]], 422); + } + + $userId = (int) $this->session->get('user_id'); + if (! $this->users->updateProfile($userId, $profile)) { + return $this->profileResponse(['error' => ['type' => 'storage', 'message' => 'Profile could not be saved.']], 500); + } + + $this->session->set($profile); + + return $this->show(); + } + + public function uploadAvatar(): ResponseInterface + { + $avatar = $this->request->getFile('avatar'); + if ($avatar === null || ! $avatar->isValid() || $avatar->hasMoved()) { + return $this->avatarError('Choose a valid image to upload.'); + } + + if ($avatar->getSize() > self::MAX_AVATAR_BYTES) { + return $this->avatarError('Avatar images must be 2 MB or smaller.'); + } + + $temporaryPath = $avatar->getTempName(); + $imageInfo = @getimagesize($temporaryPath); + if ($imageInfo === false || $imageInfo[0] > self::MAX_AVATAR_DIMENSION || $imageInfo[1] > self::MAX_AVATAR_DIMENSION) { + return $this->avatarError('Avatar images must be no larger than 1024 by 1024 pixels.'); + } + + $source = $this->createImage($temporaryPath, $imageInfo['mime']); + if ($source === null) { + return $this->avatarError('Avatar images must be JPEG, PNG, or WebP files.'); + } + + $userId = (int) $this->session->get('user_id'); + $relativePath = "avatars/{$userId}.webp"; + $directory = WRITEPATH . 'uploads/avatars'; + if (! is_dir($directory) && ! mkdir($directory, 0o755, true) && ! is_dir($directory)) { + imagedestroy($source); + return $this->profileResponse(['error' => ['type' => 'storage', 'message' => 'Avatar could not be saved.']], 500); + } + + $destination = imagecreatetruecolor(self::AVATAR_SIZE, self::AVATAR_SIZE); + $side = min(imagesx($source), imagesy($source)); + $sourceX = intdiv(imagesx($source) - $side, 2); + $sourceY = intdiv(imagesy($source) - $side, 2); + imagecopyresampled($destination, $source, 0, 0, $sourceX, $sourceY, self::AVATAR_SIZE, self::AVATAR_SIZE, $side, $side); + + $path = $directory . "/{$userId}.webp"; + $saved = imagewebp($destination, $path, 85); + imagedestroy($source); + imagedestroy($destination); + + if (! $saved || ! $this->users->updateProfile($userId, ['avatar_path' => $relativePath])) { + return $this->profileResponse(['error' => ['type' => 'storage', 'message' => 'Avatar could not be saved.']], 500); + } + + $this->session->set('avatar_path', $relativePath); + + return $this->profileResponse(['avatar_url' => site_url("profile/avatar/{$userId}")], 201); + } + + public function avatar(int $userId): ResponseInterface + { + $user = $this->users->findUserById($userId); + $path = is_array($user) ? (string) ($user['avatar_path'] ?? '') : ''; + $expectedPath = "avatars/{$userId}.webp"; + $absolutePath = WRITEPATH . 'uploads/' . $expectedPath; + + if ($path !== $expectedPath || ! is_file($absolutePath)) { + return $this->response->setStatusCode(404); + } + + return $this->response + ->setHeader('Content-Type', 'image/webp') + ->setHeader('Cache-Control', 'private, max-age=3600') + ->setBody((string) file_get_contents($absolutePath)); + } + + public function profiles(): ResponseInterface + { + $input = $this->request->getGet('usernames'); + $usernames = is_string($input) ? array_slice(explode(',', $input), 0, 50) : []; + + return $this->profileResponse([ + 'profiles' => array_map(fn (array $profile): array => $this->publicProfile($profile), $this->users->findPublicProfilesByUsernames($usernames)), + ]); + } + + /** @return array|null */ + private function currentProfile(): ?array + { + $user = $this->users->findUserById((int) $this->session->get('user_id')); + + return is_array($user) ? $this->privateProfile($user) : null; + } + + /** @param array $input + * @return array + */ + private function normalizeProfile(array $input): array + { + $displayName = trim((string) ($input['display_name'] ?? '')); + $theme = (string) ($input['theme'] ?? 'system'); + $presence = (string) ($input['presence'] ?? 'online'); + $notifications = $input['notification_prefs'] ?? []; + + if (mb_strlen($displayName) > 100) { + return ['error' => 'Display name must be 100 characters or fewer.']; + } + if (! in_array($theme, self::THEMES, true)) { + return ['error' => 'Choose a valid theme.']; + } + if (! in_array($presence, self::PRESENCES, true)) { + return ['error' => 'Choose a valid presence status.']; + } + if (! is_array($notifications)) { + return ['error' => 'Notification preferences must be an object.']; + } + + return [ + 'display_name' => $displayName !== '' ? $displayName : null, + 'theme' => $theme, + 'presence' => $presence, + 'notification_prefs' => json_encode([ + 'desktop' => (bool) ($notifications['desktop'] ?? true), + 'sound' => (bool) ($notifications['sound'] ?? true), + ], JSON_THROW_ON_ERROR), + ]; + } + + /** @return \GdImage|null */ + private function createImage(string $path, string $mime): ?\GdImage + { + $image = match ($mime) { + 'image/jpeg' => @imagecreatefromjpeg($path), + 'image/png' => @imagecreatefrompng($path), + 'image/webp' => @imagecreatefromwebp($path), + default => false, + }; + + return $image instanceof \GdImage ? $image : null; + } + + private function avatarError(string $message): ResponseInterface + { + return $this->profileResponse(['error' => ['type' => 'validation', 'message' => $message]], 422); + } + + /** @param array $data */ + private function profileResponse(array $data, int $status = 200): ResponseInterface + { + $data['csrf_token'] = csrf_hash(); + + return $this->respondWithJson($data, $status); + } + + /** @param array $user + * @return array + */ + private function privateProfile(array $user): array + { + return array_merge($this->publicProfile($user), [ + 'email' => $user['email'], + 'theme' => $user['theme'] ?? 'system', + 'notification_prefs' => json_decode((string) ($user['notification_prefs'] ?? ''), true) ?: ['desktop' => true, 'sound' => true], + ]); + } + + /** @param array $user + * @return array + */ + private function publicProfile(array $user): array + { + $userId = (int) $user['id']; + + return [ + 'id' => $userId, + 'username' => $user['username'], + 'display_name' => $user['display_name'] ?: $user['username'], + 'avatar_url' => $user['avatar_path'] ? site_url("profile/avatar/{$userId}") : null, + 'presence' => $user['presence'] ?? 'offline', + 'last_seen_at' => $user['last_seen_at'] ?? null, + ]; + } +} diff --git a/app/Database/Migrations/2026-09-15-000000_add_user_profile_fields.php b/app/Database/Migrations/2026-09-15-000000_add_user_profile_fields.php new file mode 100644 index 0000000..26e725c --- /dev/null +++ b/app/Database/Migrations/2026-09-15-000000_add_user_profile_fields.php @@ -0,0 +1,79 @@ +forge->addColumn('users', [ + 'display_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 100, + 'null' => true, + ], + 'avatar_path' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + ], + 'theme' => [ + 'type' => 'VARCHAR', + 'constraint' => 16, + 'default' => 'system', + ], + 'notification_prefs' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'presence' => [ + 'type' => 'VARCHAR', + 'constraint' => 16, + 'default' => 'offline', + ], + 'last_seen_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + } + + public function down(): void + { + if (! $this->db instanceof BaseConnection) { + throw new RuntimeException('A database connection is required to remove user profile fields.'); + } + + $db = $this->db; + $db->resetDataCache(); + + if (! $db->tableExists('users')) { + return; + } + + $columns = [ + 'display_name', + 'avatar_path', + 'theme', + 'notification_prefs', + 'presence', + 'last_seen_at', + ]; + + if ($db->DBDriver === 'SQLite3') { + $table = $db->protectIdentifiers('users', true, false); + + foreach ($columns as $column) { + $db->query("ALTER TABLE {$table} DROP COLUMN {$column}"); + } + + return; + } + + $this->forge->dropColumn('users', $columns); + } +} diff --git a/app/Helpers/UserHelper.php b/app/Helpers/UserHelper.php index 2d75a2c..05f9c82 100644 --- a/app/Helpers/UserHelper.php +++ b/app/Helpers/UserHelper.php @@ -22,6 +22,11 @@ public static function setUserSession(array $user): void 'user_id' => $user['id'], 'username' => $user['username'], 'email' => $user['email'], + 'display_name' => $user['display_name'] ?? null, + 'avatar_path' => $user['avatar_path'] ?? null, + 'theme' => $user['theme'] ?? 'system', + 'notification_prefs' => $user['notification_prefs'] ?? null, + 'presence' => $user['presence'] ?? 'offline', 'logged_in' => true, ]; @@ -35,6 +40,16 @@ public static function setUserSession(array $user): void */ public static function clearUserSession(): void { - session()->remove(['user_id', 'username', 'email', 'logged_in']); + session()->remove([ + 'user_id', + 'username', + 'email', + 'display_name', + 'avatar_path', + 'theme', + 'notification_prefs', + 'presence', + 'logged_in', + ]); } } diff --git a/app/Libraries/ChatWebSocketServer.php b/app/Libraries/ChatWebSocketServer.php index 1524866..08479f1 100644 --- a/app/Libraries/ChatWebSocketServer.php +++ b/app/Libraries/ChatWebSocketServer.php @@ -2,8 +2,10 @@ namespace App\Libraries; +use App\Contracts\UserRepository; use App\Helpers\WebSocketTokenHelper; use App\Models\ChatModel; +use App\Models\UserModel; use CodeIgniter\I18n\Time; use Exception; use Ratchet\ConnectionInterface; @@ -70,6 +72,11 @@ class ChatWebSocketServer implements MessageComponentInterface */ protected bool $requireAuth; + private readonly UserRepository $users; + + /** @var array */ + private array $presenceUsers = []; + /** * Ephemeral typing state keyed by authenticated user ID. * @@ -90,11 +97,13 @@ class ChatWebSocketServer implements MessageComponentInterface public function __construct( ?SplObjectStorage $clients = null, ?ChatModel $chatModel = null, - bool $requireAuth = true + bool $requireAuth = true, + ?UserRepository $users = null, ) { $this->clients = $clients ?? new SplObjectStorage(); $this->chatModel = $chatModel ?? new ChatModel(); $this->requireAuth = $requireAuth; + $this->users = $users ?? new UserModel(); $this->logServerStart(); } @@ -142,6 +151,10 @@ public function onOpen(ConnectionInterface $conn): void 'authenticated' => $this->requireAuth ? true : ($userId > 0), ]); + if ($this->requireAuth) { + $this->connectPresence($userId); + } + // Log successful connection $authStatus = $this->requireAuth ? "(authenticated, user_id: {$userId})" : '(auth disabled)'; echo 'New connection! (' . spl_object_id($conn) . ") {$authStatus}\n"; @@ -193,6 +206,10 @@ public function onMessage(ConnectionInterface $from, $msg) case 'typing_stop': $this->handleTypingEvent($from, $data, $messageType); break; + + case 'presence_update': + $this->handlePresenceUpdate($from, $data); + break; } } @@ -215,11 +232,17 @@ public function onClose(ConnectionInterface $conn): void // Remove the connection from our storage $this->clients->detach($conn); - if (is_int($userId) && isset($this->typingUsers[$userId])) { + if (is_int($userId) && ! $this->isUserConnected($userId) && isset($this->typingUsers[$userId])) { unset($this->typingUsers[$userId]); $this->broadcastTypingState(); } + if (is_int($userId) && $userId > 0 && ! $this->isUserConnected($userId) && isset($this->presenceUsers[$userId])) { + unset($this->presenceUsers[$userId]); + $this->users->updatePresence($userId, 'offline', date('Y-m-d H:i:s')); + $this->broadcastPresenceState(); + } + echo 'Connection ' . spl_object_id($conn) . " (user_id: {$userId}) has disconnected\n"; } @@ -587,6 +610,71 @@ private function broadcastTypingState(?ConnectionInterface $except = null): void } } + private function connectPresence(int $userId): void + { + $user = $this->users->findUserById($userId); + if ($user === null) { + return; + } + + $presence = in_array($user['presence'] ?? null, ['away', 'busy'], true) ? $user['presence'] : 'online'; + $this->users->updatePresence($userId, $presence, null); + $this->presenceUsers[$userId] = $this->presenceProfile($user, $presence, null); + $this->broadcastPresenceState(); + } + + /** @param array $data */ + private function handlePresenceUpdate(ConnectionInterface $from, array $data): void + { + if (! $this->clients->contains($from)) { + return; + } + + $connectionData = $this->clients[$from]; + $userId = $connectionData['user_id'] ?? 0; + $presence = $data['presence'] ?? null; + if (($connectionData['authenticated'] ?? false) !== true || ! is_int($userId) || ! in_array($presence, ['online', 'away', 'busy'], true)) { + return; + } + + $user = $this->users->findUserById($userId); + if ($user === null) { + return; + } + + $this->users->updatePresence($userId, $presence, null); + $this->presenceUsers[$userId] = $this->presenceProfile($user, $presence, null); + $this->broadcastPresenceState(); + } + + private function broadcastPresenceState(): void + { + $users = array_values($this->presenceUsers); + usort($users, static fn (array $left, array $right): int => [$left['display_name'], $left['user_id']] <=> [$right['display_name'], $right['user_id']]); + $payload = json_encode(['type' => 'presence_state', 'users' => $users]); + + foreach ($this->clients as $client) { + $client->send($payload); + } + } + + /** @param array $user + * @return array{user_id: int, username: string, display_name: string, avatar_url: ?string, presence: string, last_seen_at: ?string} + */ + private function presenceProfile(array $user, string $presence, ?string $lastSeenAt): array + { + $userId = (int) $user['id']; + + return [ + 'user_id' => $userId, + 'username' => (string) $user['username'], + 'display_name' => (string) ($user['display_name'] ?: $user['username']), + 'avatar_url' => $user['avatar_path'] ? "/profile/avatar/{$userId}" : null, + 'presence' => $presence, + 'last_seen_at' => $lastSeenAt, + ]; + } + /** * Handle the sendMessage action * diff --git a/app/Models/UserModel.php b/app/Models/UserModel.php index 082734a..5f4bd82 100644 --- a/app/Models/UserModel.php +++ b/app/Models/UserModel.php @@ -9,7 +9,19 @@ class UserModel extends Model implements UserRepository { protected $table = 'users'; protected $primaryKey = 'id'; - protected $allowedFields = ['username', 'email', 'password', 'created_at', 'updated_at']; + protected $allowedFields = [ + 'username', + 'email', + 'password', + 'display_name', + 'avatar_path', + 'theme', + 'notification_prefs', + 'presence', + 'last_seen_at', + 'created_at', + 'updated_at', + ]; protected $useTimestamps = true; protected $createdField = 'created_at'; protected $updatedField = 'updated_at'; @@ -38,6 +50,12 @@ public function findUserByEmail(string $email): ?array return $this->where('email', $email)->first(); } + /** @return array|null */ + public function findUserById(int $userId): ?array + { + return $this->find($userId); + } + /** * Create a new user * @@ -74,4 +92,35 @@ public function verifyCredentials(string $username, string $password): ?array return null; } + + /** @param array $profile */ + public function updateProfile(int $userId, array $profile): bool + { + return $this->update($userId, $profile); + } + + /** @return list> */ + public function findPublicProfilesByUsernames(array $usernames): array + { + $usernames = array_values(array_unique(array_filter( + array_map(static fn (mixed $username): mixed => is_string($username) ? trim($username) : $username, $usernames), + static fn (mixed $username): bool => is_string($username) && $username !== '', + ))); + + if ($usernames === []) { + return []; + } + + return $this->select('id, username, display_name, avatar_path, presence, last_seen_at') + ->whereIn('username', $usernames) + ->findAll(); + } + + public function updatePresence(int $userId, string $presence, ?string $lastSeenAt): bool + { + return $this->update($userId, [ + 'presence' => $presence, + 'last_seen_at' => $lastSeenAt, + ]); + } } diff --git a/app/Views/chat/svelteView.php b/app/Views/chat/svelteView.php index 99b4d79..8582a5c 100644 --- a/app/Views/chat/svelteView.php +++ b/app/Views/chat/svelteView.php @@ -1,5 +1,5 @@ - + CodeIgniter Chat - Svelte Edition @@ -76,7 +76,10 @@ update: "", // GET endpoint for fetching messages (JSON format) messagesApi: "", - pushSubscriptions: "" + pushSubscriptions: "", + profile: "", + profileAvatar: "", + profiles: "" }; // CSRF protection token name (CodeIgniter's built-in XSS protection) diff --git a/app/Views/chat/vueView.php b/app/Views/chat/vueView.php index 8ee2849..fde390b 100644 --- a/app/Views/chat/vueView.php +++ b/app/Views/chat/vueView.php @@ -1,5 +1,5 @@ - + CodeIgniter Chat - Vue.js Edition @@ -33,7 +33,10 @@ window.CHAT_ROUTES = { update: "", messagesApi: "", - pushSubscriptions: "" + pushSubscriptions: "", + profile: "", + profileAvatar: "", + profiles: "" }; // CSRF protection token name (CodeIgniter's built-in XSS protection) diff --git a/app/Views/profile/index.php b/app/Views/profile/index.php new file mode 100644 index 0000000..e11e052 --- /dev/null +++ b/app/Views/profile/index.php @@ -0,0 +1,15 @@ + + + + + + Your profile + + +
+

Your profile

+

Profile settings are also available from the Vue and Svelte chat clients.

+

Return to chat

+
+ + diff --git a/composer.json b/composer.json index 00cb2ea..ec8a5b3 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,8 @@ }, "require": { "php": "^8.4", + "ext-fileinfo": "*", + "ext-gd": "*", "ext-sockets": "*", "cboden/ratchet": "^0.4.4", "codeigniter4/framework": "^4.7", diff --git a/composer.lock b/composer.lock index 4c48512..bf009b2 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e277bb8963471f170a4c396888d52d58", + "content-hash": "98e93d2cc8fed02d6f12e2a41a6f4df0", "packages": [ { "name": "cboden/ratchet", @@ -6789,6 +6789,8 @@ "prefer-lowest": false, "platform": { "php": "^8.4", + "ext-fileinfo": "*", + "ext-gd": "*", "ext-sockets": "*" }, "platform-dev": {}, diff --git a/docs/user-profiles.md b/docs/user-profiles.md new file mode 100644 index 0000000..8b0048c --- /dev/null +++ b/docs/user-profiles.md @@ -0,0 +1,56 @@ +# User profiles + +Authenticated users can personalize how they appear in the Vue and Svelte chat clients. Profile settings are stored in the `users` table and restored each time the user signs in. + +## Profile fields + +- `display_name`: optional name shown in chat; the username remains the account and message identity. +- `avatar_path`: server-managed relative path for the user's WebP avatar. +- `theme`: `system`, `light`, or `dark`. +- `notification_prefs`: JSON containing the desktop-notification and sound choices. +- `presence`: `online`, `away`, `busy`, or `offline`. +- `last_seen_at`: set when the user's final WebSocket connection closes. + +## HTTP endpoints + +All profile routes require an authenticated session. Write routes also use the normal CSRF and write-rate filters. + +| Method | Route | Purpose | +| --- | --- | --- | +| `GET` | `/profile` | Render the profile entry page. | +| `POST` | `/profile` | Update profile settings. | +| `POST` | `/profile/avatar` | Upload and replace the current user's avatar. | +| `GET` | `/profile/avatar/{userId}` | Return an authenticated user's avatar. | +| `GET` | `/api/v1/profile` | Return the signed-in user's complete profile. | +| `POST` | `/api/v1/profile` | Update the signed-in user's profile as JSON. | +| `POST` | `/api/v1/profile/avatar` | Upload an avatar from the Vue or Svelte client. | +| `GET` | `/api/v1/profiles?usernames=alice,bob` | Return public presentation fields for message authors. | + +Public profile responses deliberately omit email, theme, notification preferences, and password data. + +## Avatars + +Uploads accept JPEG, PNG, and WebP images up to 2 MB and 1024 by 1024 pixels. The server reads the real image contents, center-crops the image, resizes it to 256 by 256 pixels, and saves it as `writable/uploads/avatars/{userId}.webp`. User-controlled filenames are never used for storage or retrieval. + +When a user has no avatar, both modern chat clients render initials derived from the display name or username. + +The PHP runtime must provide the GD and fileinfo extensions. They are declared as Composer platform requirements so deployment checks fail clearly when either extension is missing. + +## Presence lifecycle + +The WebSocket server publishes a complete `presence_state` message whenever an authenticated user connects, changes status, or fully disconnects. A user with several tabs remains present until the final connection closes. At that point the server stores `offline` and updates `last_seen_at`. + +Clients may change the active status with: + +```json +{ + "type": "presence_update", + "presence": "away" +} +``` + +The server derives the user ID from the authenticated connection and ignores invalid states. Presence messages also contain the display name and avatar URL needed by the Vue and Svelte clients. + +## Themes and sessions + +The selected theme is stored in the database and copied into the authenticated session at login. Server-rendered Vue and Svelte entry pages set the initial `data-theme` value before JavaScript loads, and the profile panels apply changes immediately. Logging out clears the session copy; logging in reads the saved database value again. diff --git a/src/css/chat.scss b/src/css/chat.scss index 20a8ad7..42f1b44 100644 --- a/src/css/chat.scss +++ b/src/css/chat.scss @@ -18,6 +18,152 @@ $border-radius: 4px; --chat-safe-area-left: env(safe-area-inset-left, 0px); } +html[data-theme="dark"] { + color-scheme: dark; + + body { + background: #17212b; + color: #f4f7fa; + } + + .chat-container, + .messages, + .message-form, + .search-panel, + .message-item { + background: #202c38; + color: #f4f7fa; + } + + input, + textarea, + select { + background: #17212b; + color: #f4f7fa; + border-color: #7f91a3; + } +} + +html[data-theme="light"] { + color-scheme: light; +} + +@media (prefers-color-scheme: dark) { + html[data-theme="system"] { + color-scheme: dark; + + body { + background: #17212b; + color: #f4f7fa; + } + + .chat-container, + .messages, + .message-form, + .search-panel, + .message-item { + background: #202c38; + color: #f4f7fa; + } + } +} + +.profile-menu { + position: relative; + display: inline-block; +} + +.profile-panel { + position: absolute; + z-index: 20; + top: calc(100% + 0.5rem); + right: 0; + width: min(22rem, calc(100vw - 2rem)); + padding: 1rem; + border: 1px solid #9aa8b8; + border-radius: 0.5rem; + background: Canvas; + color: CanvasText; + box-shadow: 0 0.75rem 2rem rgb(0 0 0 / 25%); + + h2 { + margin-top: 0; + font-size: 1.1rem; + } + + form { + display: grid; + gap: 0.5rem; + margin-top: 0.75rem; + } + + input:not([type="checkbox"]), + select { + box-sizing: border-box; + width: 100%; + padding: 0.45rem; + } +} + +.profile-status { + color: #167343; +} + +.message-item { + display: flex; + gap: 0.75rem; +} + +.message-body { + min-width: 0; + flex: 1; +} + +.message-avatar { + position: relative; + display: grid; + width: 2.5rem; + height: 2.5rem; + flex: 0 0 2.5rem; + place-items: center; + overflow: visible; + border-radius: 50%; + background: #3f6398; + color: #fff; + font-size: 0.8rem; + font-weight: 700; + + img { + width: 100%; + height: 100%; + border-radius: inherit; + object-fit: cover; + } +} + +.presence-dot { + position: absolute; + right: -0.1rem; + bottom: -0.1rem; + width: 0.65rem; + height: 0.65rem; + border: 2px solid Canvas; + border-radius: 50%; + background: #7b8794; +} + +.presence-online { + background: #16883e; +} + +.presence-away { + background: #d97706; +} + +.presence-busy { + background: #c52835; +} + html, body { min-width: 0; diff --git a/src/js/profile.js b/src/js/profile.js new file mode 100644 index 0000000..e869dea --- /dev/null +++ b/src/js/profile.js @@ -0,0 +1,31 @@ +export function applyTheme(theme) { + const selectedTheme = ['light', 'dark', 'system'].includes(theme) ? theme : 'system'; + document.documentElement.dataset.theme = selectedTheme; + document.documentElement.style.colorScheme = selectedTheme === 'system' ? 'light dark' : selectedTheme; +} + +export function csrfToken() { + return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''; +} + +export async function readJson(response) { + const payload = await response.json(); + if (payload?.csrf_token) { + document.querySelector('meta[name="csrf-token"]')?.setAttribute('content', payload.csrf_token); + } + if (!response.ok) { + throw new Error(payload?.error?.message || 'The profile request failed.'); + } + + return payload; +} + +export function editableProfile(profile) { + const editablePresences = ['online', 'away', 'busy']; + + return { + ...profile, + presence: editablePresences.includes(profile?.presence) ? profile.presence : 'online', + notification_prefs: profile?.notification_prefs || { desktop: true, sound: true }, + }; +} diff --git a/src/svelte/App.svelte b/src/svelte/App.svelte index ff1f532..b2d0769 100644 --- a/src/svelte/App.svelte +++ b/src/svelte/App.svelte @@ -45,6 +45,7 @@ subscribePwa, userScopedMessagesUrl, } from '../js/pwa.js'; + import ProfilePanel from './ProfilePanel.svelte'; // ============================================================================ // PROPS - Data passed from main.js @@ -125,6 +126,61 @@ let typingUsers = $state([]); let lastTypingSentAt = $state(0); let typingStopTimeout = null; + let displayName = $state(''); + let profiles = $state({}); + const requestedProfiles = new Set(); + + function handleOwnProfile(profile) { + displayName = profile.display_name || config.username; + mergeProfiles([profile]); + if (webSocketConnected) { + webSocket.send(JSON.stringify({ type: 'presence_update', presence: profile.presence })); + } + } + + function profileFor(username) { + return profiles[username] || { username, display_name: username, avatar_url: null, presence: 'offline' }; + } + + function initials(name) { + return String(name || '?') + .trim() + .split(/\s+/) + .slice(0, 2) + .map((part) => part[0]) + .join('') + .toUpperCase(); + } + + function mergeProfiles(profileList) { + profiles = Object.fromEntries([ + ...Object.entries(profiles), + ...profileList.map((profile) => [profile.username, profile]), + ]); + } + + async function loadProfiles(usernames) { + usernames = usernames + .map((username) => String(username).trim()) + .filter((username) => username && !requestedProfiles.has(username)); + if (usernames.length === 0) return; + usernames.forEach((username) => { + requestedProfiles.add(username); + }); + try { + const params = new URLSearchParams({ usernames: usernames.join(',') }); + const response = await fetch(`${config.chatRoutes.profiles}?${params}`); + if (!response.ok) return; + mergeProfiles((await response.json()).profiles || []); + } catch { + // Fallback initials and usernames remain usable when profiles are unavailable. + } + } + + $effect(() => { + const usernames = [...new Set(messages.map((chatMessage) => chatMessage.user).filter(Boolean))]; + void loadProfiles(usernames); + }); // ============================================================================ // DERIVED STATE - Computed values @@ -269,6 +325,10 @@ typingUsers = Array.isArray(data.users) ? data.users : []; return; } + if (data.type === 'presence_state') { + mergeProfiles(data.users || []); + return; + } // Handle different message types (actions) from the server switch (data.action) { @@ -1115,13 +1175,14 @@

CodeIgniter Chat

@@ -1231,17 +1292,27 @@ {#each messages as msg, index (index)}
-
- {msg.user} - {#if msg.timestamp} - + +
+
+ {profileFor(msg.user).display_name} + ({profileFor(msg.user).presence}) + {#if msg.timestamp} + + {/if} +
+ +
{@html formatMessage(msg.msg)}
- - -
{@html formatMessage(msg.msg)}
{/each} diff --git a/src/svelte/ProfilePanel.svelte b/src/svelte/ProfilePanel.svelte new file mode 100644 index 0000000..bdac2d1 --- /dev/null +++ b/src/svelte/ProfilePanel.svelte @@ -0,0 +1,132 @@ + + +
+ + {#if open} +
+

Profile settings

+
+ + + + + + + + + +
+
+ + + +
+ {#if status} +

{status}

+ {/if} + {#if error} + + {/if} +
+ {/if} +
diff --git a/src/svelte/main.js b/src/svelte/main.js index 44c9341..1d52ea9 100644 --- a/src/svelte/main.js +++ b/src/svelte/main.js @@ -41,6 +41,9 @@ const config = { update: window.CHAT_ROUTES?.update || '/api/v1/messages', api: window.CHAT_ROUTES?.messagesApi || '/api/v1/messages', pushSubscriptions: window.CHAT_ROUTES?.pushSubscriptions || '/api/v1/push-subscriptions', + profile: window.CHAT_ROUTES?.profile || '/api/v1/profile', + profileAvatar: window.CHAT_ROUTES?.profileAvatar || '/api/v1/profile/avatar', + profiles: window.CHAT_ROUTES?.profiles || '/api/v1/profiles', }, // CSRF token name for secure form submissions diff --git a/src/vue/App.vue b/src/vue/App.vue index ecd687e..a4ee95d 100644 --- a/src/vue/App.vue +++ b/src/vue/App.vue @@ -4,11 +4,12 @@

CodeIgniter Chat

@@ -109,13 +110,21 @@ class="message-item" :aria-label="`Message from ${message.user}`" > -
- {{ message.user }} - + +
+
+ {{ profileFor(message.user).display_name }} + ({{ profileFor(message.user).presence }}) + +
+
-
{{ searchActive ? 'No messages match your search.' : 'No messages yet. Be the first to send a message!' }} @@ -193,12 +202,15 @@ subscribePwa, userScopedMessagesUrl, } from '../js/pwa.js'; + import ProfilePanel from './ProfilePanel.vue'; export default { + components: { ProfilePanel }, data() { return { // User information (from global properties set in main.js) username: this.$username, + displayName: this.$username, userId: this.$userId, // WebSocket authentication token (generated on login, validated by server) @@ -255,8 +267,15 @@ typingUsers: [], lastTypingSentAt: 0, typingStopTimeout: null, + profiles: {}, + requestedProfiles: {}, }; }, + watch: { + messages() { + void this.loadProfiles(); + }, + }, computed: { canEnableNotifications() { return Boolean(this.$pushPublicKey && !this.notificationsEnabled && 'PushManager' in window); @@ -292,6 +311,56 @@ this.unsubscribePwa?.(); }, methods: { + handleOwnProfile(profile) { + this.displayName = profile.display_name || this.username; + this.mergeProfiles([profile]); + if (this.webSocketConnected) { + this.webSocket.send(JSON.stringify({ type: 'presence_update', presence: profile.presence })); + } + }, + profileFor(username) { + return ( + this.profiles[username] || { + username, + display_name: username, + avatar_url: null, + presence: 'offline', + } + ); + }, + initials(name) { + return String(name || '?') + .trim() + .split(/\s+/) + .slice(0, 2) + .map((part) => part[0]) + .join('') + .toUpperCase(); + }, + mergeProfiles(profiles) { + this.profiles = Object.fromEntries([ + ...Object.entries(this.profiles), + ...profiles.map((profile) => [profile.username, profile]), + ]); + }, + async loadProfiles() { + const usernames = [ + ...new Set(this.messages.map((message) => String(message.user || '').trim()).filter(Boolean)), + ].filter((username) => this.requestedProfiles[username] !== true); + if (usernames.length === 0) return; + this.requestedProfiles = Object.fromEntries([ + ...Object.entries(this.requestedProfiles), + ...usernames.map((username) => [username, true]), + ]); + try { + const params = new URLSearchParams({ usernames: usernames.join(',') }); + const response = await fetch(`${this.$chatRoutes.profiles}?${params}`); + if (!response.ok) return; + this.mergeProfiles((await response.json()).profiles || []); + } catch { + // Fallback initials and usernames remain usable when profiles are unavailable. + } + }, clearMessage() { this.stopTyping(); this.message = ''; @@ -384,6 +453,10 @@ this.typingUsers = Array.isArray(data.users) ? data.users : []; return; } + if (data.type === 'presence_state') { + this.mergeProfiles(data.users || []); + return; + } // Handle different message types (actions) from the server switch (data.action) { diff --git a/src/vue/ProfilePanel.vue b/src/vue/ProfilePanel.vue new file mode 100644 index 0000000..8723cf2 --- /dev/null +++ b/src/vue/ProfilePanel.vue @@ -0,0 +1,134 @@ + + + diff --git a/src/vue/main.js b/src/vue/main.js index 1cbeb62..152824a 100644 --- a/src/vue/main.js +++ b/src/vue/main.js @@ -27,6 +27,9 @@ app.config.globalProperties.$chatRoutes = { update: window.CHAT_ROUTES.update, api: window.CHAT_ROUTES.messagesApi, pushSubscriptions: window.CHAT_ROUTES.pushSubscriptions, + profile: window.CHAT_ROUTES.profile, + profileAvatar: window.CHAT_ROUTES.profileAvatar, + profiles: window.CHAT_ROUTES.profiles, }; // CSRF token name for form submissions diff --git a/tests/Tests/Integration/ProfileIntegrationTest.php b/tests/Tests/Integration/ProfileIntegrationTest.php new file mode 100644 index 0000000..328b78e --- /dev/null +++ b/tests/Tests/Integration/ProfileIntegrationTest.php @@ -0,0 +1,212 @@ + */ + private array $temporaryFiles = []; + + protected function tearDown(): void + { + foreach ($this->temporaryFiles as $file) { + if (is_file($file)) { + unlink($file); + } + } + + parent::tearDown(); + } + + public function testProfileApiRequiresAuthentication(): void + { + $result = $this->withHeaders(['Origin' => 'http://localhost']) + ->call('get', '/api/v1/profile'); + + $this->assertSame(401, $result->response()->getStatusCode()); + } + + public function testAuthenticatedUserCanReadAndUpdateProfile(): void + { + $model = new UserModel(); + $userId = $model->createUser('profile-user', 'profile@example.com', 'Password123!'); + $this->assertIsInt($userId); + + $getResult = $this->loginAs([ + 'id' => $userId, + 'username' => 'profile-user', + 'email' => 'profile@example.com', + ])->withHeaders(['Origin' => 'http://localhost']) + ->call('get', '/api/v1/profile'); + + $this->assertSame(200, $getResult->response()->getStatusCode()); + $profile = json_decode($getResult->getJSON(), true, flags: JSON_THROW_ON_ERROR)['profile']; + $this->assertSame('profile-user', $profile['display_name']); + $this->assertSame('system', $profile['theme']); + + $updateResult = $this->withHeaders([ + 'Origin' => 'http://localhost', + 'Content-Type' => 'application/json', + 'X-CSRF-TOKEN' => csrf_hash(), + ])->withBody(json_encode([ + 'display_name' => 'Profile User', + 'theme' => 'dark', + 'presence' => 'away', + 'notification_prefs' => ['desktop' => false, 'sound' => true], + ], JSON_THROW_ON_ERROR))->call('post', '/api/v1/profile'); + + $this->assertSame(200, $updateResult->response()->getStatusCode()); + $updated = json_decode($updateResult->getJSON(), true, flags: JSON_THROW_ON_ERROR)['profile']; + $this->assertSame('Profile User', $updated['display_name']); + $this->assertSame('dark', $updated['theme']); + $this->assertFalse($updated['notification_prefs']['desktop']); + } + + public function testProfileUpdateRejectsInvalidPreferences(): void + { + $model = new UserModel(); + $userId = $model->createUser('profile-user', 'profile@example.com', 'Password123!'); + $this->assertIsInt($userId); + + $result = $this->loginAs([ + 'id' => $userId, + 'username' => 'profile-user', + 'email' => 'profile@example.com', + ])->withHeaders([ + 'Origin' => 'http://localhost', + 'Content-Type' => 'application/json', + 'X-CSRF-TOKEN' => csrf_hash(), + ])->withBody(json_encode([ + 'theme' => 'ultraviolet', + 'presence' => 'invisible', + ], JSON_THROW_ON_ERROR))->call('post', '/api/v1/profile'); + + $this->assertSame(422, $result->response()->getStatusCode()); + } + + public function testPublicProfileLookupDoesNotExposePrivateFields(): void + { + $model = new UserModel(); + $userId = $model->createUser('profile-user', 'profile@example.com', 'Password123!'); + $this->assertIsInt($userId); + + $result = $this->loginAs([ + 'id' => $userId, + 'username' => 'profile-user', + 'email' => 'profile@example.com', + ])->withHeaders(['Origin' => 'http://localhost']) + ->call('get', '/api/v1/profiles?usernames=profile-user'); + + $this->assertSame(200, $result->response()->getStatusCode()); + $profile = json_decode($result->getJSON(), true, flags: JSON_THROW_ON_ERROR)['profiles'][0]; + $this->assertSame('profile-user', $profile['username']); + $this->assertArrayNotHasKey('email', $profile); + $this->assertArrayNotHasKey('theme', $profile); + } + + public function testAvatarUploadIsCroppedToA256PixelWebp(): void + { + $model = new UserModel(); + $userId = $model->createUser('avatar-user', 'avatar@example.com', 'Password123!'); + $this->assertIsInt($userId); + $source = $this->createPng(400, 200); + $avatarPath = WRITEPATH . "uploads/avatars/{$userId}.webp"; + $this->temporaryFiles[] = $avatarPath; + + $result = $this->uploadAvatar($source, $userId); + + $this->assertSame(201, $result->response()->getStatusCode()); + $this->assertFileExists($avatarPath); + $image = getimagesize($avatarPath); + $this->assertIsArray($image); + $this->assertSame([256, 256, IMAGETYPE_WEBP], [$image[0], $image[1], $image[2]]); + $user = $model->findUserById($userId); + $this->assertNotNull($user); + $this->assertSame("avatars/{$userId}.webp", $user['avatar_path']); + } + + public function testAvatarUploadRejectsImagesLargerThan1024Pixels(): void + { + $model = new UserModel(); + $userId = $model->createUser('avatar-user', 'avatar@example.com', 'Password123!'); + $this->assertIsInt($userId); + + $result = $this->uploadAvatar($this->createPng(1025, 20), $userId); + + $this->assertSame(422, $result->response()->getStatusCode()); + $payload = json_decode($result->getJSON(), true, flags: JSON_THROW_ON_ERROR); + $this->assertStringContainsString('1024', $payload['error']['message']); + } + + public function testThemeIsReappliedAfterLogoutAndLogin(): void + { + $model = new UserModel(); + $userId = $model->createUser('theme-user', 'theme@example.com', 'Password123!'); + $this->assertIsInt($userId); + $model->updateProfile($userId, ['theme' => 'dark']); + + $login = fn () => $this->withHeaders(['Origin' => 'http://localhost']) + ->call('post', '/auth/processLogin', [ + 'username' => 'theme-user', + 'password' => 'Password123!', + csrf_token() => csrf_hash(), + ]); + + $this->assertSame(302, $login()->response()->getStatusCode()); + $this->assertSame('dark', session()->get('theme')); + + $this->withHeaders(['Origin' => 'http://localhost'])->call('get', '/auth/logout'); + $this->assertFalse(session()->has('theme')); + + $this->assertSame(302, $login()->response()->getStatusCode()); + $this->assertSame('dark', session()->get('theme')); + } + + private function createPng(int $width, int $height): string + { + $path = tempnam(sys_get_temp_dir(), 'profile-avatar-'); + $this->assertNotFalse($path); + $image = imagecreatetruecolor($width, $height); + imagefill($image, 0, 0, imagecolorallocate($image, 40, 100, 160)); + imagepng($image, $path); + imagedestroy($image); + $this->temporaryFiles[] = $path; + + return $path; + } + + private function uploadAvatar(string $source, int $userId): TestResponse + { + session()->set(['logged_in' => true, 'user_id' => $userId, 'username' => 'avatar-user']); + $request = service('incomingrequest', config(\Config\App::class), false); + $files = new FileCollection(); + (new ReflectionProperty(FileCollection::class, 'files'))->setValue($files, [ + 'avatar' => new ProfileTestUploadedFile($source, 'avatar.png', 'image/png', filesize($source), UPLOAD_ERR_OK), + ]); + (new ReflectionProperty($request, 'files'))->setValue($request, $files); + + $controller = new Profile(new UserModel()); + $controller->initController($request, service('response', null, false), Services::logger()); + + return new TestResponse($controller->uploadAvatar()); + } +} + +final class ProfileTestUploadedFile extends UploadedFile +{ + public function isValid(): bool + { + return true; + } +} diff --git a/tests/Tests/Integration/UserProfileRepositoryIntegrationTest.php b/tests/Tests/Integration/UserProfileRepositoryIntegrationTest.php new file mode 100644 index 0000000..0f64d6c --- /dev/null +++ b/tests/Tests/Integration/UserProfileRepositoryIntegrationTest.php @@ -0,0 +1,60 @@ +createUser('profile-user', 'profile@example.com', 'Password123!'); + + $this->assertIsInt($userId); + $user = $model->findUserById($userId); + + $this->assertNotNull($user); + $this->assertNull($user['display_name']); + $this->assertNull($user['avatar_path']); + $this->assertSame('system', $user['theme']); + $this->assertNull($user['notification_prefs']); + $this->assertSame('offline', $user['presence']); + $this->assertNull($user['last_seen_at']); + } + + public function testProfilesCanBeUpdatedAndReadPublicly(): void + { + $model = new UserModel(); + $userId = $model->createUser('profile-user', 'profile@example.com', 'Password123!'); + $this->assertIsInt($userId); + + $this->assertTrue($model->updateProfile($userId, [ + 'display_name' => 'Profile User', + 'avatar_path' => 'avatars/1.webp', + 'theme' => 'dark', + 'notification_prefs' => '{"desktop":true}', + ])); + $this->assertTrue($model->updatePresence($userId, 'away', '2026-09-15 12:00:00')); + + $user = $model->findUserById($userId); + $this->assertNotNull($user); + $this->assertSame('Profile User', $user['display_name']); + $this->assertSame('dark', $user['theme']); + $this->assertSame('away', $user['presence']); + + $profiles = $model->findPublicProfilesByUsernames([' profile-user ', '', 'profile-user']); + $this->assertCount(1, $profiles); + $this->assertSame('profile-user', $profiles[0]['username']); + $this->assertArrayNotHasKey('email', $profiles[0]); + $this->assertArrayNotHasKey('password', $profiles[0]); + } + + public function testPublicProfileLookupHandlesAnEmptyUsernameList(): void + { + $this->assertSame([], (new UserModel())->findPublicProfilesByUsernames([])); + } +} diff --git a/tests/Tests/Integration/WebSocketFlowIntegrationTest.php b/tests/Tests/Integration/WebSocketFlowIntegrationTest.php index 4a7f50d..560058e 100644 --- a/tests/Tests/Integration/WebSocketFlowIntegrationTest.php +++ b/tests/Tests/Integration/WebSocketFlowIntegrationTest.php @@ -5,6 +5,7 @@ use App\Helpers\WebSocketTokenHelper; use App\Libraries\ChatWebSocketServer; use App\Models\ChatModel; +use App\Models\UserModel; use GuzzleHttp\Psr7\ServerRequest; use PHPUnit\Framework\Attributes\Group; use Ratchet\ConnectionInterface; @@ -119,6 +120,50 @@ public function testAuthenticatedTypingEventsReachTheOtherClient(): void $stoppedState = json_decode($observer->sent[1], true, flags: JSON_THROW_ON_ERROR); $this->assertSame([], $stoppedState['users']); } + + public function testPresenceBroadcastsAndOnlyGoesOfflineAfterTheLastConnectionCloses(): void + { + $model = new UserModel(); + $aliceId = $model->createUser('alice', 'alice@example.com', 'Password123!'); + $bobId = $model->createUser('bob', 'bob@example.com', 'Password123!'); + $this->assertIsInt($aliceId); + $this->assertIsInt($bobId); + $model->updateProfile($aliceId, ['display_name' => 'Alice A.', 'presence' => 'away']); + + $aliceToken = WebSocketTokenHelper::generateToken($aliceId); + $bobToken = WebSocketTokenHelper::generateToken($bobId); + $aliceFirst = new RecordingConnection("/?token={$aliceToken}&user_id={$aliceId}"); + $aliceSecond = new RecordingConnection("/?token={$aliceToken}&user_id={$aliceId}"); + $bob = new RecordingConnection("/?token={$bobToken}&user_id={$bobId}"); + $server = new ChatWebSocketServer(chatModel: new ChatModel(), users: $model); + + $this->expectOutputRegex('/Chat WebSocket Server started.*New connection!.*New connection!.*New connection!.*has disconnected.*has disconnected/s'); + $server->onOpen($aliceFirst); + $server->onOpen($aliceSecond); + $server->onOpen($bob); + + $presence = json_decode($bob->sent[array_key_last($bob->sent)], true, flags: JSON_THROW_ON_ERROR); + $this->assertSame('presence_state', $presence['type']); + $this->assertSame(['Alice A.', 'bob'], array_column($presence['users'], 'display_name')); + $this->assertSame(['away', 'online'], array_column($presence['users'], 'presence')); + + $server->onMessage($aliceFirst, json_encode(['type' => 'presence_update', 'presence' => 'busy'], JSON_THROW_ON_ERROR)); + $presence = json_decode($bob->sent[array_key_last($bob->sent)], true, flags: JSON_THROW_ON_ERROR); + $this->assertSame('busy', $presence['users'][0]['presence']); + + $bobMessagesBeforeClose = count($bob->sent); + $server->onClose($aliceFirst); + $this->assertSame($bobMessagesBeforeClose, count($bob->sent), 'Closing one tab must not mark the user offline.'); + + $server->onClose($aliceSecond); + $presence = json_decode($bob->sent[array_key_last($bob->sent)], true, flags: JSON_THROW_ON_ERROR); + $this->assertSame(['bob'], array_column($presence['users'], 'username')); + + $alice = $model->findUserById($aliceId); + $this->assertNotNull($alice); + $this->assertSame('offline', $alice['presence']); + $this->assertNotNull($alice['last_seen_at']); + } } final class RecordingConnection implements ConnectionInterface From dbe4aefc9c92fef25693a484f5db6648c467d78a Mon Sep 17 00:00:00 2001 From: Logan Lindquist Land Date: Tue, 15 Sep 2026 16:46:01 -0500 Subject: [PATCH 2/2] Fix legacy Chromium chat scenarios --- e2e/support/chat-scenarios.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/support/chat-scenarios.ts b/e2e/support/chat-scenarios.ts index 6b76dda..536798d 100644 --- a/e2e/support/chat-scenarios.ts +++ b/e2e/support/chat-scenarios.ts @@ -62,7 +62,7 @@ export function chatScenario(scenario: ChatScenario): void { if (isModernChat) { await page.getByRole('button', { name: 'Send Message' }).click(); } else { - await page.locator(scenario.submitButton).click(); + await page.locator('#chatform').locator(scenario.submitButton).click(); } await expect(page.locator('#messagewindow')).toContainText(message); @@ -402,7 +402,7 @@ function assertNetworkPayload(payload: unknown, format: NetworkFormat): void { } const xml = String(payload); - expect(xml).toContain(''); + expect(xml).toMatch(//); expect(xml).not.toContain(''); }