diff --git a/README.md b/README.md index a5851ad..608643c 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Create a Chat application for your multiple Models - [Remove participants from a conversation](#remove-participants-from-a-conversation) - [Add participants to a conversation](#add-participants-to-a-conversation) - [Get messages in a conversation](#get-messages-in-a-conversation) + - [Get messages with cursor pagination](#get-messages-with-cursor-pagination) - [Get public conversations for discovery](#get-public-conversations-for-discovery) - [Get recent messages](#get-recent-messages) - [Get participants in a conversation](#get-participants-in-a-conversation) @@ -322,6 +323,44 @@ Chat::conversation($conversation)->addParticipants([$participantModel, $particip Chat::conversation($conversation)->setParticipant($participantModel)->getMessages() ``` +#### Get messages with cursor pagination + +For real-time chat applications, cursor-based pagination is recommended over offset-based pagination. This prevents duplicate messages when new messages arrive between page loads. + +```php +// Get first page +$messages = Chat::conversation($conversation) + ->setParticipant($participantModel) + ->setCursorPaginationParams([ + 'perPage' => 25, + 'sorting' => 'asc', + ]) + ->getMessagesWithCursor(); + +// Get next page using cursor from previous response +$nextCursor = $messages->nextCursor()?->encode(); + +$moreMessages = Chat::conversation($conversation) + ->setParticipant($participantModel) + ->setCursorPaginationParams([ + 'perPage' => 25, + 'sorting' => 'asc', + 'cursor' => $nextCursor, + ]) + ->getMessagesWithCursor(); +``` + +**API Endpoint:** `GET /conversations/{id}/messages-cursor` + +Query parameters: +- `participant_id` (required) +- `participant_type` (required) +- `perPage` (optional, default: 25) +- `sorting` (optional: `asc` or `desc`) +- `cursor` (optional - from previous response's `next_cursor`) + +The response includes `next_cursor` and `prev_cursor` for navigation. + #### Get user conversations by type ```php @@ -380,6 +419,8 @@ You don't have to specify all the parameters. If you leave the parameters out, d `$paginated` above will return `Illuminate\Pagination\LengthAwarePaginator` To get the `conversations` simply call `$paginated->items()` +> **Tip:** For paginating messages in real-time chat applications, consider using [cursor pagination](#get-messages-with-cursor-pagination) instead. Cursor pagination prevents duplicate messages when new messages arrive between page loads. + #### Get participants in a conversation diff --git a/src/Http/Controllers/ConversationMessageController.php b/src/Http/Controllers/ConversationMessageController.php index 07bc018..4635098 100644 --- a/src/Http/Controllers/ConversationMessageController.php +++ b/src/Http/Controllers/ConversationMessageController.php @@ -6,6 +6,7 @@ use Musonza\Chat\Http\Requests\ClearConversation; use Musonza\Chat\Http\Requests\DeleteMessage; use Musonza\Chat\Http\Requests\GetParticipantMessages; +use Musonza\Chat\Http\Requests\GetParticipantMessagesWithCursor; use Musonza\Chat\Http\Requests\StoreMessage; class ConversationMessageController extends Controller @@ -48,6 +49,27 @@ public function index(GetParticipantMessages $request, $conversationId) return response($message); } + /** + * Get messages using cursor-based pagination. + * + * Cursor pagination is more suitable for real-time chat applications + * as it avoids duplicate messages when new messages arrive between page loads. + */ + public function indexWithCursor(GetParticipantMessagesWithCursor $request, $conversationId) + { + $conversation = Chat::conversations()->getById($conversationId); + $messages = Chat::conversation($conversation) + ->setParticipant($request->getParticipant()) + ->setCursorPaginationParams($request->getCursorPaginationParams()) + ->getMessagesWithCursor(); + + if ($this->messageTransformer) { + return fractal($messages, $this->messageTransformer)->respond(); + } + + return response($messages); + } + public function show(GetParticipantMessages $request, $conversationId, $messageId) { $message = Chat::messages()->getById($messageId); diff --git a/src/Http/Requests/GetParticipantMessagesWithCursor.php b/src/Http/Requests/GetParticipantMessagesWithCursor.php new file mode 100644 index 0000000..fa8d540 --- /dev/null +++ b/src/Http/Requests/GetParticipantMessagesWithCursor.php @@ -0,0 +1,48 @@ +pagination = $pagination; + } + + public function authorized() + { + return true; + } + + public function rules() + { + return [ + 'participant_id' => 'required', + 'participant_type' => 'required', + 'perPage' => 'integer', + 'sorting' => 'string|in:asc,desc', + 'columns' => 'array', + 'cursor' => 'string|nullable', + 'cursorName' => 'string', + ]; + } + + public function getCursorPaginationParams() + { + return [ + 'perPage' => $this->perPage ?? $this->pagination->getPerPage(), + 'sorting' => $this->sorting ?? $this->pagination->getSorting(), + 'columns' => $this->columns ?? $this->pagination->getColumns(), + 'cursor' => $this->cursor ?? null, + 'cursorName' => $this->cursorName ?? 'cursor', + ]; + } +} diff --git a/src/Http/routes.php b/src/Http/routes.php index b6c33ae..6441b06 100644 --- a/src/Http/routes.php +++ b/src/Http/routes.php @@ -32,6 +32,8 @@ ->name('conversations.messages.store'); Route::get('/conversations/{id}/messages', 'ConversationMessageController@index') ->name('conversations.messages.index'); + Route::get('/conversations/{id}/messages-cursor', 'ConversationMessageController@indexWithCursor') + ->name('conversations.messages.index.cursor'); Route::get('/conversations/{id}/messages/{message_id}', 'ConversationMessageController@show') ->name('conversations.messages.show'); Route::delete('/conversations/{id}/messages', 'ConversationMessageController@deleteAll') diff --git a/src/Models/Conversation.php b/src/Models/Conversation.php index 420394f..6204115 100644 --- a/src/Models/Conversation.php +++ b/src/Models/Conversation.php @@ -3,6 +3,7 @@ namespace Musonza\Chat\Models; use Chat; +use Illuminate\Contracts\Pagination\CursorPaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -89,6 +90,21 @@ public function getMessages(Model $participant, $paginationParams, $deleted = fa return $this->getConversationMessages($participant, $paginationParams, $deleted); } + /** + * Get messages for a conversation using cursor-based pagination. + * + * Cursor pagination is more suitable for real-time chat applications + * as it avoids duplicate messages when new messages arrive between page loads. + * + * @param array $paginationParams + * @param bool $deleted + * @return CursorPaginator + */ + public function getMessagesWithCursor(Model $participant, $paginationParams, $deleted = false) + { + return $this->getConversationMessagesWithCursor($participant, $paginationParams, $deleted); + } + public function getParticipantConversations($participant, array $options) { return $this->getConversationsList($participant, $options); @@ -324,6 +340,37 @@ private function getConversationMessages(Model $participant, $paginationParams, return $messages; } + /** + * Get messages in conversation using cursor-based pagination. + * + * @return CursorPaginator + */ + private function getConversationMessagesWithCursor(Model $participant, $paginationParams, $deleted) + { + $messages = $this->messages() + ->join($this->tablePrefix . 'message_notifications', $this->tablePrefix . 'message_notifications.message_id', '=', $this->tablePrefix . 'messages.id') + ->where($this->tablePrefix . 'message_notifications.messageable_type', $participant->getMorphClass()) + ->where($this->tablePrefix . 'message_notifications.messageable_id', $participant->getKey()); + $messages = $deleted ? $messages->whereNotNull($this->tablePrefix . 'message_notifications.deleted_at') : $messages->whereNull($this->tablePrefix . 'message_notifications.deleted_at'); + $messages = $messages->orderBy($this->tablePrefix . 'messages.id', $paginationParams['sorting']) + ->cursorPaginate( + $paginationParams['perPage'], + [ + $this->tablePrefix . 'message_notifications.updated_at as read_at', + $this->tablePrefix . 'message_notifications.deleted_at as deleted_at', + $this->tablePrefix . 'message_notifications.messageable_id', + $this->tablePrefix . 'message_notifications.id as notification_id', + $this->tablePrefix . 'message_notifications.is_seen', + $this->tablePrefix . 'message_notifications.is_sender', + $this->tablePrefix . 'messages.*', + ], + $paginationParams['cursorName'] ?? 'cursor', + $paginationParams['cursor'] ?? null + ); + + return $messages; + } + /** * @return mixed */ diff --git a/src/Services/ConversationService.php b/src/Services/ConversationService.php index d9ce50e..0113cf3 100644 --- a/src/Services/ConversationService.php +++ b/src/Services/ConversationService.php @@ -58,6 +58,19 @@ public function getMessages() return $this->conversation->getMessages($this->participant, $this->getPaginationParams(), $this->deleted); } + /** + * Get messages in a conversation using cursor-based pagination. + * + * Cursor pagination is more suitable for real-time chat applications + * as it avoids duplicate messages when new messages arrive between page loads. + * + * @return \Illuminate\Contracts\Pagination\CursorPaginator + */ + public function getMessagesWithCursor() + { + return $this->conversation->getMessagesWithCursor($this->participant, $this->getCursorPaginationParams(), $this->deleted); + } + /** * Clears conversation. */ diff --git a/src/Traits/Paginates.php b/src/Traits/Paginates.php index 4fec3cc..26ecbf2 100644 --- a/src/Traits/Paginates.php +++ b/src/Traits/Paginates.php @@ -16,6 +16,10 @@ trait Paginates protected $deleted = false; + protected $cursor = null; + + protected $cursorName = 'cursor'; + /** * Set the limit. * @@ -75,4 +79,44 @@ public function getPaginationParams() 'pageName' => $this->pageName, ]; } + + public function getCursorPaginationParams() + { + return [ + 'perPage' => $this->perPage, + 'sorting' => $this->sorting, + 'columns' => $this->columns, + 'cursor' => $this->cursor, + 'cursorName' => $this->cursorName, + ]; + } + + /** + * Set the cursor for cursor-based pagination. + * + * @param string|null $cursor + * @return $this + */ + public function cursor($cursor) + { + $this->cursor = $cursor; + + return $this; + } + + /** + * Set cursor pagination parameters. + * + * @return $this + */ + public function setCursorPaginationParams($params) + { + foreach ($params as $key => $value) { + if (property_exists($this, $key)) { + $this->{$key} = $value; + } + } + + return $this; + } } diff --git a/tests/Feature/ConversationMessageControllerTest.php b/tests/Feature/ConversationMessageControllerTest.php index c544326..3bc39c0 100644 --- a/tests/Feature/ConversationMessageControllerTest.php +++ b/tests/Feature/ConversationMessageControllerTest.php @@ -127,4 +127,140 @@ public function test_destroy() ->assertSuccessful(); $this->assertCount(2, Chat::conversation($conversation)->setParticipant($userModel)->getMessages()); } + + public function test_index_with_cursor() + { + $conversation = factory(Conversation::class)->create(); + $userModel = factory(User::class)->create(); + $clientModel = factory(Client::class)->create(); + + Chat::conversation($conversation)->addParticipants([$userModel, $clientModel]); + Chat::message('message 1')->from($userModel)->to($conversation)->send(); + Chat::message('message 2')->from($clientModel)->to($conversation)->send(); + Chat::message('message 3')->from($userModel)->to($conversation)->send(); + + $parameters = [ + $conversation->getKey(), + 'participant_id' => $userModel->getKey(), + 'participant_type' => $userModel->getMorphClass(), + 'perPage' => 2, + 'sorting' => 'asc', + ]; + + $response = $this->getJson(route('conversations.messages.index.cursor', $parameters)) + ->assertStatus(200) + ->assertJsonStructure([ + 'data' => [ + [ + 'sender', + 'body', + ], + ], + 'next_cursor', + 'next_page_url', + 'path', + 'per_page', + 'prev_cursor', + 'prev_page_url', + ]); + + $this->assertCount(2, $response->json('data')); + } + + public function test_index_with_cursor_pagination_navigation() + { + $conversation = factory(Conversation::class)->create(); + $userModel = factory(User::class)->create(); + $clientModel = factory(Client::class)->create(); + + Chat::conversation($conversation)->addParticipants([$userModel, $clientModel]); + + // Create 5 messages + Chat::message('message 1')->from($userModel)->to($conversation)->send(); + Chat::message('message 2')->from($clientModel)->to($conversation)->send(); + Chat::message('message 3')->from($userModel)->to($conversation)->send(); + Chat::message('message 4')->from($clientModel)->to($conversation)->send(); + Chat::message('message 5')->from($userModel)->to($conversation)->send(); + + // Get first page with 2 items + $parameters = [ + $conversation->getKey(), + 'participant_id' => $userModel->getKey(), + 'participant_type' => $userModel->getMorphClass(), + 'perPage' => 2, + 'sorting' => 'asc', + ]; + + $firstPage = $this->getJson(route('conversations.messages.index.cursor', $parameters)) + ->assertStatus(200); + + $this->assertCount(2, $firstPage->json('data')); + $this->assertEquals('message 1', $firstPage->json('data.0.body')); + $this->assertEquals('message 2', $firstPage->json('data.1.body')); + $this->assertNotNull($firstPage->json('next_cursor')); + + // Get second page using cursor + $nextCursor = $firstPage->json('next_cursor'); + $parameters['cursor'] = $nextCursor; + + $secondPage = $this->getJson(route('conversations.messages.index.cursor', $parameters)) + ->assertStatus(200); + + $this->assertCount(2, $secondPage->json('data')); + $this->assertEquals('message 3', $secondPage->json('data.0.body')); + $this->assertEquals('message 4', $secondPage->json('data.1.body')); + + // Get third page + $nextCursor = $secondPage->json('next_cursor'); + $parameters['cursor'] = $nextCursor; + + $thirdPage = $this->getJson(route('conversations.messages.index.cursor', $parameters)) + ->assertStatus(200); + + $this->assertCount(1, $thirdPage->json('data')); + $this->assertEquals('message 5', $thirdPage->json('data.0.body')); + $this->assertNull($thirdPage->json('next_cursor')); + } + + public function test_index_with_cursor_descending_order() + { + $conversation = factory(Conversation::class)->create(); + $userModel = factory(User::class)->create(); + $clientModel = factory(Client::class)->create(); + + Chat::conversation($conversation)->addParticipants([$userModel, $clientModel]); + Chat::message('message 1')->from($userModel)->to($conversation)->send(); + Chat::message('message 2')->from($clientModel)->to($conversation)->send(); + Chat::message('message 3')->from($userModel)->to($conversation)->send(); + + $parameters = [ + $conversation->getKey(), + 'participant_id' => $userModel->getKey(), + 'participant_type' => $userModel->getMorphClass(), + 'perPage' => 2, + 'sorting' => 'desc', + ]; + + $response = $this->getJson(route('conversations.messages.index.cursor', $parameters)) + ->assertStatus(200); + + $this->assertCount(2, $response->json('data')); + // In descending order, newest messages come first + $this->assertEquals('message 3', $response->json('data.0.body')); + $this->assertEquals('message 2', $response->json('data.1.body')); + } + + public function test_index_with_cursor_requires_participant() + { + $conversation = factory(Conversation::class)->create(); + + $parameters = [ + $conversation->getKey(), + 'perPage' => 10, + ]; + + $this->getJson(route('conversations.messages.index.cursor', $parameters)) + ->assertStatus(422) + ->assertJsonValidationErrors(['participant_id', 'participant_type']); + } }