Skip to content

Commit 74b1dde

Browse files
authored
Merge pull request #350 from musonza/feature/cursor-pagination
Add cursor-based pagination for messages
2 parents 810eca3 + 35072d6 commit 74b1dde

File tree

8 files changed

+353
-0
lines changed

8 files changed

+353
-0
lines changed

README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ Create a Chat application for your multiple Models
3939
- [Remove participants from a conversation](#remove-participants-from-a-conversation)
4040
- [Add participants to a conversation](#add-participants-to-a-conversation)
4141
- [Get messages in a conversation](#get-messages-in-a-conversation)
42+
- [Get messages with cursor pagination](#get-messages-with-cursor-pagination)
4243
- [Get public conversations for discovery](#get-public-conversations-for-discovery)
4344
- [Get recent messages](#get-recent-messages)
4445
- [Get participants in a conversation](#get-participants-in-a-conversation)
@@ -322,6 +323,44 @@ Chat::conversation($conversation)->addParticipants([$participantModel, $particip
322323
Chat::conversation($conversation)->setParticipant($participantModel)->getMessages()
323324
```
324325

326+
#### Get messages with cursor pagination
327+
328+
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.
329+
330+
```php
331+
// Get first page
332+
$messages = Chat::conversation($conversation)
333+
->setParticipant($participantModel)
334+
->setCursorPaginationParams([
335+
'perPage' => 25,
336+
'sorting' => 'asc',
337+
])
338+
->getMessagesWithCursor();
339+
340+
// Get next page using cursor from previous response
341+
$nextCursor = $messages->nextCursor()?->encode();
342+
343+
$moreMessages = Chat::conversation($conversation)
344+
->setParticipant($participantModel)
345+
->setCursorPaginationParams([
346+
'perPage' => 25,
347+
'sorting' => 'asc',
348+
'cursor' => $nextCursor,
349+
])
350+
->getMessagesWithCursor();
351+
```
352+
353+
**API Endpoint:** `GET /conversations/{id}/messages-cursor`
354+
355+
Query parameters:
356+
- `participant_id` (required)
357+
- `participant_type` (required)
358+
- `perPage` (optional, default: 25)
359+
- `sorting` (optional: `asc` or `desc`)
360+
- `cursor` (optional - from previous response's `next_cursor`)
361+
362+
The response includes `next_cursor` and `prev_cursor` for navigation.
363+
325364
#### Get user conversations by type
326365

327366
```php
@@ -380,6 +419,8 @@ You don't have to specify all the parameters. If you leave the parameters out, d
380419
`$paginated` above will return `Illuminate\Pagination\LengthAwarePaginator`
381420
To get the `conversations` simply call `$paginated->items()`
382421

422+
> **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.
423+
383424

384425
#### Get participants in a conversation
385426

src/Http/Controllers/ConversationMessageController.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use Musonza\Chat\Http\Requests\ClearConversation;
77
use Musonza\Chat\Http\Requests\DeleteMessage;
88
use Musonza\Chat\Http\Requests\GetParticipantMessages;
9+
use Musonza\Chat\Http\Requests\GetParticipantMessagesWithCursor;
910
use Musonza\Chat\Http\Requests\StoreMessage;
1011

1112
class ConversationMessageController extends Controller
@@ -48,6 +49,27 @@ public function index(GetParticipantMessages $request, $conversationId)
4849
return response($message);
4950
}
5051

52+
/**
53+
* Get messages using cursor-based pagination.
54+
*
55+
* Cursor pagination is more suitable for real-time chat applications
56+
* as it avoids duplicate messages when new messages arrive between page loads.
57+
*/
58+
public function indexWithCursor(GetParticipantMessagesWithCursor $request, $conversationId)
59+
{
60+
$conversation = Chat::conversations()->getById($conversationId);
61+
$messages = Chat::conversation($conversation)
62+
->setParticipant($request->getParticipant())
63+
->setCursorPaginationParams($request->getCursorPaginationParams())
64+
->getMessagesWithCursor();
65+
66+
if ($this->messageTransformer) {
67+
return fractal($messages, $this->messageTransformer)->respond();
68+
}
69+
70+
return response($messages);
71+
}
72+
5173
public function show(GetParticipantMessages $request, $conversationId, $messageId)
5274
{
5375
$message = Chat::messages()->getById($messageId);
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
namespace Musonza\Chat\Http\Requests;
4+
5+
use Musonza\Chat\ValueObjects\Pagination;
6+
7+
class GetParticipantMessagesWithCursor extends BaseRequest
8+
{
9+
/**
10+
* @var Pagination
11+
*/
12+
private $pagination;
13+
14+
public function __construct(Pagination $pagination)
15+
{
16+
parent::__construct();
17+
$this->pagination = $pagination;
18+
}
19+
20+
public function authorized()
21+
{
22+
return true;
23+
}
24+
25+
public function rules()
26+
{
27+
return [
28+
'participant_id' => 'required',
29+
'participant_type' => 'required',
30+
'perPage' => 'integer',
31+
'sorting' => 'string|in:asc,desc',
32+
'columns' => 'array',
33+
'cursor' => 'string|nullable',
34+
'cursorName' => 'string',
35+
];
36+
}
37+
38+
public function getCursorPaginationParams()
39+
{
40+
return [
41+
'perPage' => $this->perPage ?? $this->pagination->getPerPage(),
42+
'sorting' => $this->sorting ?? $this->pagination->getSorting(),
43+
'columns' => $this->columns ?? $this->pagination->getColumns(),
44+
'cursor' => $this->cursor ?? null,
45+
'cursorName' => $this->cursorName ?? 'cursor',
46+
];
47+
}
48+
}

src/Http/routes.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
->name('conversations.messages.store');
3333
Route::get('/conversations/{id}/messages', 'ConversationMessageController@index')
3434
->name('conversations.messages.index');
35+
Route::get('/conversations/{id}/messages-cursor', 'ConversationMessageController@indexWithCursor')
36+
->name('conversations.messages.index.cursor');
3537
Route::get('/conversations/{id}/messages/{message_id}', 'ConversationMessageController@show')
3638
->name('conversations.messages.show');
3739
Route::delete('/conversations/{id}/messages', 'ConversationMessageController@deleteAll')

src/Models/Conversation.php

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace Musonza\Chat\Models;
44

55
use Chat;
6+
use Illuminate\Contracts\Pagination\CursorPaginator;
67
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
78
use Illuminate\Database\Eloquent\Model;
89
use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -89,6 +90,21 @@ public function getMessages(Model $participant, $paginationParams, $deleted = fa
8990
return $this->getConversationMessages($participant, $paginationParams, $deleted);
9091
}
9192

93+
/**
94+
* Get messages for a conversation using cursor-based pagination.
95+
*
96+
* Cursor pagination is more suitable for real-time chat applications
97+
* as it avoids duplicate messages when new messages arrive between page loads.
98+
*
99+
* @param array $paginationParams
100+
* @param bool $deleted
101+
* @return CursorPaginator
102+
*/
103+
public function getMessagesWithCursor(Model $participant, $paginationParams, $deleted = false)
104+
{
105+
return $this->getConversationMessagesWithCursor($participant, $paginationParams, $deleted);
106+
}
107+
92108
public function getParticipantConversations($participant, array $options)
93109
{
94110
return $this->getConversationsList($participant, $options);
@@ -324,6 +340,37 @@ private function getConversationMessages(Model $participant, $paginationParams,
324340
return $messages;
325341
}
326342

343+
/**
344+
* Get messages in conversation using cursor-based pagination.
345+
*
346+
* @return CursorPaginator
347+
*/
348+
private function getConversationMessagesWithCursor(Model $participant, $paginationParams, $deleted)
349+
{
350+
$messages = $this->messages()
351+
->join($this->tablePrefix . 'message_notifications', $this->tablePrefix . 'message_notifications.message_id', '=', $this->tablePrefix . 'messages.id')
352+
->where($this->tablePrefix . 'message_notifications.messageable_type', $participant->getMorphClass())
353+
->where($this->tablePrefix . 'message_notifications.messageable_id', $participant->getKey());
354+
$messages = $deleted ? $messages->whereNotNull($this->tablePrefix . 'message_notifications.deleted_at') : $messages->whereNull($this->tablePrefix . 'message_notifications.deleted_at');
355+
$messages = $messages->orderBy($this->tablePrefix . 'messages.id', $paginationParams['sorting'])
356+
->cursorPaginate(
357+
$paginationParams['perPage'],
358+
[
359+
$this->tablePrefix . 'message_notifications.updated_at as read_at',
360+
$this->tablePrefix . 'message_notifications.deleted_at as deleted_at',
361+
$this->tablePrefix . 'message_notifications.messageable_id',
362+
$this->tablePrefix . 'message_notifications.id as notification_id',
363+
$this->tablePrefix . 'message_notifications.is_seen',
364+
$this->tablePrefix . 'message_notifications.is_sender',
365+
$this->tablePrefix . 'messages.*',
366+
],
367+
$paginationParams['cursorName'] ?? 'cursor',
368+
$paginationParams['cursor'] ?? null
369+
);
370+
371+
return $messages;
372+
}
373+
327374
/**
328375
* @return mixed
329376
*/

src/Services/ConversationService.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,19 @@ public function getMessages()
5858
return $this->conversation->getMessages($this->participant, $this->getPaginationParams(), $this->deleted);
5959
}
6060

61+
/**
62+
* Get messages in a conversation using cursor-based pagination.
63+
*
64+
* Cursor pagination is more suitable for real-time chat applications
65+
* as it avoids duplicate messages when new messages arrive between page loads.
66+
*
67+
* @return \Illuminate\Contracts\Pagination\CursorPaginator
68+
*/
69+
public function getMessagesWithCursor()
70+
{
71+
return $this->conversation->getMessagesWithCursor($this->participant, $this->getCursorPaginationParams(), $this->deleted);
72+
}
73+
6174
/**
6275
* Clears conversation.
6376
*/

src/Traits/Paginates.php

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ trait Paginates
1616

1717
protected $deleted = false;
1818

19+
protected $cursor = null;
20+
21+
protected $cursorName = 'cursor';
22+
1923
/**
2024
* Set the limit.
2125
*
@@ -75,4 +79,44 @@ public function getPaginationParams()
7579
'pageName' => $this->pageName,
7680
];
7781
}
82+
83+
public function getCursorPaginationParams()
84+
{
85+
return [
86+
'perPage' => $this->perPage,
87+
'sorting' => $this->sorting,
88+
'columns' => $this->columns,
89+
'cursor' => $this->cursor,
90+
'cursorName' => $this->cursorName,
91+
];
92+
}
93+
94+
/**
95+
* Set the cursor for cursor-based pagination.
96+
*
97+
* @param string|null $cursor
98+
* @return $this
99+
*/
100+
public function cursor($cursor)
101+
{
102+
$this->cursor = $cursor;
103+
104+
return $this;
105+
}
106+
107+
/**
108+
* Set cursor pagination parameters.
109+
*
110+
* @return $this
111+
*/
112+
public function setCursorPaginationParams($params)
113+
{
114+
foreach ($params as $key => $value) {
115+
if (property_exists($this, $key)) {
116+
$this->{$key} = $value;
117+
}
118+
}
119+
120+
return $this;
121+
}
78122
}

0 commit comments

Comments
 (0)