diff --git a/assets/controllers/article_comments_controller.js b/assets/controllers/article_comments_controller.js index 5c4d9e9..c5fa54c 100644 --- a/assets/controllers/article_comments_controller.js +++ b/assets/controllers/article_comments_controller.js @@ -12,6 +12,7 @@ export default class extends Controller { static targets = ['container']; connect() { + this.partialReloads = 0; this.boundOnAuth = this.onAuthChanged.bind(this); window.addEventListener('unfold:auth-changed', this.boundOnAuth); if (!this.hasContainerTarget || !this.urlValue) { @@ -67,6 +68,15 @@ export default class extends Controller { return; } this.containerTarget.innerHTML = html; + const isPartial = /data-comments-partial="1"/.test(html); + if (isPartial && this.partialReloads < 2) { + this.partialReloads += 1; + window.setTimeout(() => { + if (this.hasContainerTarget) { + void this.load(); + } + }, 1200); + } const ms = Math.round(performance.now() - t0); if (attempt > 1) { console.info(`[article-comments] fragment OK in ${ms}ms (after ${attempt} attempts)`, this.urlValue); diff --git a/assets/styles/app.css b/assets/styles/app.css index 3694a41..aab3be9 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -954,3 +954,50 @@ a:focus-visible { height: 40px; } } + +.pager { + margin-top: 1.25rem; + display: flex; + justify-content: center; + width: 100%; +} + +.pager__inner { + width: min(100%, 36rem); + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 0.5rem 0.75rem; + border: 1px solid var(--color-border); + background: var(--color-bg-light); +} + +.pager__status { + min-width: 8rem; + text-align: center; +} + +.pager__btn { + min-width: 6.5rem; + text-align: center; +} + +.pager__btn.is-disabled { + opacity: 0.55; + pointer-events: none; +} + +@media (max-width: 640px) { + .pager__inner { + width: 100%; + gap: 0.5rem; + } + .pager__btn { + min-width: 5.25rem; + } + .pager__status { + min-width: 7rem; + font-size: 0.92rem; + } +} diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index 1b7f3e4..3d3330e 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -383,6 +383,7 @@ class ArticleController extends AbstractController $commentsData = null; $commentsPreloaded = false; + $commentReplyContext = $this->buildArticleReplyContext($coordinate, $eid, $articleTitle); $cached = $commentThreadLoader->tryLoadFromCacheOnly($coordinate, $eid); if (null !== $cached) { $commentsData = $this->enrichCommentDataWithReplyContext( @@ -391,6 +392,7 @@ class ArticleController extends AbstractController $eid, $articleTitle ); + $commentReplyContext = $commentsData['comment_reply_context'] ?? $commentReplyContext; $commentsPreloaded = true; } @@ -401,9 +403,36 @@ class ArticleController extends AbstractController 'content' => $html, 'comments_data' => $commentsData, 'comments_preloaded' => $commentsPreloaded, + 'comment_reply_context' => $commentReplyContext, ]); } + /** + * Base article-level reply context so the top "Reply" button can render before async comments load. + * + * @return array{ + * can_publish: bool, + * coordinate: string, + * article_event_id: ?string, + * parent_kind: int, + * rows: array>, + * fragment_url: string + * } + */ + private function buildArticleReplyContext(string $coordinate, ?string $articleEventId, string $articleTitle): array + { + $base = [ + 'list' => [], + 'quotes' => [], + 'commentLinks' => [], + 'quoteLinks' => [], + 'processedContent' => [], + ]; + $enriched = $this->enrichCommentDataWithReplyContext($base, $coordinate, $articleEventId, $articleTitle); + + return $enriched['comment_reply_context']; + } + /** * Fetch complete event to show as preview * POST data contains an object with request params diff --git a/src/Service/ArticleCommentThreadLoader.php b/src/Service/ArticleCommentThreadLoader.php index fb0ded7..9d4d6b4 100644 --- a/src/Service/ArticleCommentThreadLoader.php +++ b/src/Service/ArticleCommentThreadLoader.php @@ -93,18 +93,20 @@ final readonly class ArticleCommentThreadLoader try { $discussion = $this->cache->get($cacheKey, function (ItemInterface $item) use ($coordinate, $articleEventHexId, $t0): array { - // Prewarm + HTTP should share the same key; 2m expiry caused cold misses during normal use. - $item->expiresAfter(86400); $this->logger->info('comments.loader.cache_miss', [ 'elapsed_since_load_start_ms' => (int) round((microtime(true) - $t0) * 1000), ]); $tNostr = microtime(true); // On failure, let this throw: Symfony cache will not store a value, so a prior good thread is not replaced by []. $out = $this->nostrClient->getArticleDiscussion($coordinate, $articleEventHexId); + $partial = (bool) ($out['partial'] ?? false); + // Partial relay snapshots are intentionally short-lived so the next request can pick up late relays. + $item->expiresAfter($partial ? 15 : 86400); $this->logger->info('comments.loader.nostr_ok', [ 'nostr_elapsed_ms' => (int) round((microtime(true) - $tNostr) * 1000), 'thread' => \count($out['thread'] ?? []), 'quotes' => \count($out['quotes'] ?? []), + 'partial' => $partial, ]); return $out; @@ -193,6 +195,7 @@ final readonly class ArticleCommentThreadLoader 'commentLinks' => $commentLinks, 'quoteLinks' => $quoteLinks, 'processedContent' => $processedContent, + 'comments_partial' => (bool) ($discussion['partial'] ?? false), ]; } diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index fadc57f..d73fd80 100644 --- a/src/Service/NostrClient.php +++ b/src/Service/NostrClient.php @@ -34,6 +34,8 @@ class NostrClient /** Extra wall time for {@see bin/nostr_relay_request_worker.php} process vs. WebSocket timeout. */ private const DISCUSSION_WORKER_GRACE_SEC = 5.0; + /** Soft wall-time for parallel discussion collection before returning partial results. */ + private const DISCUSSION_PARALLEL_SOFT_DEADLINE_SEC = 3.5; /** * Hard cap on unique relay URLs for article discussion. More relays do not help much (indexers duplicate) @@ -1224,7 +1226,7 @@ class NostrClient * @param string $coordinate kind:pubkey:d-identifier (e.g. longform address) * @param null|string $rootEventHexId Published article event id (hex) for #e / #q matching * - * @return array{thread: array, quotes: array} + * @return array{thread: array, quotes: array, partial?: bool} */ public function getArticleDiscussion(string $coordinate, ?string $rootEventHexId = null): array { @@ -1315,6 +1317,8 @@ class NostrClient throw new \RuntimeException('Nostr request failed for article discussion', 0, $e); } + $respondedRelayCount = \count($response); + $partial = $respondedRelayCount < \count($plannedRelayUrls); $tParse = microtime(true); $this->processResponse($response, function ($event) use (&$byId) { if (\is_object($event) && isset($event->id)) { @@ -1369,9 +1373,12 @@ class NostrClient $this->logger->info('nostr.article_discussion.done', [ 'thread_count' => \count($thread), 'quotes_count' => \count($quotes), + 'partial' => $partial, + 'responded_relays' => $respondedRelayCount, + 'planned_relays' => \count($plannedRelayUrls), ]); - return ['thread' => $thread, 'quotes' => $quotes]; + return ['thread' => $thread, 'quotes' => $quotes, 'partial' => $partial]; } /** @@ -1449,38 +1456,52 @@ class NostrClient } $merged = []; - foreach ($procs as $wss => $p) { - $p->wait(); - if (!$p->isSuccessful()) { - $err = $p->getErrorOutput(); - $this->logger->warning('nostr.article_discussion.relay_worker_failed', [ - 'relay' => $wss, - 'exit_code' => $p->getExitCode(), - 'stderr' => $err !== '' ? $err : null, - ]); - $merged[$wss] = []; - - continue; - } - $out = trim($p->getOutput()); - if ($out === '') { - $merged[$wss] = []; + $pending = $procs; + $deadlineAt = microtime(true) + self::DISCUSSION_PARALLEL_SOFT_DEADLINE_SEC; + while ($pending !== []) { + foreach ($pending as $wss => $p) { + if ($p->isRunning()) { + continue; + } + unset($pending[$wss]); + if (!$p->isSuccessful()) { + $err = $p->getErrorOutput(); + $this->logger->warning('nostr.article_discussion.relay_worker_failed', [ + 'relay' => $wss, + 'exit_code' => $p->getExitCode(), + 'stderr' => $err !== '' ? $err : null, + ]); - continue; + continue; + } + $out = trim($p->getOutput()); + if ($out === '') { + continue; + } + $decoded = base64_decode($out, true); + if ($decoded === false || $decoded === '') { + continue; + } + $chunk = unserialize($decoded, ['allowed_classes' => true]); + if (!\is_array($chunk)) { + continue; + } + $merged = array_replace($merged, $chunk); } - $decoded = base64_decode($out, true); - if ($decoded === false || $decoded === '') { - $merged[$wss] = []; - - continue; + if ($pending === []) { + break; } - $chunk = unserialize($decoded, ['allowed_classes' => true]); - if (!\is_array($chunk)) { - $merged[$wss] = []; - - continue; + if (microtime(true) >= $deadlineAt) { + foreach ($pending as $wss => $p) { + $this->logger->warning('nostr.article_discussion.relay_worker_soft_timeout', [ + 'relay' => $wss, + 'soft_deadline_sec' => self::DISCUSSION_PARALLEL_SOFT_DEADLINE_SEC, + ]); + $p->stop(0.2); + } + break; } - $merged = array_replace($merged, $chunk); + usleep(100_000); } return $merged; diff --git a/src/Twig/Components/SearchComponent.php b/src/Twig/Components/SearchComponent.php deleted file mode 100644 index 34ca585..0000000 --- a/src/Twig/Components/SearchComponent.php +++ /dev/null @@ -1,127 +0,0 @@ -query)) { - $session = $this->requestStack->getSession(); - if ($session->has(self::SESSION_QUERY_KEY)) { - $this->query = $session->get(self::SESSION_QUERY_KEY); - $this->results = $session->get(self::SESSION_KEY, []); - $this->logger->info('Restored search results from session for query: ' . $this->query); - } - } - } - - #[LiveAction] - public function search(): void - { - $this->logger->info("Query: {$this->query}"); - - if (empty($this->query)) { - $this->results = []; - $this->clearSearchCache(); - return; - } - - // Check if the same query exists in session - $session = $this->requestStack->getSession(); - if ($session->has(self::SESSION_QUERY_KEY) && - $session->get(self::SESSION_QUERY_KEY) === $this->query) { - $this->results = $session->get(self::SESSION_KEY, []); - $this->logger->info('Using cached search results for query: ' . $this->query); - return; - } - - try { - $this->results = []; - - // Use database-based search instead of Elasticsearch - $offset = ($this->page - 1) * $this->resultsPerPage; - $results = $this->articleRepository->searchArticles( - $this->query, - $this->resultsPerPage, - $offset - ); - - $this->logger->info('Search results count: ' . count($results)); - $this->logger->info('Search results: ', ['results' => $results]); - - $this->results = $results; - - // Cache the search results in session - $this->saveSearchToSession($this->query, $this->results); - - } catch (\Exception $e) { - $this->logger->error('Search error: ' . $e->getMessage()); - $this->results = []; - } - } - - /** - * Save search results to session - */ - private function saveSearchToSession(string $query, array $results): void - { - $session = $this->requestStack->getSession(); - $session->set(self::SESSION_QUERY_KEY, $query); - $session->set(self::SESSION_KEY, $results); - $this->logger->info('Saved search results to session for query: ' . $query); - } - - /** - * Clear search cache from session - */ - private function clearSearchCache(): void - { - $session = $this->requestStack->getSession(); - $session->remove(self::SESSION_QUERY_KEY); - $session->remove(self::SESSION_KEY); - $this->logger->info('Cleared search cache from session'); - } -} diff --git a/templates/components/Molecules/ArticleReplyComposer.html.twig b/templates/components/Molecules/ArticleReplyComposer.html.twig new file mode 100644 index 0000000..cd8bacc --- /dev/null +++ b/templates/components/Molecules/ArticleReplyComposer.html.twig @@ -0,0 +1,57 @@ +{% set ctx = comment_reply_context|default(null) %} +{% if ctx and ctx.can_publish|default(false) %} + {% for row in ctx.rows|default([]) %} + {% if row.mode|default('') == 'article' %} +
+
+
+

Reply to this note on Nostr (kind 1111).

+ +
+
+
+
+ + +
+
+ +
+

+
+
+
+
+ {% endif %} + {% endfor %} +{% endif %} diff --git a/templates/components/Molecules/Pagination.html.twig b/templates/components/Molecules/Pagination.html.twig new file mode 100644 index 0000000..164834b --- /dev/null +++ b/templates/components/Molecules/Pagination.html.twig @@ -0,0 +1,19 @@ +{% set _page = page|default(1) %} +{% set _last = last_page|default(1) %} +{% if _last > 1 %} + +{% endif %} diff --git a/templates/components/Organisms/Comments.html.twig b/templates/components/Organisms/Comments.html.twig index c7abd27..023a633 100644 --- a/templates/components/Organisms/Comments.html.twig +++ b/templates/components/Organisms/Comments.html.twig @@ -1,64 +1,5 @@ {% set ctx = comment_reply_context|default(null) %} -{# `rows` can be non-empty for nested replies when the article-level NIP-22 row is missing; do not require length > 0. #} -{% if ctx and ctx.can_publish|default(false) %} - {% for row in ctx.rows %} - {% if row.mode|default('') == 'article' %} -
-
-
-

Reply to this note on Nostr (kind 1111).

- -
-
-
-
- - -
-
- -
-

-
-
-
-
- {% endif %} - {% endfor %} -{% endif %} - -
+
{% for item in list %} {% set cid = item.id|default('')|lower %} {% set cpk = item.pubkey|default('') %} diff --git a/templates/components/SearchComponent.html.twig b/templates/components/SearchComponent.html.twig deleted file mode 100644 index 4b47721..0000000 --- a/templates/components/SearchComponent.html.twig +++ /dev/null @@ -1,29 +0,0 @@ -
- {% if interactive %} -
- -
- - -
-
-
-
-
- {% endif %} - - - {% if this.results is not empty %} - - {% elseif this.query is not empty %} -

{{ 'text.noResults'|trans }}

- {% endif %} -
diff --git a/templates/components/UserMenu.html.twig b/templates/components/UserMenu.html.twig index 4f653d6..4586bca 100644 --- a/templates/components/UserMenu.html.twig +++ b/templates/components/UserMenu.html.twig @@ -16,7 +16,7 @@ {# Write an article#} {# #}
  • - {{ 'heading.search'|trans }} + {{ 'heading.search'|trans }}
  • {#
  • #} {# {{ 'heading.index'|trans }}#} diff --git a/templates/pages/article.html.twig b/templates/pages/article.html.twig index d780b59..2f36cf9 100644 --- a/templates/pages/article.html.twig +++ b/templates/pages/article.html.twig @@ -124,6 +124,8 @@ {# #} {% set article_coordinate = (article.kind ? article.kind.value : 30023) ~ ':' ~ article.pubkey ~ ':' ~ article.slug %} {% set comments_query = { coordinate: article_coordinate, title: article.title|default('') }|merge(article.eventId ? { e: article.eventId } : {}) %} + {% set _reply_ctx = comments_data.comment_reply_context|default(comment_reply_context|default(null)) %} + {% include 'components/Molecules/ArticleReplyComposer.html.twig' with { comment_reply_context: _reply_ctx } only %}
    1 %} {% set _page = pagination.page|default(1) %} {% set _last = pagination.last_page|default(1) %} - + {% set _prev_url = _page > 1 ? path('author-profile', _page > 2 ? { npub: npub, page: _page - 1 } : { npub: npub }) : null %} + {% set _next_url = _page < _last ? path('author-profile', { npub: npub, page: _page + 1 }) : null %} + {% include 'components/Molecules/Pagination.html.twig' with { + page: _page, + last_page: _last, + prev_url: _prev_url, + next_url: _next_url, + aria_label: 'Author articles pagination', + } only %} {% endif %}
    {% endblock %} diff --git a/templates/pages/category.html.twig b/templates/pages/category.html.twig index b307a84..0b45293 100644 --- a/templates/pages/category.html.twig +++ b/templates/pages/category.html.twig @@ -18,8 +18,13 @@ {% set _articles_url = _articles_page > 1 ? url('articles', { page: _articles_page }) : url('articles') %} {% set _category_slug = sync_slug|default(app.request.attributes.get('slug')) %} {% set _category_page = app.request.query.getInt('page', 1) %} - {% set _category_url = _category_page > 1 ? url('magazine-category', {slug: _category_slug, page: _category_page}) : url('magazine-category', {slug: _category_slug}) %} - {% set _canonical_url = _is_articles_route ? _articles_url : (_is_category_route ? _category_url : url('magazine-category', {slug: _category_slug})) %} + {% set _category_has_slug = (_category_slug|default('')|trim) != '' %} + {% set _category_url = _category_has_slug + ? (_category_page > 1 + ? url('magazine-category', {slug: _category_slug, page: _category_page}) + : url('magazine-category', {slug: _category_slug})) + : _articles_url %} + {% set _canonical_url = _is_articles_route ? _articles_url : (_is_category_route and _category_has_slug ? _category_url : _articles_url) %} @@ -52,21 +57,19 @@ {% set _last = pagination.last_page|default(1) %} {% set _is_articles_route = app.request.attributes.get('_route') == 'articles' %} {% set _slug = sync_slug|default(app.request.attributes.get('slug')) %} - {% set _prev_url = _is_articles_route + {% set _prev_url = _page > 1 ? (_is_articles_route ? path('articles', _page > 2 ? { page: _page - 1 } : {}) - : path('magazine-category', _page > 2 ? { slug: _slug, page: _page - 1 } : { slug: _slug }) %} - {% set _next_url = _is_articles_route + : path('magazine-category', _page > 2 ? { slug: _slug, page: _page - 1 } : { slug: _slug })) : null %} + {% set _next_url = _page < _last ? (_is_articles_route ? path('articles', { page: _page + 1 }) - : path('magazine-category', { slug: _slug, page: _page + 1 }) %} - + : path('magazine-category', { slug: _slug, page: _page + 1 })) : null %} + {% include 'components/Molecules/Pagination.html.twig' with { + page: _page, + last_page: _last, + prev_url: _prev_url, + next_url: _next_url, + aria_label: 'Articles pagination', + } only %} {% endif %} {% endblock %} diff --git a/templates/pages/featured_authors.html.twig b/templates/pages/featured_authors.html.twig index 7329906..230aa1c 100644 --- a/templates/pages/featured_authors.html.twig +++ b/templates/pages/featured_authors.html.twig @@ -39,15 +39,15 @@ {% if pagination is defined and pagination.last_page > 1 %} {% set _page = pagination.page|default(1) %} {% set _last = pagination.last_page|default(1) %} - + {% set _prev_url = _page > 1 ? path('featured_authors', _page > 2 ? { page: _page - 1 } : {}) : null %} + {% set _next_url = _page < _last ? path('featured_authors', { page: _page + 1 }) : null %} + {% include 'components/Molecules/Pagination.html.twig' with { + page: _page, + last_page: _last, + prev_url: _prev_url, + next_url: _next_url, + aria_label: 'Featured authors pagination', + } only %} {% endif %}
  • {% endblock %} diff --git a/templates/pages/search.html.twig b/templates/pages/search.html.twig index 1b2b245..c2fafbd 100644 --- a/templates/pages/search.html.twig +++ b/templates/pages/search.html.twig @@ -27,15 +27,15 @@ {% set _page = pagination.page|default(1) %} {% set _last = pagination.last_page|default(1) %} {% set _query = query|default('') %} - + {% set _prev_url = _page > 1 ? path('search', _page > 2 ? { q: _query, page: _page - 1 } : { q: _query }) : null %} + {% set _next_url = _page < _last ? path('search', { q: _query, page: _page + 1 }) : null %} + {% include 'components/Molecules/Pagination.html.twig' with { + page: _page, + last_page: _last, + prev_url: _prev_url, + next_url: _next_url, + aria_label: 'Search pagination', + } only %} {% endif %}
    {% endblock %}