diff --git a/assets/controllers/messaging_controller.js b/assets/controllers/messaging_controller.js index aeda77c4..8030dceb 100644 --- a/assets/controllers/messaging_controller.js +++ b/assets/controllers/messaging_controller.js @@ -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 @@ -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'); + } + } + } } diff --git a/src/Controller/Messaging/ConversationDetailController.php b/src/Controller/Messaging/ConversationDetailController.php index 64e5182f..821f48dc 100644 --- a/src/Controller/Messaging/ConversationDetailController.php +++ b/src/Controller/Messaging/ConversationDetailController.php @@ -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; @@ -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( @@ -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; @@ -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, diff --git a/src/Controller/Messaging/ConversationOlderMessagesController.php b/src/Controller/Messaging/ConversationOlderMessagesController.php new file mode 100644 index 00000000..e33b4bc0 --- /dev/null +++ b/src/Controller/Messaging/ConversationOlderMessagesController.php @@ -0,0 +1,66 @@ +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(), + ]); + } +} diff --git a/src/Controller/Messaging/StartConversationController.php b/src/Controller/Messaging/StartConversationController.php index d5ab5302..f0269272 100644 --- a/src/Controller/Messaging/StartConversationController.php +++ b/src/Controller/Messaging/StartConversationController.php @@ -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( diff --git a/src/Controller/Messaging/StartMarketplaceConversationController.php b/src/Controller/Messaging/StartMarketplaceConversationController.php index e0216fae..7813fa70 100644 --- a/src/Controller/Messaging/StartMarketplaceConversationController.php +++ b/src/Controller/Messaging/StartMarketplaceConversationController.php @@ -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( diff --git a/src/Query/GetMessages.php b/src/Query/GetMessages.php index 9860b65e..c02cb674 100644 --- a/src/Query/GetMessages.php +++ b/src/Query/GetMessages.php @@ -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; @@ -17,42 +18,72 @@ public function __construct( } /** - * @return array + * 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 = <<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, @@ -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, + ); } } diff --git a/src/Results/MessagesPage.php b/src/Results/MessagesPage.php new file mode 100644 index 00000000..825a7f79 --- /dev/null +++ b/src/Results/MessagesPage.php @@ -0,0 +1,24 @@ + $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; + } +} diff --git a/templates/messaging/_older_messages.html.twig b/templates/messaging/_older_messages.html.twig new file mode 100644 index 00000000..5d53f06e --- /dev/null +++ b/templates/messaging/_older_messages.html.twig @@ -0,0 +1,5 @@ +
+ {% for message in messages %} + {{ include('messaging/_message_bubble.html.twig') }} + {% endfor %} +
diff --git a/templates/messaging/conversation_detail.html.twig b/templates/messaging/conversation_detail.html.twig index ccad63d9..0c011129 100644 --- a/templates/messaging/conversation_detail.html.twig +++ b/templates/messaging/conversation_detail.html.twig @@ -94,6 +94,18 @@ data-messaging-target="messages" data-conversation-realtime-target="messages"> + {% if has_older_messages %} +
+ +
+ {% endif %} + {% if conversation.status.value in ['pending', 'ignored'] and is_recipient %} {% if messages is not empty %} {% for message in messages %} diff --git a/tests/MessageHandler/MarkListingAsReservedHandlerTest.php b/tests/MessageHandler/MarkListingAsReservedHandlerTest.php index 0216d676..832a8ef5 100644 --- a/tests/MessageHandler/MarkListingAsReservedHandlerTest.php +++ b/tests/MessageHandler/MarkListingAsReservedHandlerTest.php @@ -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); diff --git a/tests/MessageHandler/MarkMessagesAsReadHandlerTest.php b/tests/MessageHandler/MarkMessagesAsReadHandlerTest.php index 23aeb820..604e9657 100644 --- a/tests/MessageHandler/MarkMessagesAsReadHandlerTest.php +++ b/tests/MessageHandler/MarkMessagesAsReadHandlerTest.php @@ -30,7 +30,7 @@ public function testMarksUnreadMessagesFromOtherParticipantAsRead(): void $messagesBefore = $this->getMessages->forConversation( ConversationFixture::CONVERSATION_ACCEPTED, PlayerFixture::PLAYER_REGULAR, - ); + )->messages; $unreadBefore = array_filter($messagesBefore, static fn ($m) => $m->readAt === null && !$m->isOwnMessage); self::assertNotEmpty($unreadBefore, 'Should have unread messages from other participant before marking'); @@ -45,7 +45,7 @@ public function testMarksUnreadMessagesFromOtherParticipantAsRead(): void $messagesAfter = $this->getMessages->forConversation( ConversationFixture::CONVERSATION_ACCEPTED, PlayerFixture::PLAYER_REGULAR, - ); + )->messages; $unreadAfter = array_filter($messagesAfter, static fn ($m) => $m->readAt === null && !$m->isOwnMessage); self::assertEmpty($unreadAfter, 'All messages from other participant should be marked as read'); @@ -65,7 +65,7 @@ public function testDoesNotMarkOwnMessagesAsRead(): void $messages = $this->getMessages->forConversation( ConversationFixture::CONVERSATION_ACCEPTED, PlayerFixture::PLAYER_REGULAR, - ); + )->messages; // Messages from REGULAR (msg01, msg03) were already read in fixtures // Message from ADMIN (msg04) was not read - marking as ADMIN should not affect it @@ -76,7 +76,7 @@ public function testDoesNotMarkOwnMessagesAsRead(): void $messagesFromAdminView = $this->getMessages->forConversation( ConversationFixture::CONVERSATION_ACCEPTED, PlayerFixture::PLAYER_ADMIN, - ); + )->messages; $ownMessageCount = 0; foreach ($messagesFromAdminView as $message) { @@ -94,7 +94,7 @@ public function testMarksSystemMessagesAsRead(): void $messagesBefore = $this->getMessages->forConversation( ConversationFixture::CONVERSATION_ACCEPTED, PlayerFixture::PLAYER_REGULAR, - ); + )->messages; $unreadSystemBefore = array_filter($messagesBefore, static fn ($m) => $m->isSystemMessage && $m->readAt === null); self::assertNotEmpty($unreadSystemBefore, 'Should have unread system messages before marking'); @@ -109,7 +109,7 @@ public function testMarksSystemMessagesAsRead(): void $messagesAfter = $this->getMessages->forConversation( ConversationFixture::CONVERSATION_ACCEPTED, PlayerFixture::PLAYER_REGULAR, - ); + )->messages; $unreadSystemAfter = array_filter($messagesAfter, static fn ($m) => $m->isSystemMessage && $m->readAt === null); self::assertEmpty($unreadSystemAfter, 'System messages should be marked as read'); diff --git a/tests/MessageHandler/MarkPuzzleAsSoldOrSwappedHandlerTest.php b/tests/MessageHandler/MarkPuzzleAsSoldOrSwappedHandlerTest.php index e535a207..e9e6f043 100644 --- a/tests/MessageHandler/MarkPuzzleAsSoldOrSwappedHandlerTest.php +++ b/tests/MessageHandler/MarkPuzzleAsSoldOrSwappedHandlerTest.php @@ -126,7 +126,7 @@ public function testSystemMessageCreatedInConversationAfterSale(): void $messages = $this->getMessages->forConversation( ConversationFixture::CONVERSATION_MARKETPLACE, PlayerFixture::PLAYER_WITH_FAVORITES, - ); + )->messages; $systemMessages = array_filter($messages, static fn ($m) => $m->isSystemMessage); self::assertNotEmpty($systemMessages); diff --git a/tests/MessageHandler/SendMessageHandlerTest.php b/tests/MessageHandler/SendMessageHandlerTest.php index 6602c924..dbeb1328 100644 --- a/tests/MessageHandler/SendMessageHandlerTest.php +++ b/tests/MessageHandler/SendMessageHandlerTest.php @@ -47,7 +47,7 @@ public function testSendingMessageInAcceptedConversation(): void $messages = $this->getMessages->forConversation( ConversationFixture::CONVERSATION_ACCEPTED, PlayerFixture::PLAYER_REGULAR, - ); + )->messages; $lastMessage = end($messages); self::assertNotFalse($lastMessage); @@ -86,7 +86,7 @@ public function testInitiatorCanSendMessageInPendingConversation(): void $messages = $this->getMessages->forConversation( ConversationFixture::CONVERSATION_PENDING, PlayerFixture::PLAYER_WITH_STRIPE, - ); + )->messages; $lastMessage = end($messages); self::assertNotFalse($lastMessage); diff --git a/tests/MessageHandler/StartConversationHandlerTest.php b/tests/MessageHandler/StartConversationHandlerTest.php index 835de5a5..55da9a05 100644 --- a/tests/MessageHandler/StartConversationHandlerTest.php +++ b/tests/MessageHandler/StartConversationHandlerTest.php @@ -161,7 +161,7 @@ public function testStartingConversationWithExistingAcceptedReusesConversation() $messages = $this->getMessages->forConversation( ConversationFixture::CONVERSATION_ACCEPTED, PlayerFixture::PLAYER_ADMIN, - ); + )->messages; $lastMessage = end($messages); self::assertNotFalse($lastMessage); diff --git a/tests/Query/GetMessagesTest.php b/tests/Query/GetMessagesTest.php index 004a7e11..940ec087 100644 --- a/tests/Query/GetMessagesTest.php +++ b/tests/Query/GetMessagesTest.php @@ -22,16 +22,17 @@ protected function setUp(): void public function testReturnsMessagesForConversationInChronologicalOrder(): void { - $messages = $this->getMessages->forConversation( + $page = $this->getMessages->forConversation( ConversationFixture::CONVERSATION_ACCEPTED, PlayerFixture::PLAYER_REGULAR, ); - self::assertCount(6, $messages); + self::assertCount(6, $page->messages); + self::assertFalse($page->hasOlderMessages); // Verify chronological order (ASC) - for ($i = 0; $i < count($messages) - 1; $i++) { - self::assertLessThanOrEqual($messages[$i + 1]->sentAt, $messages[$i]->sentAt); + for ($i = 0; $i < count($page->messages) - 1; $i++) { + self::assertLessThanOrEqual($page->messages[$i + 1]->sentAt, $page->messages[$i]->sentAt); } } @@ -40,7 +41,7 @@ public function testIsOwnMessageFlag(): void $messages = $this->getMessages->forConversation( ConversationFixture::CONVERSATION_ACCEPTED, PlayerFixture::PLAYER_REGULAR, - ); + )->messages; // Messages 1, 3 are from REGULAR (own), messages 2, 4, 5 from ADMIN (not own) self::assertTrue($messages[0]->isOwnMessage); @@ -50,27 +51,75 @@ public function testIsOwnMessageFlag(): void self::assertFalse($messages[4]->isOwnMessage); } - public function testPaginationWorks(): void + public function testLimitReturnsNewestMessages(): void { - $firstTwo = $this->getMessages->forConversation( + $allMessages = $this->getMessages->forConversation( + ConversationFixture::CONVERSATION_ACCEPTED, + PlayerFixture::PLAYER_REGULAR, + )->messages; + + $page = $this->getMessages->forConversation( ConversationFixture::CONVERSATION_ACCEPTED, PlayerFixture::PLAYER_REGULAR, limit: 2, ); - self::assertCount(2, $firstTwo); + self::assertCount(2, $page->messages); + self::assertTrue($page->hasOlderMessages); - $nextTwo = $this->getMessages->forConversation( + // The window must contain the NEWEST messages, still in chronological order + $lastTwo = array_slice($allMessages, -2); + self::assertSame($lastTwo[0]->messageId, $page->messages[0]->messageId); + self::assertSame($lastTwo[1]->messageId, $page->messages[1]->messageId); + } + + public function testCursorPaginationWalksWholeHistoryBackwards(): void + { + $allMessages = $this->getMessages->forConversation( ConversationFixture::CONVERSATION_ACCEPTED, PlayerFixture::PLAYER_REGULAR, - limit: 2, - offset: 2, + )->messages; + + $collected = []; + $cursor = null; + + do { + $page = $this->getMessages->forConversation( + ConversationFixture::CONVERSATION_ACCEPTED, + PlayerFixture::PLAYER_REGULAR, + limit: 2, + beforeMessageId: $cursor, + ); + + // Older batches are prepended before what we already have + $collected = array_merge($page->messages, $collected); + $cursor = $page->oldestMessageId(); + } while ($page->hasOlderMessages); + + self::assertCount(count($allMessages), $collected); + self::assertSame( + array_map(static fn ($message) => $message->messageId, $allMessages), + array_map(static fn ($message) => $message->messageId, $collected), ); + } - self::assertCount(2, $nextTwo); + public function testCursorFromDifferentConversationReturnsNothing(): void + { + $marketplaceMessages = $this->getMessages->forConversation( + ConversationFixture::CONVERSATION_MARKETPLACE, + PlayerFixture::PLAYER_REGULAR, + )->messages; - // Ensure they are different messages - self::assertNotSame($firstTwo[0]->messageId, $nextTwo[0]->messageId); + self::assertNotEmpty($marketplaceMessages); + + $page = $this->getMessages->forConversation( + ConversationFixture::CONVERSATION_ACCEPTED, + PlayerFixture::PLAYER_REGULAR, + beforeMessageId: $marketplaceMessages[0]->messageId, + ); + + self::assertSame([], $page->messages); + self::assertFalse($page->hasOlderMessages); } public function testReadAtIsPopulated(): void @@ -78,7 +127,7 @@ public function testReadAtIsPopulated(): void $messages = $this->getMessages->forConversation( ConversationFixture::CONVERSATION_ACCEPTED, PlayerFixture::PLAYER_REGULAR, - ); + )->messages; // First 3 messages have readAt set in fixtures self::assertNotNull($messages[0]->readAt); diff --git a/tests/Services/SystemMessageSenderTest.php b/tests/Services/SystemMessageSenderTest.php index 91548756..5243399c 100644 --- a/tests/Services/SystemMessageSenderTest.php +++ b/tests/Services/SystemMessageSenderTest.php @@ -48,7 +48,7 @@ public function testSystemMessageIsCreatedInConversation(): 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); @@ -73,7 +73,7 @@ public function testSystemMessageWithTargetPlayerResolvesDifferentlyPerViewer(): $messages = $this->getMessages->forConversation( ConversationFixture::CONVERSATION_MARKETPLACE, PlayerFixture::PLAYER_WITH_STRIPE, - ); + )->messages; $systemMessages = array_values(array_filter($messages, static fn ($m) => $m->isSystemMessage)); self::assertSame('messaging.system.listing_reserved_for_you', $systemMessages[0]->systemTranslationKey); @@ -81,7 +81,7 @@ public function testSystemMessageWithTargetPlayerResolvesDifferentlyPerViewer(): $messages = $this->getMessages->forConversation( ConversationFixture::CONVERSATION_MARKETPLACE, PlayerFixture::PLAYER_WITH_FAVORITES, - ); + )->messages; $systemMessages = array_values(array_filter($messages, static fn ($m) => $m->isSystemMessage)); self::assertSame('messaging.system.listing_reserved_for_this_puzzler', $systemMessages[0]->systemTranslationKey); } diff --git a/translations/messages.cs.yml b/translations/messages.cs.yml index ce93d0b2..02407330 100644 --- a/translations/messages.cs.yml +++ b/translations/messages.cs.yml @@ -340,6 +340,9 @@ "tag": "Tag" "competition": "Soutěž / událost" "competition_help": "Vyberte soutěž, pokud tento výsledek pochází ze soutěže nebo online události." + "competition_hint_series": "U opakujících se událostí vyberte konkrétní edici (datum), které jste se zúčastnili." + "competition_live_badge": "živě" + "competition_not_selectable": "Tuto soutěž nebo událost nelze vybrat. Vyberte prosím jednu ze seznamu." "tags": "Tagy" "feedback": "Zpráva" "feedback_help": "Pošlete nám návrh na novou funkci nebo zlepšení, report chyby, co se vám (ne)líbí, může to být cokoliv, co vás napadne..." @@ -477,6 +480,7 @@ "past_editions": "Minulé edice" "edition_date": "Datum" "no_editions_yet": "Zatím žádné edice." + "add_my_time": "Přidat můj čas z této události" "filter": "filters": "Filtry" "time_period": "Období" @@ -546,6 +550,8 @@ "table_endpoint": "Endpoint" "table_section_me": "Vaše vlastní data (/me)" "table_section_players": "Veřejná data ostatních hráčů" + "table_section_puzzles": "Katalog puzzlí" + "table_section_library": "Knihovna puzzlí (collections:read)" "summary_pat": "PAT — pouze vaše data. Žádné scope nejsou potřeba, plný přístup ke všem endpointům /me." "summary_auth_code": "Auth Code — všechny endpointy. Přístup závisí na udělených scope. K dispozici jsou i scope pro zápis." "summary_client_cred": "Client Credentials — přístup pouze pro čtení k datům libovolného veřejného hráče. Žádné endpointy /me." @@ -1708,6 +1714,7 @@ "no_conversations_yet": "Zatím žádné konverzace." "no_ignored_conversations": "Žádné ignorované konverzace." "no_messages_yet": "Zatím žádné zprávy. Začněte konverzaci!" + "load_older_messages": "Načíst starší zprávy" "no_pending_requests": "Žádné čekající žádosti o zprávu." "no_requests": "Nemáte žádné čekající žádosti." "pending_notice": "Tato žádost o konverzaci čeká na schválení." diff --git a/translations/messages.de.yml b/translations/messages.de.yml index 859dd844..f46c7260 100644 --- a/translations/messages.de.yml +++ b/translations/messages.de.yml @@ -351,6 +351,9 @@ "tag": "Tag" "competition": "Wettbewerb / Veranstaltung" "competition_help": "Wählen Sie den Wettbewerb, wenn dieses Ergebnis von einem Wettbewerb oder einer Online-Veranstaltung stammt." + "competition_hint_series": "Wählen Sie bei wiederkehrenden Veranstaltungen die konkrete Ausgabe (Datum), an der Sie teilgenommen haben." + "competition_live_badge": "live" + "competition_not_selectable": "Dieser Wettbewerb oder diese Veranstaltung kann nicht ausgewählt werden. Bitte wählen Sie eine aus der Liste." "tags": "Tags" "feedback": "Nachricht" "feedback_help": "Senden Sie uns Vorschläge für neue Funktionen oder Verbesserungen, Fehlermeldungen, was Ihnen (nicht) gefällt, es kann alles sein, was Ihnen in den Sinn kommt..." @@ -457,6 +460,7 @@ "past_editions": "Vergangene Ausgaben" "edition_date": "Datum" "no_editions_yet": "Noch keine Ausgaben." + "add_my_time": "Meine Zeit von dieser Veranstaltung hinzufügen" "filter": "filters": "Filter" "time_period": "Zeitraum" @@ -526,6 +530,8 @@ "table_endpoint": "Endpoint" "table_section_me": "Ihre eigenen Daten (/me)" "table_section_players": "Öffentliche Daten anderer Spieler" + "table_section_puzzles": "Puzzle-Katalog" + "table_section_library": "Puzzle-Bibliothek (collections:read)" "summary_pat": "PAT — nur Ihre eigenen Daten. Keine Scopes erforderlich, voller Zugriff auf alle /me-Endpoints." "summary_auth_code": "Auth Code — alle Endpoints. Zugriff abhängig von gewährten Scopes. Schreib-Scopes verfügbar." "summary_client_cred": "Client Credentials — Lesezugriff auf jeden öffentlichen Spieler. Keine /me-Endpoints." @@ -1710,6 +1716,7 @@ "no_conversations_yet": "Noch keine Konversationen." "no_ignored_conversations": "Keine ignorierten Konversationen." "no_messages_yet": "Noch keine Nachrichten. Starten Sie die Konversation!" + "load_older_messages": "Ältere Nachrichten laden" "no_pending_requests": "Keine ausstehenden Nachrichtenanfragen." "no_requests": "Sie haben keine ausstehenden Anfragen." "pending_notice": "Diese Konversationsanfrage wartet auf Genehmigung." diff --git a/translations/messages.en.yml b/translations/messages.en.yml index 0b76aaf9..4ee12cbf 100644 --- a/translations/messages.en.yml +++ b/translations/messages.en.yml @@ -2050,6 +2050,7 @@ messaging: waiting_for_acceptance: "Waiting for %name% to accept your message request." view: "View" no_messages_yet: "No messages yet. Start the conversation!" + load_older_messages: "Load older messages" type_message: "Type a message..." shift_enter_hint: "Shift+Enter for a new line" message_label: "Message" diff --git a/translations/messages.es.yml b/translations/messages.es.yml index c5e4e122..906161b0 100644 --- a/translations/messages.es.yml +++ b/translations/messages.es.yml @@ -367,6 +367,9 @@ "tag": "Etiqueta" "competition": "Competencia / evento" "competition_help": "Elige la competencia, cuando este resultado sea de una competencia o un evento online." + "competition_hint_series": "Para eventos recurrentes, elige la edición específica (fecha) en la que participaste." + "competition_live_badge": "en vivo" + "competition_not_selectable": "Esta competencia o evento no se puede seleccionar. Por favor, elige uno de la lista." "tags": "Etiquetas" "feedback": "Mensaje" "feedback_help": "Envíanos sugerencias de nuevas características o mejoras, reportes de errores, lo que te gusta o disgusta, puede ser cualquier cosa que se te ocurra..." @@ -604,6 +607,7 @@ "past_editions": "Ediciones pasadas" "edition_date": "Fecha" "no_editions_yet": "Aún no hay ediciones." + "add_my_time": "Añadir mi tiempo de este evento" "filter": "filters": "Filtros" "time_period": "Período" @@ -673,6 +677,8 @@ "table_endpoint": "Endpoint" "table_section_me": "Tus propios datos (/me)" "table_section_players": "Datos públicos de otros jugadores" + "table_section_puzzles": "Catálogo de puzzles" + "table_section_library": "Biblioteca de puzzles (collections:read)" "summary_pat": "PAT — solo tus datos. No se necesitan scopes, acceso completo a todos los endpoints /me." "summary_auth_code": "Auth Code — todos los endpoints. El acceso depende de los scopes otorgados. Scopes de escritura disponibles." "summary_client_cred": "Client Credentials — acceso de solo lectura a cualquier jugador público. Sin endpoints /me." @@ -1710,6 +1716,7 @@ "no_conversations_yet": "Aún no hay conversaciones." "no_ignored_conversations": "No hay conversaciones ignoradas." "no_messages_yet": "Aún no hay mensajes. ¡Inicia la conversación!" + "load_older_messages": "Cargar mensajes anteriores" "no_pending_requests": "No hay solicitudes de mensajes pendientes." "no_requests": "No tienes solicitudes pendientes." "pending_notice": "Esta solicitud de conversación está esperando aprobación." diff --git a/translations/messages.fr.yml b/translations/messages.fr.yml index 8fb76565..d17a8806 100644 --- a/translations/messages.fr.yml +++ b/translations/messages.fr.yml @@ -368,6 +368,9 @@ "tag": "Tag" "competition": "Compétition / événement" "competition_help": "Choisissez la compétition, lorsque ce résultat provient d'une compétition ou d'un événement en ligne." + "competition_hint_series": "Pour les événements récurrents, choisissez l'édition précise (date) à laquelle vous avez participé." + "competition_live_badge": "en direct" + "competition_not_selectable": "Cette compétition ou cet événement ne peut pas être sélectionné. Veuillez en choisir un dans la liste." "tags": "Tags" "feedback": "Message" "feedback_help": "Envoyez-nous une suggestion de nouvelle fonctionnalité ou amélioration, rapport de bug, ce que vous aimez (ou non), cela peut être tout ce qui vous vient à l'esprit..." @@ -460,6 +463,7 @@ "past_editions": "Éditions passées" "edition_date": "Date" "no_editions_yet": "Pas encore d'éditions." + "add_my_time": "Ajouter mon temps de cet événement" "filter": "filters": "Filtres" "time_period": "Période" @@ -529,6 +533,8 @@ "table_endpoint": "Endpoint" "table_section_me": "Vos propres données (/me)" "table_section_players": "Données publiques des autres joueurs" + "table_section_puzzles": "Catalogue de puzzles" + "table_section_library": "Bibliothèque de puzzles (collections:read)" "summary_pat": "PAT — vos données uniquement. Aucun scope nécessaire, accès complet à tous les endpoints /me." "summary_auth_code": "Auth Code — tous les endpoints. L'accès dépend des scopes accordés. Scopes d'écriture disponibles." "summary_client_cred": "Client Credentials — accès en lecture seule à tout joueur public. Pas d'endpoints /me." @@ -1711,6 +1717,7 @@ "no_conversations_yet": "Pas encore de conversations." "no_ignored_conversations": "Aucune conversation ignorée." "no_messages_yet": "Pas encore de messages. Commencez la conversation !" + "load_older_messages": "Charger les messages précédents" "no_pending_requests": "Aucune demande de message en attente." "no_requests": "Vous n'avez aucune demande en attente." "pending_notice": "Cette demande de conversation est en attente d'approbation." diff --git a/translations/messages.ja.yml b/translations/messages.ja.yml index 9c265c58..2a415abd 100644 --- a/translations/messages.ja.yml +++ b/translations/messages.ja.yml @@ -367,6 +367,9 @@ "tag": "タグ" "competition": "競技会 / イベント" "competition_help": "この結果が競技会やオンラインイベントからのものである場合は、競技会を選択してください。" + "competition_hint_series": "定期開催のイベントの場合は、参加したエディション(日付)を選択してください。" + "competition_live_badge": "開催中" + "competition_not_selectable": "この競技会・イベントは選択できません。リストから選択してください。" "tags": "タグ" "feedback": "メッセージ" "feedback_help": "新機能や改善の提案、バグ報告、好きなこと嫌いなこと、頭に浮かんだことなら何でもお送りください..." @@ -517,6 +520,7 @@ "past_editions": "過去のエディション" "edition_date": "日付" "no_editions_yet": "まだエディションがありません。" + "add_my_time": "このイベントのタイムを追加" "filter": "filters": "フィルター" "time_period": "期間" @@ -605,6 +609,8 @@ "table_endpoint": "エンドポイント" "table_section_me": "自分のデータ (/me)" "table_section_players": "他のプレイヤーの公開データ" + "table_section_puzzles": "パズルカタログ" + "table_section_library": "パズルライブラリ (collections:read)" "summary_pat": "PAT — 自分のデータのみ。スコープ不要、すべての/meエンドポイントにフルアクセス。" "summary_auth_code": "Auth Code — すべてのエンドポイント。アクセスは付与されたスコープに依存。書き込みスコープも利用可能。" "summary_client_cred": "Client Credentials — 任意の公開プレイヤーへの読み取り専用アクセス。/meエンドポイントは利用不可。" @@ -1699,6 +1705,7 @@ "no_conversations_yet": "まだ会話がありません。" "no_ignored_conversations": "無視した会話はありません。" "no_messages_yet": "まだメッセージがありません。会話を始めましょう!" + "load_older_messages": "以前のメッセージを読み込む" "no_pending_requests": "保留中のメッセージリクエストはありません。" "no_requests": "保留中のリクエストはありません。" "pending_notice": "この会話リクエストは承認待ちです。"