diff --git a/README.md b/README.md index eac0f77..77193f6 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/api.md` (message reaction HTTP and WebSocket contracts) - `docs/user-profiles.md` (profile settings, avatars, themes, and WebSocket presence) ## Troubleshooting diff --git a/app/Config/App.php b/app/Config/App.php index eef38d9..93489ad 100644 --- a/app/Config/App.php +++ b/app/Config/App.php @@ -81,7 +81,7 @@ class App extends BaseConfig | DO NOT CHANGE THIS UNLESS YOU FULLY UNDERSTAND THE REPERCUSSIONS!! | */ - public string $permittedURIChars = 'a-z 0-9~%.:_\-'; + public string $permittedURIChars = 'a-z 0-9~%.:_\-\p{S}\p{M}\x{200D}'; /** * -------------------------------------------------------------------------- diff --git a/app/Config/RateLimit.php b/app/Config/RateLimit.php index 00bdef8..d03387b 100644 --- a/app/Config/RateLimit.php +++ b/app/Config/RateLimit.php @@ -26,6 +26,10 @@ class RateLimit extends BaseConfig 'anonymous' => [10, 60], 'authenticated' => [30, 60], ], + 'react' => [ + 'anonymous' => [10, 60], + 'authenticated' => [120, 60], + ], ]; public function __construct() diff --git a/app/Config/Routes.php b/app/Config/Routes.php index e24278c..c8e83df 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -35,6 +35,9 @@ $routes->get('messages', 'MessagesController::list', ['filter' => ['apiFormat', 'auth']]); $routes->get('messages/search', 'MessagesController::search', ['filter' => ['apiFormat', 'auth']]); $routes->post('messages', 'MessagesController::create', ['filter' => ['apiFormat', 'auth', 'rate:write', 'validate:message']]); + $routes->get('messages/(:num)/reactions', 'ReactionsController::list/$1', ['filter' => ['apiFormat', 'auth']]); + $routes->post('messages/(:num)/reactions', 'ReactionsController::create/$1', ['filter' => ['apiFormat', 'auth', 'rate:react']]); + $routes->delete('messages/(:num)/reactions/(:segment)', 'ReactionsController::delete/$1/$2', ['filter' => ['apiFormat', 'auth', 'rate:react']]); $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']]); diff --git a/app/Config/Services.php b/app/Config/Services.php index aefc557..8cb7232 100644 --- a/app/Config/Services.php +++ b/app/Config/Services.php @@ -8,9 +8,11 @@ use App\Contracts\ChatRepository; use App\Contracts\CspReportRepository; use App\Contracts\PushSubscriptionRepository; +use App\Contracts\ReactionRepository; use App\Contracts\UserRepository; use App\Controllers\Api\V1\MessagesController; use App\Controllers\Api\V1\PushSubscriptionsController; +use App\Controllers\Api\V1\ReactionsController; use App\Controllers\AuditLog; use App\Controllers\Auth; use App\Controllers\Chat; @@ -22,6 +24,7 @@ use App\Models\AuditLogModel; use App\Models\ChatModel; use App\Models\CspReportModel; +use App\Models\MessageReactionModel; use App\Models\PushSubscriptionModel; use App\Models\UserModel; use App\Services\AuditLogger; @@ -69,6 +72,7 @@ public static function controller(string $controllerClass): Controller Chat::class => new Chat(static::chatRepository(), static::chatFormatter()), MessagesController::class => new MessagesController(static::chatRepository(), static::chatFormatter()), PushSubscriptionsController::class => new PushSubscriptionsController(static::pushSubscriptionRepository()), + ReactionsController::class => new ReactionsController(static::reactionRepository()), Auth::class => new Auth(static::userRepository(), static::auditLogger()), Profile::class => new Profile(static::userRepository()), CspReport::class => new CspReport(static::cspReportRepository()), @@ -122,6 +126,15 @@ public static function pushSubscriptionRepository(bool $getShared = true): PushS return new PushSubscriptionModel(); } + public static function reactionRepository(bool $getShared = true): ReactionRepository + { + if ($getShared) { + return static::getSharedInstance('reactionRepository'); + } + + return new MessageReactionModel(); + } + public static function auditLogRepository(bool $getShared = true): AuditLogRepository { if ($getShared) { diff --git a/app/Contracts/ReactionRepository.php b/app/Contracts/ReactionRepository.php new file mode 100644 index 0000000..e357bc0 --- /dev/null +++ b/app/Contracts/ReactionRepository.php @@ -0,0 +1,15 @@ +, reacted_by_current_user: bool}> */ + public function forMessage(int $messageId, ?int $currentUserId = null): array; +} diff --git a/app/Controllers/Api/V1/ReactionsController.php b/app/Controllers/Api/V1/ReactionsController.php new file mode 100644 index 0000000..c24c57c --- /dev/null +++ b/app/Controllers/Api/V1/ReactionsController.php @@ -0,0 +1,126 @@ +reactions->messageExists($messageId)) { + return $this->notFound(); + } + + return $this->reactionJson($this->reactions->forMessage($messageId, $this->currentUserId())); + } + + public function create(int $messageId): ResponseInterface + { + if (! $this->reactions->messageExists($messageId)) { + return $this->notFound(); + } + + $payload = $this->request->getJSON(true); + $emoji = is_array($payload) ? $this->validEmoji($payload['emoji'] ?? null) : null; + if ($emoji === null) { + return $this->validationError(); + } + + if (! $this->reactions->add($messageId, $this->currentUserId(), $emoji)) { + return $this->reactionJson([ + 'error' => ['type' => 'storage', 'message' => 'The reaction could not be saved.'], + ], 500); + } + + return $this->reactionJson($this->state($messageId, $emoji), 201) + ->setHeader('X-CSRF-TOKEN', csrf_hash()); + } + + public function delete(int $messageId, string $emoji): ResponseInterface + { + if (! $this->reactions->messageExists($messageId)) { + return $this->notFound(); + } + + $emoji = $this->validEmoji(rawurldecode($emoji)); + if ($emoji === null) { + return $this->validationError(); + } + + $this->reactions->remove($messageId, $this->currentUserId(), $emoji); + + return $this->response + ->setStatusCode(204) + ->setHeader('Cache-Control', 'no-store') + ->setHeader('X-CSRF-TOKEN', csrf_hash()); + } + + /** @return array{message_id: int, reaction: array{emoji: string, count: int, users: list, reacted_by_current_user: bool}} */ + private function state(int $messageId, string $emoji): array + { + foreach ($this->reactions->forMessage($messageId, $this->currentUserId()) as $reaction) { + if ($reaction['emoji'] === $emoji) { + return ['message_id' => $messageId, 'reaction' => $reaction]; + } + } + + return [ + 'message_id' => $messageId, + 'reaction' => ['emoji' => $emoji, 'count' => 0, 'users' => [], 'reacted_by_current_user' => false], + ]; + } + + private function validEmoji(mixed $value): ?string + { + if (! is_string($value)) { + return null; + } + + $value = trim($value); + if ( + $value === '' + || mb_strlen($value) > 10 + || strlen($value) > 64 + || preg_match('/[\p{C}\p{Z}]/u', $value) === 1 + || preg_match('/\p{Extended_Pictographic}/u', $value) !== 1 + ) { + return null; + } + + return $value; + } + + private function currentUserId(): int + { + return (int) $this->session->get('user_id'); + } + + private function notFound(): ResponseInterface + { + return $this->reactionJson([ + 'error' => ['type' => 'not_found', 'message' => 'Message not found.'], + ], 404); + } + + private function validationError(): ResponseInterface + { + return $this->reactionJson([ + 'error' => ['type' => 'validation', 'message' => 'emoji must be a valid emoji of at most 10 characters.'], + ], 422); + } + + private function reactionJson(mixed $data, int $status = 200): ResponseInterface + { + $response = $this->respondWithJson($data, $status); + $response->removeHeader('Cache-Control'); + + return $response->setHeader('Cache-Control', 'no-store'); + } +} diff --git a/app/Database/Migrations/2026-09-15-010000_create_message_reactions_table.php b/app/Database/Migrations/2026-09-15-010000_create_message_reactions_table.php new file mode 100644 index 0000000..9d99722 --- /dev/null +++ b/app/Database/Migrations/2026-09-15-010000_create_message_reactions_table.php @@ -0,0 +1,46 @@ +forge->addField([ + 'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true], + 'message_id' => ['type' => 'INT', 'constraint' => 7], + 'user_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true], + 'emoji' => ['type' => 'VARCHAR', 'constraint' => 64], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey('message_id'); + $this->forge->addKey('user_id'); + $this->forge->addUniqueKey(['message_id', 'user_id', 'emoji'], 'uq_message_user_emoji'); + $this->forge->addForeignKey('message_id', 'messages', 'id', 'CASCADE', 'CASCADE'); + $this->forge->addForeignKey('user_id', 'users', 'id', 'CASCADE', 'CASCADE'); + $this->forge->createTable('message_reactions'); + + if ($this->db->getPlatform() === 'MySQLi') { + $table = $this->database()->prefixTable('message_reactions'); + $this->database()->query("ALTER TABLE {$table} MODIFY emoji VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL"); + } + } + + public function down(): void + { + $this->forge->dropTable('message_reactions'); + } + + private function database(): BaseConnection + { + if (! $this->db instanceof BaseConnection) { + throw new \LogicException('Reaction migrations require a CodeIgniter base database connection.'); + } + + return $this->db; + } +} diff --git a/app/Libraries/ChatWebSocketServer.php b/app/Libraries/ChatWebSocketServer.php index 08479f1..4476150 100644 --- a/app/Libraries/ChatWebSocketServer.php +++ b/app/Libraries/ChatWebSocketServer.php @@ -2,9 +2,11 @@ namespace App\Libraries; +use App\Contracts\ReactionRepository; use App\Contracts\UserRepository; use App\Helpers\WebSocketTokenHelper; use App\Models\ChatModel; +use App\Models\MessageReactionModel; use App\Models\UserModel; use CodeIgniter\I18n\Time; use Exception; @@ -74,6 +76,11 @@ class ChatWebSocketServer implements MessageComponentInterface private readonly UserRepository $users; + private readonly ReactionRepository $reactions; + + /** @var array> */ + private array $reactionHistory = []; + /** @var array */ private array $presenceUsers = []; @@ -99,11 +106,13 @@ public function __construct( ?ChatModel $chatModel = null, bool $requireAuth = true, ?UserRepository $users = null, + ?ReactionRepository $reactions = null, ) { $this->clients = $clients ?? new SplObjectStorage(); $this->chatModel = $chatModel ?? new ChatModel(); $this->requireAuth = $requireAuth; $this->users = $users ?? new UserModel(); + $this->reactions = $reactions ?? new MessageReactionModel(); $this->logServerStart(); } @@ -210,6 +219,11 @@ public function onMessage(ConnectionInterface $from, $msg) case 'presence_update': $this->handlePresenceUpdate($from, $data); break; + + case 'reaction_add': + case 'reaction_remove': + $this->handleReaction($from, $data, $messageType); + break; } } @@ -699,12 +713,13 @@ private function handleSendMessage(ConnectionInterface $from, array $data, int $ $timestamp = Time::now()->getTimestamp(); // Insert message into database - $this->chatModel->insertMsg($username, $message, $timestamp); + $messageId = $this->chatModel->insertMsg($username, $message, $timestamp); // Prepare the message data for broadcasting $messageData = [ 'action' => 'newMessage', 'data' => [ + 'id' => $messageId, 'user' => $username, 'msg' => $message, 'timestamp' => $timestamp, @@ -717,4 +732,110 @@ private function handleSendMessage(ConnectionInterface $from, array $data, int $ $client->send(json_encode($messageData)); } } + + /** @param array $data */ + private function handleReaction(ConnectionInterface $from, array $data, string $messageType): void + { + if (! $this->clients->contains($from)) { + return; + } + + $connectionData = $this->clients[$from]; + $userId = $connectionData['user_id'] ?? 0; + $messageId = $data['message_id'] ?? 0; + $emoji = $this->validReactionEmoji($data['emoji'] ?? null); + if ( + ($connectionData['authenticated'] ?? false) !== true + || ! is_int($userId) + || $userId <= 0 + || ! is_int($messageId) + || $messageId <= 0 + || $emoji === null + || ! $this->reactions->messageExists($messageId) + ) { + $this->sendReactionError($from, 'INVALID_REACTION', 'The reaction request is invalid.'); + + return; + } + + if (! $this->allowReaction($userId)) { + $this->sendReactionError($from, 'REACTION_RATE_LIMITED', 'Too many reactions. Please try again later.'); + + return; + } + + if ($messageType === 'reaction_add') { + $this->reactions->add($messageId, $userId, $emoji); + } else { + $this->reactions->remove($messageId, $userId, $emoji); + } + + $reaction = ['emoji' => $emoji, 'count' => 0, 'users' => []]; + foreach ($this->reactions->forMessage($messageId) as $state) { + if ($state['emoji'] === $emoji) { + $reaction = [ + 'emoji' => $state['emoji'], + 'count' => $state['count'], + 'users' => $state['users'], + ]; + break; + } + } + + $payload = json_encode([ + 'type' => 'reaction', + 'message_id' => $messageId, + ...$reaction, + ], JSON_UNESCAPED_UNICODE); + foreach ($this->clients as $client) { + $client->send($payload); + } + } + + private function allowReaction(int $userId): bool + { + $now = time(); + $history = array_values(array_filter( + $this->reactionHistory[$userId] ?? [], + static fn (int $timestamp): bool => $timestamp > $now - 60, + )); + if (count($history) >= 120) { + $this->reactionHistory[$userId] = $history; + + return false; + } + + $history[] = $now; + $this->reactionHistory[$userId] = $history; + + return true; + } + + private function validReactionEmoji(mixed $value): ?string + { + if (! is_string($value)) { + return null; + } + + $value = trim($value); + if ( + $value === '' + || mb_strlen($value) > 10 + || strlen($value) > 64 + || preg_match('/[\p{C}\p{Z}]/u', $value) === 1 + || preg_match('/\p{Extended_Pictographic}/u', $value) !== 1 + ) { + return null; + } + + return $value; + } + + private function sendReactionError(ConnectionInterface $connection, string $code, string $message): void + { + $connection->send(json_encode([ + 'action' => 'error', + 'data' => ['code' => $code, 'message' => $message], + ])); + } } diff --git a/app/Models/MessageReactionModel.php b/app/Models/MessageReactionModel.php new file mode 100644 index 0000000..8fdddf1 --- /dev/null +++ b/app/Models/MessageReactionModel.php @@ -0,0 +1,78 @@ +db->table('messages')->where('id', $messageId)->countAllResults() === 1; + } + + public function add(int $messageId, int $userId, string $emoji): bool + { + $saved = $this->db->table($this->table)->ignore(true)->insert([ + 'message_id' => $messageId, + 'user_id' => $userId, + 'emoji' => $emoji, + 'created_at' => date('Y-m-d H:i:s'), + ]); + + return $saved && $this->hasReaction($messageId, $userId, $emoji); + } + + public function remove(int $messageId, int $userId, string $emoji): bool + { + return $this->where('message_id', $messageId) + ->where('user_id', $userId) + ->where('emoji', $emoji) + ->delete(); + } + + public function forMessage(int $messageId, ?int $currentUserId = null): array + { + $rows = $this->db->table('message_reactions AS reactions') + ->select('reactions.emoji, reactions.user_id, users.username') + ->join('users', 'users.id = reactions.user_id') + ->where('reactions.message_id', $messageId) + ->orderBy('reactions.emoji', 'ASC') + ->orderBy('users.username', 'ASC') + ->get() + ->getResultArray(); + $grouped = []; + + foreach ($rows as $row) { + $emoji = (string) $row['emoji']; + $grouped[$emoji] ??= [ + 'emoji' => $emoji, + 'count' => 0, + 'users' => [], + 'reacted_by_current_user' => false, + ]; + ++$grouped[$emoji]['count']; + $grouped[$emoji]['users'][] = (string) $row['username']; + if ($currentUserId !== null && (int) $row['user_id'] === $currentUserId) { + $grouped[$emoji]['reacted_by_current_user'] = true; + } + } + + return array_values($grouped); + } + + private function hasReaction(int $messageId, int $userId, string $emoji): bool + { + return $this->where('message_id', $messageId) + ->where('user_id', $userId) + ->where('emoji', $emoji) + ->countAllResults() === 1; + } +} diff --git a/docs/api-versioning.md b/docs/api-versioning.md index da71cea..8158cd4 100644 --- a/docs/api-versioning.md +++ b/docs/api-versioning.md @@ -23,6 +23,9 @@ with the message endpoints at `/api/v1/messages`. | `POST` | `/api/v1/messages` | Create a message | | `GET` | `/api/v1/messages/search` | Search messages by text, exact user, or Unix time range | | `GET` | `/api/v1/messages/xml` | Paginated XML messages | +| `GET` | `/api/v1/messages/{id}/reactions` | List reactions grouped by emoji | +| `POST` | `/api/v1/messages/{id}/reactions` | Add the current user's reaction | +| `DELETE` | `/api/v1/messages/{id}/reactions/{emoji}` | Remove the current user's reaction | All endpoints use the existing session authentication and CSRF rules. The canonical payload and error envelopes are documented in diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..99e99e5 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,55 @@ +# Message Reaction API + +All reaction endpoints require an authenticated session. Write requests require +the normal CSRF token and use the `react` rate-limit profile (120 requests per +minute for authenticated users). + +## Endpoints + +### `GET /api/v1/messages/{messageId}/reactions` + +Returns a JSON array grouped by emoji: + +```json +[{"emoji":"๐Ÿ‘","count":2,"users":["alice","bob"],"reacted_by_current_user":true}] +``` + +### `POST /api/v1/messages/{messageId}/reactions` + +Send JSON such as `{"emoji":"๐Ÿ‘"}`. The emoji must contain an emoji code +point, contain no whitespace or control characters, and be at most 10 Unicode +characters (64 UTF-8 bytes). The response is `201 Created` with the current +aggregate state. Repeating the same request is idempotent because storage has a +unique `(message_id, user_id, emoji)` constraint. + +### `DELETE /api/v1/messages/{messageId}/reactions/{emoji}` + +URL-encode the emoji path segment. Only the authenticated user's matching +reaction is removed. The response is `204 No Content`, including when that +reaction was already absent. + +Unknown message IDs return `404`; invalid emoji return `422`. + +## WebSocket events + +Authenticated clients add or remove their own reaction with: + +```json +{"type":"reaction_add","message_id":42,"emoji":"๐Ÿ‘"} +``` + +```json +{"type":"reaction_remove","message_id":42,"emoji":"๐Ÿ‘"} +``` + +The server derives the user from the authenticated connection, applies the same +120-per-minute limit, persists the change, and broadcasts the complete current +state for that emoji: + +```json +{"type":"reaction","message_id":42,"emoji":"๐Ÿ‘","count":2,"users":["alice","bob"]} +``` + +The Vue and Svelte clients use WebSockets while connected and the HTTP API as a +fallback. Both provide the same eight-emoji accessible picker and togglable +reaction badges. diff --git a/docs/public-api.md b/docs/public-api.md index d4e5a36..d96255e 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -340,6 +340,18 @@ curl -sS -b cookies.txt -c cookies.txt \ "$BASE_URL/api/v1/messages" | python -m json.tool ``` +#### Message reactions + +Authenticated clients can list, add, and remove emoji reactions: + +- `GET /api/v1/messages/{messageId}/reactions` +- `POST /api/v1/messages/{messageId}/reactions` with JSON `{ "emoji": "๐Ÿ‘" }` +- `DELETE /api/v1/messages/{messageId}/reactions/{emoji}` + +Write requests require CSRF protection and use the `react` rate-limit profile +(120 requests per minute for authenticated users). See +[`api.md`](api.md) for payloads, validation, and WebSocket events. + ### CSP violation reporting #### `POST /csp-report` @@ -509,10 +521,23 @@ backward compatible: ```json { "action": "newMessage", - "data": { "user": "alice", "msg": "Hello", "timestamp": 1700000000 } + "data": { "id": 42, "user": "alice", "msg": "Hello", "timestamp": 1700000000 } } ``` +#### Client โ†’ server: reaction changes + +```json +{ "type": "reaction_add", "message_id": 42, "emoji": "๐Ÿ‘" } +``` + +Use `reaction_remove` to remove the authenticated user's reaction. The server +validates and rate-limits the event, then broadcasts the aggregate state: + +```json +{ "type": "reaction", "message_id": 42, "emoji": "๐Ÿ‘", "count": 2, "users": ["alice", "bob"] } +``` + #### Client โ†’ server: typing events The Vue and Svelte clients announce typing activity with the authenticated diff --git a/docs/user-guide.md b/docs/user-guide.md index d954277..d014fc7 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -204,6 +204,13 @@ The Vue.js and Svelte chat interfaces include a search panel above the message l While search filters are active, new live messages are not inserted into the filtered result list. Use **Refresh search** to rerun the same filters, or clear the search to return to the live feed. +### Reacting to Messages + +In the Vue.js and Svelte chats, select the **React to message** button beneath a +message and choose an emoji. Reaction badges show the total and identify the +people who reacted to assistive technology. Select an active badge to remove +your own reaction. Changes appear live for connected users. + ### Loading Older Messages By default, the chat shows the most recent messages. To load older messages: diff --git a/e2e/support/chat-scenarios.ts b/e2e/support/chat-scenarios.ts index 536798d..f1d8762 100644 --- a/e2e/support/chat-scenarios.ts +++ b/e2e/support/chat-scenarios.ts @@ -46,6 +46,7 @@ export function chatScenario(scenario: ChatScenario): void { await assertModernChatAccessibility(page, scenario.messageInput); await assertModernChatTyping(page, scenario.messageInput); await assertModernChatSearch(page); + await assertModernChatReactions(page); await expect(page.locator('link[rel="manifest"]')).toHaveAttribute('href', '/manifest.webmanifest'); await page.context().setOffline(true); await expect(page.getByText('Youโ€™re offline.')).toBeVisible(); @@ -279,10 +280,35 @@ async function assertModernChatSearch(page: Page): Promise { await expect(page.getByRole('alert')).toBeHidden(); } +async function assertModernChatReactions(page: Page): Promise { + const message = page.locator('.message-item').first(); + await message.hover(); + await message.getByRole('button', { name: /React to message from/ }).click(); + const picker = page.getByRole('group', { name: 'Choose a reaction' }); + await expect(picker).toBeVisible(); + await picker.getByRole('button', { name: 'React with ๐Ÿ‘' }).click(); + + const badge = page.getByRole('button', { name: /๐Ÿ‘ reaction from e2euser\. 1 total\./ }); + await expect(badge).toBeVisible(); + await expect(badge).toHaveAttribute('aria-pressed', 'true'); + await badge.click(); + await expect(badge).toBeHidden(); + await expect + .poll(async () => page.evaluate(() => window.__chatReactionRequests)) + .toEqual([ + { type: 'reaction_add', message_id: 1, emoji: '๐Ÿ‘' }, + { type: 'reaction_remove', message_id: 1, emoji: '๐Ÿ‘' }, + ]); +} + async function installModernChatWebSocket(page: Page): Promise { + await page.route(/\/api\/v1\/messages\/\d+\/reactions$/, async (route) => { + await route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }); + }); await page.addInitScript(() => { window.__chatSearchRequests = []; window.__chatTypingRequests = []; + window.__chatReactionRequests = []; window.__expectedSearchDates = { from: Math.floor(new Date(2026, 8, 13, 0, 0, 0, 0).getTime() / 1000), to: Math.floor(new Date(2026, 8, 14, 23, 59, 59, 999).getTime() / 1000), @@ -305,6 +331,15 @@ async function installModernChatWebSocket(page: Page): Promise { type: 'typing_state', users: request.type === 'typing_start' ? [{ user_id: 999, username: 'Teammate' }] : [], }; + } else if (request.type === 'reaction_add' || request.type === 'reaction_remove') { + window.__chatReactionRequests.push(request); + response = { + type: 'reaction', + message_id: request.message_id, + emoji: request.emoji, + count: request.type === 'reaction_add' ? 1 : 0, + users: request.type === 'reaction_add' ? ['e2euser'] : [], + }; } else if (request.action === 'getMessages' && request.search) { window.__chatSearchRequests.push(request); const page = request.page || 1; @@ -352,6 +387,7 @@ async function installModernChatWebSocket(page: Page): Promise { data: { messages: [ { + id: 1, user: 'mobile-overflow-check-user-with-a-very-long-name', msg: `mobile-overflow-check-${'x'.repeat(460)}`, timestamp: Math.floor(Date.now() / 1000) - 60, @@ -364,6 +400,7 @@ async function installModernChatWebSocket(page: Page): Promise { response = { action: 'newMessage', data: { + id: 2, user: request.username, msg: request.message, timestamp: Math.floor(Date.now() / 1000), diff --git a/src/config/reactions.ts b/src/config/reactions.ts new file mode 100644 index 0000000..fc87f2e --- /dev/null +++ b/src/config/reactions.ts @@ -0,0 +1 @@ +export const reactionEmojis = ['๐Ÿ‘', 'โค๏ธ', '๐Ÿ˜‚', '๐ŸŽ‰', '๐Ÿ˜ฎ', '๐Ÿ˜ข', '๐Ÿ‘', '๐Ÿ”ฅ'] as const; diff --git a/src/css/chat.scss b/src/css/chat.scss index 42f1b44..842a7f6 100644 --- a/src/css/chat.scss +++ b/src/css/chat.scss @@ -119,6 +119,62 @@ html[data-theme="light"] { flex: 1; } +.message-reactions { + position: relative; + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + margin-top: 0.45rem; +} + +.reaction-badge, +.reaction-picker-toggle, +.reaction-picker button { + min-width: 2.75rem; + min-height: 2.75rem; + border: 1px solid #9aa7b8; + border-radius: 999px; + background: #fff; + color: #172b4d; + cursor: pointer; +} + +.reaction-badge.active { + border-color: #3f6398; + background: #e7eef8; + font-weight: 700; +} + +.reaction-picker-toggle { + opacity: 0; + transition: opacity 0.15s ease; +} + +.message-item:is(:hover, :focus-within) .reaction-picker-toggle { + opacity: 1; +} + +.reaction-picker { + position: absolute; + bottom: 3rem; + left: 0; + z-index: 5; + display: flex; + gap: 0.25rem; + padding: 0.4rem; + border: 1px solid #9aa7b8; + border-radius: $border-radius; + background: #fff; + box-shadow: 0 0.35rem 1rem rgb(23 43 77 / 20%); + margin: 0; +} + +@media (hover: none) { + .reaction-picker-toggle { + opacity: 1; + } +} + .message-avatar { position: relative; display: grid; diff --git a/src/svelte/App.svelte b/src/svelte/App.svelte index b2d0769..d216a3f 100644 --- a/src/svelte/App.svelte +++ b/src/svelte/App.svelte @@ -38,6 +38,7 @@