diff --git a/README.md b/README.md index d0429fc..68a0324 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,7 @@ See: - `docs/api.md` (message reaction HTTP and WebSocket contracts) - `docs/user-profiles.md` (profile settings, avatars, themes, and WebSocket presence) - `docs/channels.md` (public channels, private DMs, APIs, WebSocket routing, and migration operations) +- `docs/operations.md` (message archiving schedules, batching, and export storage) ## Troubleshooting diff --git a/app/Commands/ArchiveMessages.php b/app/Commands/ArchiveMessages.php new file mode 100644 index 0000000..86f6d42 --- /dev/null +++ b/app/Commands/ArchiveMessages.php @@ -0,0 +1,98 @@ + 'Required age such as 90d, 24h, or 30m.', + '--channel' => 'Channel ID or all (default: all).', + '--batch-size' => 'Rows per transaction, from 1 to 10000 (default: 1000).', + ]; + + public function run(array $params): int + { + $seconds = self::parseAge($params['older-than'] ?? CLI::getOption('older-than')); + $channelId = self::parseChannel($params['channel'] ?? CLI::getOption('channel') ?? 'all'); + $batchSize = self::parseBatchSize($params['batch-size'] ?? CLI::getOption('batch-size') ?? '1000'); + + if ($seconds === null) { + CLI::error('The --older-than option is required and must look like 90d, 24h, or 30m.'); + + return 1; + } + if ($channelId === false) { + CLI::error('The --channel option must be a positive channel ID or all.'); + + return 1; + } + if ($batchSize === null) { + CLI::error('The --batch-size option must be an integer between 1 and 10000.'); + + return 1; + } + + try { + $count = Services::chatRepository()->archiveMessagesBefore( + time() - $seconds, + $channelId, + $batchSize, + ); + } catch (Throwable $exception) { + CLI::error('Unable to archive messages: ' . $exception->getMessage()); + + return 1; + } + + CLI::write(sprintf('Archived %s message%s.', number_format($count), $count === 1 ? '' : 's'), 'green'); + + return 0; + } + + public static function parseAge(mixed $value): ?int + { + if (! is_string($value) || preg_match('/^([1-9]\d*)([mhd])$/', strtolower($value), $matches) !== 1) { + return null; + } + + $multiplier = match ($matches[2]) { + 'm' => 60, + 'h' => 3_600, + 'd' => 86_400, + }; + $seconds = (int) $matches[1] * $multiplier; + + return $seconds > 0 ? $seconds : null; + } + + public static function parseChannel(mixed $value): int|false|null + { + if ($value === 'all') { + return null; + } + if (! is_string($value) || preg_match('/^[1-9]\d*$/', $value) !== 1 || (float) $value > PHP_INT_MAX) { + return false; + } + + return (int) $value; + } + + public static function parseBatchSize(mixed $value): ?int + { + if (! is_string($value) || preg_match('/^[1-9]\d*$/', $value) !== 1) { + return null; + } + $batchSize = (int) $value; + + return $batchSize <= 10_000 ? $batchSize : null; + } +} diff --git a/app/Config/RateLimit.php b/app/Config/RateLimit.php index d03387b..b66b790 100644 --- a/app/Config/RateLimit.php +++ b/app/Config/RateLimit.php @@ -30,6 +30,10 @@ class RateLimit extends BaseConfig 'anonymous' => [10, 60], 'authenticated' => [120, 60], ], + 'export' => [ + 'anonymous' => [1, 3_600], + 'authenticated' => [5, 3_600], + ], ]; public function __construct() diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 3805f18..785f5da 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -43,6 +43,8 @@ $routes->get('channels/(:num)/messages', 'ChannelsController::messages/$1', ['filter' => ['apiFormat', 'auth']]); $routes->get('channels/(:num)/messages/search', 'ChannelsController::searchMessages/$1', ['filter' => ['apiFormat', 'auth']]); $routes->post('channels/(:num)/messages', 'ChannelsController::createMessage/$1', ['filter' => ['apiFormat', 'auth', 'rate:write']]); + $routes->get('channels/(:num)/export', 'ChannelsController::exportChannel/$1', ['filter' => ['auth', 'rate:export']]); + $routes->get('users/me/export', 'ChannelsController::exportMine', ['filter' => ['auth', 'rate:export']]); $routes->post('dms', 'ChannelsController::createDm', ['filter' => ['apiFormat', 'auth', 'rate:write']]); $routes->get('messages/(:num)/reactions', 'ReactionsController::list/$1', ['filter' => ['apiFormat', 'auth']]); $routes->post('messages/(:num)/reactions', 'ReactionsController::create/$1', ['filter' => ['apiFormat', 'auth', 'rate:react']]); diff --git a/app/Contracts/ChatRepository.php b/app/Contracts/ChatRepository.php index 038f525..6d9ba09 100644 --- a/app/Contracts/ChatRepository.php +++ b/app/Contracts/ChatRepository.php @@ -12,6 +12,14 @@ interface ChatRepository */ public function getMsgPaginated(int $page = 1, int $perPage = 10, ?int $channelId = null): array; + /** + * @return array{ + * messages: list>, + * pagination: array{limit: int, nextBefore: ?int, hasMore: bool} + * } + */ + public function getMessageHistory(int $channelId, ?int $before = null, int $limit = 25): array; + /** * @return array{ * messages: list>, @@ -29,4 +37,9 @@ public function searchMessages( ): array; public function insertMsg(string $name, string $message, int $current, ?int $channelId = null): int|bool; + + public function archiveMessagesBefore(int $timestamp, ?int $channelId = null, int $batchSize = 1_000): int; + + /** @return iterable */ + public function exportMessages(?int $channelId = null, ?string $username = null, int $batchSize = 500): iterable; } diff --git a/app/Controllers/Api/V1/ChannelsController.php b/app/Controllers/Api/V1/ChannelsController.php index 8975c38..1807845 100644 --- a/app/Controllers/Api/V1/ChannelsController.php +++ b/app/Controllers/Api/V1/ChannelsController.php @@ -6,6 +6,7 @@ use App\Contracts\ChatRepository; use App\Contracts\UserRepository; use App\Controllers\BaseController; +use CodeIgniter\HTTP\DownloadResponse; use CodeIgniter\HTTP\ResponseInterface; use Throwable; @@ -174,13 +175,21 @@ public function messages(int $channelId): ResponseInterface return $membershipError; } - $page = $this->positiveIntegerQuery('page', 1, PHP_INT_MAX); - $perPage = $this->positiveIntegerQuery('per_page', 10, 100); - if (is_string($page) || is_string($perPage)) { - return $this->error('validation', is_string($page) ? $page : $perPage, 400); + if ($this->request->getGet('before') !== null || $this->request->getGet('limit') !== null) { + $before = $this->optionalPositiveIntegerQuery('before'); + $limit = $this->positiveIntegerQuery('limit', 25, 100); + if (is_string($before) || is_string($limit)) { + return $this->error('validation', is_string($before) ? $before : $limit, 400); + } + $result = $this->messages->getMessageHistory($channelId, $before, $limit); + } else { + $page = $this->positiveIntegerQuery('page', 1, PHP_INT_MAX); + $perPage = $this->positiveIntegerQuery('per_page', 10, 100); + if (is_string($page) || is_string($perPage)) { + return $this->error('validation', is_string($page) ? $page : $perPage, 400); + } + $result = $this->messages->getMsgPaginated($page, $perPage, $channelId); } - - $result = $this->messages->getMsgPaginated($page, $perPage, $channelId); $this->channels->markRead($channelId, $this->userId()); return $this->channelJson($result); @@ -254,6 +263,27 @@ public function searchMessages(int $channelId): ResponseInterface ]); } + public function exportChannel(int $channelId): ResponseInterface + { + $membershipError = $this->memberChannel($channelId); + if ($membershipError instanceof ResponseInterface) { + return $membershipError; + } + + return $this->exportResponse( + $this->messages->exportMessages(channelId: $channelId), + 'channel-' . $channelId . '-messages', + ); + } + + public function exportMine(): ResponseInterface + { + return $this->exportResponse( + $this->messages->exportMessages(username: (string) $this->getCurrentUsername()), + 'my-messages', + ); + } + private function memberChannel(int $channelId): ?ResponseInterface { $channel = $this->channels->findChannel($channelId); @@ -322,6 +352,19 @@ private function positiveIntegerQuery(string $name, int $default, int $maximum): return (int) $value; } + private function optionalPositiveIntegerQuery(string $name): int|string|null + { + $value = $this->request->getGet($name); + if ($value === null) { + return null; + } + if (! is_string($value) || preg_match('/^[1-9]\d*$/', $value) !== 1 || (float) $value > PHP_INT_MAX) { + return "The {$name} parameter must be a positive integer."; + } + + return (int) $value; + } + /** @return array{value: ?string, error: ?string} */ private function optionalQueryString(string $name, int $maximum): array { @@ -364,4 +407,65 @@ private function channelJson(mixed $data, int $status = 200): ResponseInterface { return $this->respondWithJson($data, $status)->setHeader('X-CSRF-TOKEN', csrf_hash()); } + + /** @param iterable $messages */ + private function exportResponse(iterable $messages, string $basename): ResponseInterface + { + $format = strtolower((string) ($this->request->getGet('format') ?? 'json')); + if (! in_array($format, ['json', 'csv'], true)) { + return $this->error('validation', 'The format parameter must be json or csv.', 400); + } + + $path = tempnam(WRITEPATH . 'cache', 'chat-export-'); + if ($path === false) { + return $this->error('export', 'The export could not be prepared.', 500); + } + $handle = fopen($path, 'wb'); + if ($handle === false) { + @unlink($path); + + return $this->error('export', 'The export could not be prepared.', 500); + } + + if ($format === 'csv') { + fputcsv($handle, ['id', 'channel_id', 'user', 'msg', 'time', 'archived'], escape: ''); + foreach ($messages as $message) { + fputcsv($handle, [ + $message['id'], + $message['channel_id'], + $message['user'], + $message['msg'], + $message['time'], + $message['archived'], + ], escape: ''); + } + } else { + fwrite($handle, '['); + $first = true; + foreach ($messages as $message) { + if (! $first) { + fwrite($handle, ','); + } + fwrite($handle, json_encode($message, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE)); + $first = false; + } + fwrite($handle, ']'); + } + fclose($handle); + register_shutdown_function(static function () use ($path): void { + @unlink($path); + }); + + $response = $this->response->download($path, null, true); + if (! $response instanceof DownloadResponse) { + @unlink($path); + + return $this->error('export', 'The export could not be prepared.', 500); + } + + return $response + ->setFileName($basename . '.' . $format) + ->setContentType($format === 'csv' ? 'text/csv' : 'application/json') + ->setHeader('X-CSRF-TOKEN', csrf_hash()); + } } diff --git a/app/Database/Migrations/2026-09-16-010000_create_archived_messages.php b/app/Database/Migrations/2026-09-16-010000_create_archived_messages.php new file mode 100644 index 0000000..76dde7f --- /dev/null +++ b/app/Database/Migrations/2026-09-16-010000_create_archived_messages.php @@ -0,0 +1,46 @@ +forge->addField([ + 'id' => ['type' => 'INT', 'constraint' => 7], + 'channel_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true], + 'user' => ['type' => 'VARCHAR', 'constraint' => 255], + 'msg' => ['type' => 'TEXT'], + 'time' => ['type' => 'INT', 'constraint' => 11, 'default' => 0], + 'archived_at' => ['type' => 'DATETIME'], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey(['channel_id', 'time', 'id']); + $this->forge->addKey(['user', 'id']); + $this->forge->addForeignKey('channel_id', 'channels', 'id', 'CASCADE', 'CASCADE'); + $this->forge->createTable('archived_messages'); + + if ($this->database()->getPlatform() === 'MySQLi') { + $table = $this->database()->prefixTable('archived_messages'); + $this->database()->query("ALTER TABLE {$table} MODIFY user VARCHAR(255) CHARACTER SET latin1 NOT NULL"); + $this->database()->query("ALTER TABLE {$table} MODIFY msg TEXT CHARACTER SET latin1 NOT NULL"); + } + } + + public function down(): void + { + $this->forge->dropTable('archived_messages'); + } + + private function database(): BaseConnection + { + if (! $this->db instanceof BaseConnection) { + throw new \LogicException('Archive migrations require a CodeIgniter base database connection.'); + } + + return $this->db; + } +} diff --git a/app/Models/ChatModel.php b/app/Models/ChatModel.php index 25f2272..3aa1404 100644 --- a/app/Models/ChatModel.php +++ b/app/Models/ChatModel.php @@ -134,6 +134,54 @@ public function getMsg(int $limit = 10): array return $result['messages']; } + /** + * Read stable channel history across the live and archive tables. + * + * @return array{ + * messages: list>, + * pagination: array{limit: int, nextBefore: ?int, hasMore: bool} + * } + */ + public function getMessageHistory(int $channelId, ?int $before = null, int $limit = 25): array + { + $limit = max(1, min(100, $limit)); + $messages = $this->db->prefixTable('messages'); + $archived = $this->db->prefixTable('archived_messages'); + $cursor = $before === null ? '' : ' AND id < ?'; + $bindings = [$channelId]; + if ($before !== null) { + $bindings[] = $before; + } + $bindings[] = $channelId; + if ($before !== null) { + $bindings[] = $before; + } + $bindings[] = $limit + 1; + + $rows = $this->db->query( + "SELECT id, channel_id, user, msg, time, archived FROM ( + SELECT id, channel_id, user, msg, time, 0 AS archived FROM {$messages} WHERE channel_id = ?{$cursor} + UNION ALL + SELECT id, channel_id, user, msg, time, 1 AS archived FROM {$archived} WHERE channel_id = ?{$cursor} + ) AS channel_history ORDER BY id DESC LIMIT ?", + $bindings, + )->getResultArray(); + + $hasMore = count($rows) > $limit; + if ($hasMore) { + array_pop($rows); + } + + return [ + 'messages' => $rows, + 'pagination' => [ + 'limit' => $limit, + 'nextBefore' => $hasMore && $rows !== [] ? (int) $rows[array_key_last($rows)]['id'] : null, + 'hasMore' => $hasMore, + ], + ]; + } + /** * Search messages using each supported database's native full-text index. * @@ -250,6 +298,80 @@ public function insertMsg(string $name, string $message, int $current, ?int $cha return $result; } + public function archiveMessagesBefore(int $timestamp, ?int $channelId = null, int $batchSize = 1_000): int + { + $batchSize = max(1, min(10_000, $batchSize)); + $archivedCount = 0; + + do { + $builder = $this->db->table('messages')->where('time <', $timestamp)->orderBy('id')->limit($batchSize); + if ($channelId !== null) { + $builder->where('channel_id', $channelId); + } + $rows = $builder->get()->getResultArray(); + if ($rows === []) { + break; + } + + $ids = array_map(static fn (array $row): int => (int) $row['id'], $rows); + $archivedAt = date('Y-m-d H:i:s'); + $archiveRows = array_map(static fn (array $row): array => [ + 'id' => (int) $row['id'], + 'channel_id' => (int) $row['channel_id'], + 'user' => (string) $row['user'], + 'msg' => (string) $row['msg'], + 'time' => (int) $row['time'], + 'archived_at' => $archivedAt, + ], $rows); + + $this->db->transException(true)->transStart(); + $this->db->table('archived_messages')->insertBatch($archiveRows); + $this->db->table('messages')->whereIn('id', $ids)->delete(); + $this->db->transComplete(); + + $archivedCount += count($rows); + $this->invalidateCache(); + } while (count($rows) === $batchSize); + + return $archivedCount; + } + + public function exportMessages(?int $channelId = null, ?string $username = null, int $batchSize = 500): iterable + { + $batchSize = max(1, min(5_000, $batchSize)); + $messages = $this->db->prefixTable('messages'); + $archived = $this->db->prefixTable('archived_messages'); + $after = 0; + + do { + $where = ['id > ?']; + $tableBindings = [$after]; + if ($channelId !== null) { + $where[] = 'channel_id = ?'; + $tableBindings[] = $channelId; + } + if ($username !== null) { + $where[] = 'user = ?'; + $tableBindings[] = $username; + } + $condition = implode(' AND ', $where); + $bindings = [...$tableBindings, ...$tableBindings, $batchSize]; + $rows = $this->db->query( + "SELECT id, channel_id, user, msg, time, archived FROM ( + SELECT id, channel_id, user, msg, time, 0 AS archived FROM {$messages} WHERE {$condition} + UNION ALL + SELECT id, channel_id, user, msg, time, 1 AS archived FROM {$archived} WHERE {$condition} + ) AS message_export ORDER BY id ASC LIMIT ?", + $bindings, + )->getResultArray(); + + foreach ($rows as $row) { + $after = (int) $row['id']; + yield $row; + } + } while (count($rows) === $batchSize); + } + /** * Get messages by user with caching and pagination * diff --git a/docs/api.md b/docs/api.md index 99e99e5..5bc425d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,4 +1,51 @@ -# Message Reaction API +# Chat API details + +## Message history and exports + +All history and export routes require an authenticated channel member. The +channel history endpoint uses an ID cursor so newly arriving messages cannot +shift an older page: + +```http +GET /api/v1/channels/12/messages?limit=25 +GET /api/v1/channels/12/messages?before=481&limit=25 +``` + +`limit` defaults to 25 and is capped at 100. Results are newest first. Pass the +returned `nextBefore` value as the next `before` cursor while `hasMore` is true: + +```json +{ + "messages": [ + {"id": 481, "channel_id": 12, "user": "alice", "msg": "Hello", "time": 1700000000, "archived": 0} + ], + "pagination": {"limit": 25, "nextBefore": 457, "hasMore": true} +} +``` + +The repository reads live and archived rows transparently. An `archived` value +of `1` identifies read-only archived history; reactions are available only on +live messages. + +Download a member-visible channel as a JSON array or CSV file: + +```http +GET /api/v1/channels/12/export?format=json +GET /api/v1/channels/12/export?format=csv +``` + +Download every message authored by the signed-in user, across all channels: + +```http +GET /api/v1/users/me/export?format=json +``` + +Exports are built incrementally in a temporary file and the response sends the +file in bounded chunks. This avoids retaining a large export in PHP memory. +Both routes use the `export` rate-limit profile: five downloads per authenticated +user per hour, regardless of channel. + +## 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 diff --git a/docs/channels.md b/docs/channels.md index 2a393f4..0d6be3f 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -12,6 +12,8 @@ to `#general`; they do not need to send a channel identifier. - `channel_members` stores membership and the last-read message identifier used for unread counts. - `messages.channel_id` is required and indexed with message time and ID. +- `archived_messages` preserves original message IDs after old rows leave the + live table. Cursor reads combine both tables; archived rows are read-only. - The migration creates `#general`, enrolls existing users, and assigns every existing message to it. New users are enrolled when they first use a channel endpoint or open an authenticated WebSocket connection. @@ -33,9 +35,11 @@ normal CSRF token and write-rate limit. | `POST` | `/api/v1/channels/{id}/join` | Join an active public channel | | `POST` | `/api/v1/channels/{id}/leave` | Leave a public channel other than `#general` | | `POST` | `/api/v1/dms` | Find or create an idempotent two-person DM by user ID | -| `GET` | `/api/v1/channels/{id}/messages?page=1&per_page=10` | Read member-scoped messages and mark them read | +| `GET` | `/api/v1/channels/{id}/messages?limit=25&before={id}` | Read cursor-paginated live and archived messages and mark the channel read | | `POST` | `/api/v1/channels/{id}/messages` | Post a member-scoped message | | `GET` | `/api/v1/channels/{id}/messages/search` | Search messages within one member-scoped channel | +| `GET` | `/api/v1/channels/{id}/export?format=json|csv` | Download a member-scoped channel history | +| `GET` | `/api/v1/users/me/export?format=json|csv` | Download all messages authored by the current user | Create a public channel: diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..7829c78 --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,52 @@ +# Operations + +## Archiving old messages + +The `messages:archive` Spark command moves old rows from `messages` to +`archived_messages`. Original message and channel IDs are preserved, so channel +cursor reads and exports continue across the live/archive boundary. + +Preview the scope with a database count, then archive every channel older than +90 days: + +```bash +php spark messages:archive --older-than 90d --channel all +``` + +Limit the run to one channel or tune the transaction size: + +```bash +php spark messages:archive --older-than 30d --channel 12 --batch-size 500 +``` + +Accepted age suffixes are `m` (minutes), `h` (hours), and `d` (days). Each +batch is inserted into the archive and removed from the live table in one +transaction. A completed batch is therefore safe to re-run; a later invocation +finds only remaining live rows. The default batch size is 1,000 and the maximum +is 10,000. + +Archived messages are read-only. Because reactions belong to live messages, +their rows are removed by the existing foreign-key cascade when a message is +archived. Search also remains live-message-only. + +### Scheduling + +Run from the application directory so CodeIgniter loads the intended `.env`. +For example, archive messages older than 90 days every day at 02:15: + +```cron +15 2 * * * cd /srv/codeigniter-chat && /usr/bin/php spark messages:archive --older-than 90d --channel all >> writable/logs/archive.log 2>&1 +``` + +On MySQL, each batch briefly writes both tables and deletes from the live table. +Start with a smaller batch on busy installations, monitor transaction-log and +replication growth, and increase only after measuring. Back up the database +before the first production archive run. Restoring archived rows is deliberately +outside the command's scope and requires a manual SQL recovery. + +## Export storage + +Channel and current-user exports write to the application's `writable/cache` +directory and stream the completed file to the client in bounded chunks. Make +sure this directory is writable and has enough temporary space for the largest +expected export. Temporary files are removed at request shutdown. diff --git a/e2e/support/chat-scenarios.ts b/e2e/support/chat-scenarios.ts index eb08160..0dbacd9 100644 --- a/e2e/support/chat-scenarios.ts +++ b/e2e/support/chat-scenarios.ts @@ -47,6 +47,7 @@ export function chatScenario(scenario: ChatScenario): void { await assertModernChatTyping(page, scenario.messageInput); await assertModernChatSearch(page); await assertModernChatReactions(page); + await assertModernChatHistory(page); await expect(page.getByRole('navigation', { name: 'Channels and direct messages' })).toBeVisible(); await expect(page.getByRole('button', { name: '# General' })).toHaveAttribute('aria-current', 'page'); await expect(page.getByLabel('Start a direct message')).toBeVisible(); @@ -300,12 +301,58 @@ async function assertModernChatReactions(page: Page): Promise { await expect .poll(async () => page.evaluate(() => window.__chatReactionRequests)) .toEqual([ - { type: 'reaction_add', message_id: 1, emoji: '👍' }, - { type: 'reaction_remove', message_id: 1, emoji: '👍' }, + { type: 'reaction_add', message_id: 19, emoji: '👍' }, + { type: 'reaction_remove', message_id: 19, emoji: '👍' }, ]); } +async function assertModernChatHistory(page: Page): Promise { + const messageLog = page.getByRole('log', { name: 'Chat messages' }); + await messageLog.evaluate((element) => { + element.scrollTop = 0; + }); + await expect(messageLog).toContainText('Archived boundary message'); + await expect.poll(async () => messageLog.evaluate((element) => element.scrollTop)).toBeGreaterThan(0); + await messageLog.evaluate((element) => { + element.scrollTop = 0; + }); + await expect(page.getByText('Beginning of channel')).toBeVisible(); +} + async function installModernChatWebSocket(page: Page): Promise { + await page.route(/\/api\/v1\/channels\/\d+\/messages(?:\?.*)?$/, async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (request.method() !== 'GET' || !url.searchParams.has('limit')) { + await route.continue(); + return; + } + + const before = url.searchParams.get('before'); + const now = Math.floor(Date.now() / 1000); + const messages = before + ? [ + { id: 18, channel_id: 1, user: 'e2euser', msg: 'Older live message', time: now - 7200 }, + { id: 17, channel_id: 1, user: 'e2euser', msg: 'Archived boundary message', time: now - 10800 }, + ] + : Array.from({ length: 12 }, (_, index) => ({ + id: 30 - index, + channel_id: 1, + user: 'mobile-overflow-check-user-with-a-very-long-name', + msg: `mobile-overflow-check-${'x'.repeat(460)}`, + time: now - index * 60, + })); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + messages, + pagination: before + ? { limit: 25, nextBefore: null, hasMore: false } + : { limit: 25, nextBefore: 19, hasMore: true }, + }), + }); + }); await page.route(/\/api\/v1\/messages\/\d+\/reactions$/, async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }); }); diff --git a/src/svelte/App.svelte b/src/svelte/App.svelte index 5b31fd0..4fb2911 100644 --- a/src/svelte/App.svelte +++ b/src/svelte/App.svelte @@ -37,7 +37,7 @@ -->