Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 55 additions & 1 deletion assets/controllers/messaging_controller.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
static targets = ['messages', 'textarea'];
static targets = ['messages', 'textarea', 'loadOlder', 'loadOlderSpinner'];

connect() {
// Delay scroll to ensure DOM layout is fully computed
Expand Down Expand Up @@ -51,6 +51,60 @@ export default class extends Controller {
}

scrollToBottom() {
// Prepending older history must not yank the view to the bottom
if (this._suppressAutoScroll) return;
this.messagesTarget.scrollTop = this.messagesTarget.scrollHeight;
}

async loadOlderMessages(event) {
if (this._loadingOlder) return;

const button = event.currentTarget;
const url = button.dataset.url;
const before = this._oldestMessageId || button.dataset.oldestId;
if (!url || !before) return;

this._loadingOlder = true;
button.disabled = true;
if (this.hasLoadOlderSpinnerTarget) {
this.loadOlderSpinnerTarget.classList.remove('d-none');
}

try {
const response = await fetch(`${url}?before=${encodeURIComponent(before)}`, {
headers: { 'X-Requested-With': 'XMLHttpRequest' },
});
if (!response.ok) return;

const template = document.createElement('template');
template.innerHTML = (await response.text()).trim();
const batch = template.content.firstElementChild;
if (!batch) return;

const container = this.messagesTarget;
const prevScrollHeight = container.scrollHeight;
const prevScrollTop = container.scrollTop;

// Insert right below the button: each batch is older than the previous one
this._suppressAutoScroll = true;
this.loadOlderTarget.insertAdjacentElement('afterend', batch);
container.scrollTop = prevScrollTop + (container.scrollHeight - prevScrollHeight);
requestAnimationFrame(() => {
this._suppressAutoScroll = false;
});

this._oldestMessageId = batch.dataset.oldestId || before;
if (batch.dataset.hasOlder !== '1') {
this.loadOlderTarget.classList.add('d-none');
}
} catch {
// Network error - keep the button enabled so the user can retry
} finally {
this._loadingOlder = false;
button.disabled = false;
if (this.hasLoadOlderSpinnerTarget) {
this.loadOlderSpinnerTarget.classList.add('d-none');
}
}
}
}
11 changes: 7 additions & 4 deletions src/Controller/Messaging/ConversationDetailController.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use SpeedPuzzling\Web\Message\MarkMessagesAsRead;
use SpeedPuzzling\Web\Query\GetMessages;
use SpeedPuzzling\Web\Query\GetTransactionRatings;
use SpeedPuzzling\Web\Results\MessagesPage;
use SpeedPuzzling\Web\Repository\ConversationRepository;
use SpeedPuzzling\Web\Services\MercureTopicCollector;
use SpeedPuzzling\Web\Services\RetrieveLoggedUserProfile;
Expand Down Expand Up @@ -52,9 +53,9 @@ public function __invoke(string $conversationId): Response

$otherPlayer = $isInitiator ? $conversation->recipient : $conversation->initiator;

$messages = [];
$messagesPage = new MessagesPage(messages: [], hasOlderMessages: false);
if ($conversation->status === ConversationStatus::Accepted) {
$messages = $this->getMessages->forConversation($conversationId, $loggedPlayer->playerId);
$messagesPage = $this->getMessages->forConversation($conversationId, $loggedPlayer->playerId);

// Mark messages as read
$this->messageBus->dispatch(new MarkMessagesAsRead(
Expand All @@ -63,7 +64,7 @@ public function __invoke(string $conversationId): Response
));
} elseif (in_array($conversation->status, [ConversationStatus::Pending, ConversationStatus::Ignored], true)) {
// Both parties can see messages, but do NOT mark as read for recipient
$messages = $this->getMessages->forConversation($conversationId, $loggedPlayer->playerId);
$messagesPage = $this->getMessages->forConversation($conversationId, $loggedPlayer->playerId);
}

$puzzleContext = null;
Expand Down Expand Up @@ -118,7 +119,9 @@ public function __invoke(string $conversationId): Response

return $this->render('messaging/conversation_detail.html.twig', [
'conversation' => $conversation,
'messages' => $messages,
'messages' => $messagesPage->messages,
'has_older_messages' => $messagesPage->hasOlderMessages,
'oldest_message_id' => $messagesPage->oldestMessageId(),
'other_player' => $otherPlayer,
'is_recipient' => $isRecipient,
'puzzle_context' => $puzzleContext,
Expand Down
66 changes: 66 additions & 0 deletions src/Controller/Messaging/ConversationOlderMessagesController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

declare(strict_types=1);

namespace SpeedPuzzling\Web\Controller\Messaging;

use Ramsey\Uuid\Uuid;
use SpeedPuzzling\Web\Query\GetMessages;
use SpeedPuzzling\Web\Repository\ConversationRepository;
use SpeedPuzzling\Web\Services\RetrieveLoggedUserProfile;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

final class ConversationOlderMessagesController extends AbstractController
{
public function __construct(
readonly private ConversationRepository $conversationRepository,
readonly private GetMessages $getMessages,
readonly private RetrieveLoggedUserProfile $retrieveLoggedUserProfile,
) {
}

#[Route(
path: '/en/messages/{conversationId}/older-messages',
name: 'conversation_older_messages',
methods: ['GET'],
)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function __invoke(string $conversationId, Request $request): Response
{
$loggedPlayer = $this->retrieveLoggedUserProfile->getProfile();
assert($loggedPlayer !== null);

$conversation = $this->conversationRepository->get($conversationId);

// Verify current user is a participant
$isParticipant = $conversation->initiator?->id->toString() === $loggedPlayer->playerId
|| $conversation->recipient?->id->toString() === $loggedPlayer->playerId;

if (!$isParticipant) {
throw $this->createAccessDeniedException();
}

$beforeMessageId = $request->query->getString('before');

if (!Uuid::isValid($beforeMessageId)) {
throw new BadRequestHttpException('Invalid "before" message id.');
}

$messagesPage = $this->getMessages->forConversation(
conversationId: $conversationId,
viewerId: $loggedPlayer->playerId,
beforeMessageId: $beforeMessageId,
);

return $this->render('messaging/_older_messages.html.twig', [
'messages' => $messagesPage->messages,
'has_older_messages' => $messagesPage->hasOlderMessages,
'oldest_message_id' => $messagesPage->oldestMessageId(),
]);
}
}
2 changes: 1 addition & 1 deletion src/Controller/Messaging/StartConversationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ public function __invoke(Request $request, string $recipientId): Response
}
}

$messages = $this->getMessages->forConversation($existingConversation->id->toString(), $loggedPlayer->playerId);
$messages = $this->getMessages->forConversation($existingConversation->id->toString(), $loggedPlayer->playerId)->messages;

if ($existingConversation->status === ConversationStatus::Accepted) {
$this->messageBus->dispatch(new MarkMessagesAsRead(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ public function __invoke(Request $request, string $sellSwapListItemId): Response
}
}

$messages = $this->getMessages->forConversation($existingConversation->id->toString(), $loggedPlayer->playerId);
$messages = $this->getMessages->forConversation($existingConversation->id->toString(), $loggedPlayer->playerId)->messages;

if ($existingConversation->status === ConversationStatus::Accepted) {
$this->messageBus->dispatch(new MarkMessagesAsRead(
Expand Down
94 changes: 65 additions & 29 deletions src/Query/GetMessages.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use SpeedPuzzling\Web\Results\MessagesPage;
use SpeedPuzzling\Web\Results\MessageView;
use SpeedPuzzling\Web\Value\SystemMessageType;

Expand All @@ -17,42 +18,72 @@ public function __construct(
}

/**
* @return array<MessageView>
* Returns the newest $limit messages of the conversation in chronological (ascending) order.
* When $beforeMessageId is given, returns the newest $limit messages older than that message
* (cursor for loading conversation history backwards).
*/
public function forConversation(string $conversationId, string $viewerId, int $limit = 50, int $offset = 0): array
{
public function forConversation(
string $conversationId,
string $viewerId,
int $limit = 50,
null|string $beforeMessageId = null,
): MessagesPage {
$cursorFilter = '';
$params = [
'conversationId' => $conversationId,
'viewerId' => $viewerId,
// One extra row to detect whether older messages exist
'limitPlusOne' => $limit + 1,
];

if ($beforeMessageId !== null) {
$cursorFilter = <<<SQL
AND (cm.sent_at, cm.id) < (
SELECT bm.sent_at, bm.id
FROM chat_message bm
WHERE bm.id = :beforeMessageId AND bm.conversation_id = :conversationId
)
SQL;
$params['beforeMessageId'] = $beforeMessageId;
}

$query = <<<SQL
SELECT
cm.id AS message_id,
cm.sender_id,
p.name AS sender_name,
p.avatar AS sender_avatar,
cm.content,
cm.sent_at,
cm.read_at,
cm.system_message_type,
cm.system_message_target_player_id,
c.puzzle_id,
CASE WHEN c.initiator_id = :viewerId THEN c.recipient_id ELSE c.initiator_id END AS other_participant_id
FROM chat_message cm
LEFT JOIN player p ON cm.sender_id = p.id
JOIN conversation c ON cm.conversation_id = c.id
WHERE cm.conversation_id = :conversationId
ORDER BY cm.sent_at ASC
LIMIT :limit
OFFSET :offset
SELECT * FROM (
SELECT
cm.id AS message_id,
cm.sender_id,
p.name AS sender_name,
p.avatar AS sender_avatar,
cm.content,
cm.sent_at,
cm.read_at,
cm.system_message_type,
cm.system_message_target_player_id,
c.puzzle_id,
CASE WHEN c.initiator_id = :viewerId THEN c.recipient_id ELSE c.initiator_id END AS other_participant_id
FROM chat_message cm
LEFT JOIN player p ON cm.sender_id = p.id
JOIN conversation c ON cm.conversation_id = c.id
WHERE cm.conversation_id = :conversationId
{$cursorFilter}
ORDER BY cm.sent_at DESC, cm.id DESC
LIMIT :limitPlusOne
) newest
ORDER BY newest.sent_at ASC, newest.message_id ASC
SQL;

$data = $this->database
->executeQuery($query, [
'conversationId' => $conversationId,
'viewerId' => $viewerId,
'limit' => $limit,
'offset' => $offset,
])
->executeQuery($query, $params)
->fetchAllAssociative();

return array_map(static function (array $row) use ($viewerId): MessageView {
$hasOlderMessages = count($data) > $limit;

if ($hasOlderMessages) {
// The extra row is the oldest one - first in ascending order
$data = array_slice($data, 1);
}

$messages = array_map(static function (array $row) use ($viewerId): MessageView {
/** @var array{
* message_id: string,
* sender_id: null|string,
Expand Down Expand Up @@ -95,5 +126,10 @@ public function forConversation(string $conversationId, string $viewerId, int $l
puzzleId: $isSystemMessage ? $row['puzzle_id'] : null,
);
}, $data);

return new MessagesPage(
messages: $messages,
hasOlderMessages: $hasOlderMessages,
);
}
}
24 changes: 24 additions & 0 deletions src/Results/MessagesPage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace SpeedPuzzling\Web\Results;

readonly final class MessagesPage
{
/**
* @param array<MessageView> $messages Messages in chronological (ascending) order.
*/
public function __construct(
public array $messages,
public bool $hasOlderMessages,
) {
}

public function oldestMessageId(): null|string
{
$first = $this->messages[0] ?? null;

return $first?->messageId;
}
}
5 changes: 5 additions & 0 deletions templates/messaging/_older_messages.html.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<div data-has-older="{{ has_older_messages ? '1' : '0' }}" data-oldest-id="{{ oldest_message_id }}">
{% for message in messages %}
{{ include('messaging/_message_bubble.html.twig') }}
{% endfor %}
</div>
12 changes: 12 additions & 0 deletions templates/messaging/conversation_detail.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,18 @@
data-messaging-target="messages"
data-conversation-realtime-target="messages">

{% if has_older_messages %}
<div class="text-center mb-3" data-messaging-target="loadOlder">
<button type="button" class="btn btn-sm btn-outline-secondary"
data-action="messaging#loadOlderMessages"
data-url="{{ path('conversation_older_messages', {conversationId: conversation.id}) }}"
data-oldest-id="{{ oldest_message_id }}">
<span class="spinner-border spinner-border-sm me-1 d-none" data-messaging-target="loadOlderSpinner" aria-hidden="true"></span>
<i class="bi bi-clock-history me-1"></i>{{ 'messaging.load_older_messages'|trans }}
</button>
</div>
{% endif %}

{% if conversation.status.value in ['pending', 'ignored'] and is_recipient %}
{% if messages is not empty %}
{% for message in messages %}
Expand Down
2 changes: 1 addition & 1 deletion tests/MessageHandler/MarkListingAsReservedHandlerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ public function testSystemMessageCreatedInConversationAfterReservation(): void
$messages = $this->getMessages->forConversation(
ConversationFixture::CONVERSATION_MARKETPLACE,
PlayerFixture::PLAYER_WITH_FAVORITES,
);
)->messages;

$systemMessages = array_filter($messages, static fn ($m) => $m->isSystemMessage);
self::assertCount(1, $systemMessages);
Expand Down
Loading