diff --git a/assets/styles/article.css b/assets/styles/article.css index c6b15a2..3c97e36 100644 --- a/assets/styles/article.css +++ b/assets/styles/article.css @@ -86,13 +86,14 @@ gap: 0.35em; } -blockquote { +/* Article body only — avoid 50px vertical margins on comment / quote cards (Atoms:Content markdown). */ +.article-main blockquote { border-left: 6px solid var(--color-bg-light); padding-left: 3px; margin: 50px 0 50px 3px; } -blockquote p { +.article-main blockquote p { font-size: 1.6rem; font-style: italic; color: var(--color-text-mid); @@ -180,20 +181,22 @@ blockquote p { margin-top: 0.75rem; } -/* Thread: no depth indent; one accent color for all replies; compact vertical rhythm */ +/* Thread: no depth indent; one accent color for all replies */ .comments { display: flex; flex-direction: column; - gap: 0.4rem; + gap: 0.55rem; } -.comments .card.comment { +.comments .card.comment, +.comments-quotes__list .card.comment { margin-left: 0; margin-bottom: 0; - padding: 0.5rem 0.65rem 0.5rem 0.7rem; + padding: 0.75rem 0.9rem 0.85rem; border-radius: 6px; border: 1px solid var(--color-border); border-left: 3px solid var(--color-primary); + gap: 0.5rem; } .comments .card.comment--depth-0, @@ -204,19 +207,53 @@ blockquote p { border-left-color: var(--color-primary); } -.comments .card.comment .metadata { - margin-bottom: 0.4rem; +.comments .card.comment .metadata, +.comments-quotes__list .card.comment .metadata { + margin-bottom: 0; } .comment__reply-blurb { - padding: 0.2rem 0.45rem 0.2rem 0.5rem; + padding: 0.4rem 0.55rem 0.45rem 0.55rem; margin: 0 0 0 0.2rem; border-left: 2px solid color-mix(in srgb, var(--color-border) 50%, transparent); background: color-mix(in srgb, var(--color-bg-light) 55%, transparent); border-radius: 0 3px 3px 0; - font-size: 0.78em; - line-height: 1.35; + font-size: 0.82em; + line-height: 1.45; + color: var(--color-text-mid); +} + +/* Markdown blockquotes inside note bodies: tight rhythm (not .article-main blockquote). */ +.comments .card.comment .card-body blockquote, +.comments-quotes__list .card.comment .card-body blockquote { + margin: 0.35rem 0 0.5rem; + padding: 0.3rem 0 0.35rem 0.65rem; + border-left: 3px solid color-mix(in srgb, var(--color-primary) 40%, var(--color-border)); +} + +.comments .card.comment .card-body blockquote p, +.comments-quotes__list .card.comment .card-body blockquote p { + font-size: 1em; + line-height: 1.45; + font-style: italic; color: var(--color-text-mid); + margin: 0; + padding-left: 0; +} + +.comments .card.comment .card-body > :first-child, +.comments-quotes__list .card.comment .card-body > :first-child { + margin-top: 0; +} + +.comments .card.comment .card-body > :last-child, +.comments-quotes__list .card.comment .card-body > :last-child { + margin-bottom: 0; +} + +.comments .card.comment .card-body, +.comments-quotes__list .card.comment .card-body { + line-height: 1.52; } .comment__reply-blurb blockquote, diff --git a/assets/styles/components/_nostr_previews.scss b/assets/styles/components/_nostr_previews.scss index f11db27..a18bb41 100644 --- a/assets/styles/components/_nostr_previews.scss +++ b/assets/styles/components/_nostr_previews.scss @@ -67,6 +67,34 @@ flex-direction: column; gap: 0.35rem; } + + .nostr-address-preview { + position: relative; + } + + .nostr-address-preview--with-menu .nostr-address-preview__body { + padding-right: 2.25rem; + } + + .nostr-address-preview__body { + display: flex; + flex-direction: column; + gap: 0.35rem; + padding: 0.45rem 0.75rem 0.55rem; + + .card-title, + .card-text { + margin: 0; + } + } + + .nostr-address-preview .nostr-preview-card__menu { + position: absolute; + top: 0.35rem; + right: 0.4rem; + margin: 0; + z-index: 1; + } } .nostr-previews { diff --git a/assets/styles/nostr-previews.css b/assets/styles/nostr-previews.css index 55499c0..68c2f66 100644 --- a/assets/styles/nostr-previews.css +++ b/assets/styles/nostr-previews.css @@ -85,12 +85,44 @@ gap: 0.35rem; } +/* naddr previews: avoid global h1–h6 margins + relay tag order quirks; menu out of flow */ +.nostr-preview .nostr-address-preview { + position: relative; +} + +.nostr-preview .nostr-address-preview--with-menu .nostr-address-preview__body { + padding-right: 2.25rem; +} + +.nostr-preview .nostr-address-preview__body { + display: flex; + flex-direction: column; + gap: 0.35rem; + padding: 0.45rem 0.75rem 0.55rem; +} + +.nostr-preview .nostr-address-preview__body .card-title { + margin: 0; +} + +.nostr-preview .nostr-address-preview__body .card-text { + margin: 0; +} + .nostr-preview-card__menu { display: flex; justify-content: flex-end; margin-bottom: 0.35rem; } +.nostr-preview .nostr-address-preview .nostr-preview-card__menu { + position: absolute; + top: 0.35rem; + right: 0.4rem; + margin: 0; + z-index: 1; +} + .nostr-previews h6 { font-size: 0.9rem; margin-bottom: 1rem; diff --git a/config/unfold.yaml b/config/unfold.yaml index e3d5e73..073086d 100644 --- a/config/unfold.yaml +++ b/config/unfold.yaml @@ -8,17 +8,26 @@ parameters: og_headline: 'Nostr, Curated Thoughtfully' og_subheading: 'Imwald Blog by Laeserin' - default_relay: 'wss://TheForest.nostr1.com' + default_relay: 'wss://theforest.nostr1.com' # Extra wss:// URLs for article sync (articles:get), comment threads (NIP-22 / getArticleDiscussion), # and any request that merges the default set with author-specific relays. default_relay is first; duplicates ignored. - article_relays: ['wss://christpill.nostr1.com', 'wss://nostr.land', 'wss://nostr.wine', 'wss://nostr21.com', 'wss://nostr.sovbit.host'] + article_relays: [ + 'wss://christpill.nostr1.com', + 'wss://nostr.land', + 'wss://nostr.wine', + 'wss://nostr21.com', + 'wss://nostr.sovbit.host', + 'wss://orly-relay.imwald.eu' + ] # Kind-0 / profile fetches (author metadata, prewarm). Tried first, then default + article_relays (deduped). - profile_relays: - - 'wss://relay.damus.io' - - 'wss://nos.lol' - - 'wss://profiles.nostr1.com' - - 'wss://thecitadel.nostr1.com' - - 'wss://nostr.wine' + profile_relays: [ + 'wss://relay.damus.io', + 'wss://nos.lol', + 'wss://profiles.nostr1.com', + 'wss://thecitadel.nostr1.com', + 'wss://nostr.wine', + 'wss://orly-relay.imwald.eu' + ] # Example: # article_relays: # - 'wss://nos.lol' diff --git a/src/Command/PrewarmCommand.php b/src/Command/PrewarmCommand.php index 58a4aa6..cd29397 100644 --- a/src/Command/PrewarmCommand.php +++ b/src/Command/PrewarmCommand.php @@ -360,10 +360,13 @@ final class PrewarmCommand extends Command $io->success(sprintf('Warmed metadata for %d of %d author(s).', $n, $total)); if ($toWarm !== []) { + $domain = trim((string) $this->params->get('nip05_domain')); + if ($domain !== '') { + $this->waitForSiteWellKnownBeforeVerification($io, $domain); + } $io->writeln('Verifying NIP-05 (HTTPS /.well-known/nostr.json, per identifier)…'); $nt = 0; $nv = 0; - $domain = trim((string) $this->params->get('nip05_domain')); foreach ($toWarm as $hex) { if (64 !== \strlen($hex) || !ctype_xdigit($hex)) { continue; @@ -471,6 +474,104 @@ final class PrewarmCommand extends Command return Command::SUCCESS; } + private function waitForSiteWellKnownBeforeVerification(SymfonyStyle $io, string $domain): void + { + $expected = []; + foreach ($this->featuredAuthorRepository->findAllListedOrderByLocalPart() as $row) { + $local = trim((string) $row->getLocalPart()); + $hex = strtolower(trim((string) $row->getPubkeyHex())); + if ($local === '' || 64 !== \strlen($hex) || !ctype_xdigit($hex)) { + continue; + } + $expected[$local] = $hex; + } + if ($expected === []) { + return; + } + + $io->writeln(sprintf( + 'Ensuring site NIP-05 directory is current before verification (%s, names: %d)…', + $domain, + \count($expected) + )); + $url = 'https://'.$domain.'/.well-known/nostr.json'; + $attempt = 0; + $maxAttempts = 4; + while ($attempt < $maxAttempts) { + $attempt++; + $payload = $this->fetchWellKnownNamesMap($url); + if ($payload !== null && $this->wellKnownHasExpectedNames($payload, $expected)) { + $io->writeln(sprintf(' OK /.well-known/nostr.json is up-to-date (attempt %d/%d).', $attempt, $maxAttempts)); + + return; + } + if ($attempt < $maxAttempts) { + usleep(1_500_000); + } + } + $io->warning('Site /.well-known/nostr.json did not reflect current featured authors before verification; NIP-05 checks may fail transiently.'); + } + + /** + * @return array|null + */ + private function fetchWellKnownNamesMap(string $url): ?array + { + $ctx = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'header' => "User-Agent: Unfold-Prewarm/1.0\r\nAccept: application/json\r\n", + 'timeout' => 8, + 'ignore_errors' => true, + ], + 'ssl' => [ + 'verify_peer' => true, + 'verify_peer_name' => true, + ], + ]); + $raw = @file_get_contents($url, false, $ctx); + if ($raw === false) { + return null; + } + try { + $decoded = json_decode($raw, true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return null; + } + if (!\is_array($decoded) || !isset($decoded['names']) || !\is_array($decoded['names'])) { + return null; + } + $out = []; + foreach ($decoded['names'] as $k => $v) { + if (!\is_string($k) || !\is_string($v)) { + continue; + } + $key = trim($k); + $hex = strtolower(trim($v)); + if ($key === '' || 64 !== \strlen($hex) || !ctype_xdigit($hex)) { + continue; + } + $out[$key] = $hex; + } + + return $out; + } + + /** + * @param array $names + * @param array $expected + */ + private function wellKnownHasExpectedNames(array $names, array $expected): bool + { + foreach ($expected as $local => $hex) { + if (!isset($names[$local]) || !hash_equals($hex, $names[$local])) { + return false; + } + } + + return true; + } + /** * Absolute used/budget wall seconds for the comment phase, e.g. "127.4/600 s" (not a percentage). */ diff --git a/src/Service/MagazineContentService.php b/src/Service/MagazineContentService.php index 33a7c6f..4faaa0f 100644 --- a/src/Service/MagazineContentService.php +++ b/src/Service/MagazineContentService.php @@ -290,6 +290,9 @@ final class MagazineContentService { $n = 0; foreach ($this->getCategorySlugsFromStore() as $catSlug) { + // If a category 30040 wasn't persisted during the refresh phase (relay errors/timeouts), + // try one direct fetch here so long-form ingest and reports are not silently incomplete. + $this->warmCategoryIndexIfMissing($catSlug); $all = $this->findAllLongformCoordinatesForCategory($catSlug); if ($all === []) { continue; @@ -331,8 +334,23 @@ final class MagazineContentService $totResolved = 0; $totMissing = 0; foreach ($this->getCategorySlugsFromStore() as $slug) { + $this->warmCategoryIndexIfMissing($slug); $catIndex = $this->store->getCategory($slug); if ($catIndex === null) { + $totMissing++; + $categories[] = [ + 'slug' => $slug, + 'title' => $slug, + 'event_id' => '', + 'listed_total' => 0, + 'resolved_total' => 0, + 'missing_total' => 1, + 'entries' => [[ + 'coordinate' => 'category:'.$slug, + 'status' => 'missing', + 'reason' => 'category_index_unavailable', + ]], + ]; continue; } $title = $slug; diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index 23c5913..ba7bb65 100644 --- a/src/Service/NostrClient.php +++ b/src/Service/NostrClient.php @@ -2847,6 +2847,43 @@ class NostrClient 'group_key' => $gkey, 'authors_filter' => $g['pubkey'], ]); + // Some relays fail to satisfy combined authors+#d filters for parameterized replaceables. + // Fallback: query by #d only, then enforce author and d-tag match client-side. + $fallbackReq = $this->createNostrRequest( + [$kindEnum], + ['tag' => ['#d', $dTags]], + $this->defaultRelaySet, + ); + $fallbackEvents = $this->processResponse( + $fallbackReq->send(), + static fn (object $event) => $event, + ); + $fallbackMatched = []; + $expectedPubkey = strtolower((string) $g['pubkey']); + $expectedD = array_fill_keys($dTags, true); + foreach ($fallbackEvents as $ev) { + if (!\is_object($ev)) { + continue; + } + $evPubkey = strtolower((string) ($ev->pubkey ?? '')); + if ($evPubkey !== $expectedPubkey) { + continue; + } + $evD = self::eventDTagValue($ev); + if ($evD === null || !isset($expectedD[$evD])) { + continue; + } + $fallbackMatched[] = $ev; + } + $this->logger->info('[longform_ingest] ingestLongform: fallback #d-only query result', [ + 'group_key' => $gkey, + 'fallback_raw_wire_count' => \count($fallbackEvents), + 'fallback_matched_count' => \count($fallbackMatched), + ]); + if ($fallbackMatched !== []) { + $events = $fallbackMatched; + $rawCount = \count($events); + } } $merged = self::mergeNip33ParameterizedWireEvents($events); $mergedDetail = []; @@ -2860,13 +2897,43 @@ class NostrClient 'merged_count' => \count($merged), 'one_row_per_nip33_address' => $mergedDetail, ]); + $kindInt = (int) $g['kind']; + $authorHex = strtolower((string) $g['pubkey']); + $expectedAddresses = []; + foreach ($dTags as $dt) { + $expectedAddresses[$kindInt.':'.$authorHex.':'.$dt] = true; + } + $seenAddresses = []; foreach ($merged as $event) { if (!\is_object($event)) { continue; } + $addr = self::nip33ParameterizedReplaceableAddress($event); + if ($addr !== null) { + $seenAddresses[$addr] = true; + } $article = $this->articleFactory->createFromLongFormContentEvent($event); $this->saveEachArticleToTheDatabase($article); } + foreach (array_keys($expectedAddresses) as $coordinate) { + if (isset($seenAddresses[$coordinate])) { + continue; + } + $this->logger->notice('[longform_ingest] ingestLongform: address missing after batch merge; trying author NIP-65 relays', [ + 'coordinate' => $coordinate, + ]); + $byCoord = $this->getArticlesByCoordinates([$coordinate]); + $evExtra = $byCoord[$coordinate] ?? null; + if ($evExtra === null) { + $this->logger->warning('[longform_ingest] ingestLongform: still no event for coordinate (not on default or author relays)', [ + 'coordinate' => $coordinate, + ]); + + continue; + } + $article = $this->articleFactory->createFromLongFormContentEvent($evExtra); + $this->saveEachArticleToTheDatabase($article); + } } catch (\Throwable $e) { $this->logger->error( sprintf('[longform_ingest] ingestLongform: exception in group %s: %s', (string) $gkey, $e->getMessage()), diff --git a/templates/components/Molecules/NostrPreviewContent.html.twig b/templates/components/Molecules/NostrPreviewContent.html.twig index c0b1584..11223c7 100644 --- a/templates/components/Molecules/NostrPreviewContent.html.twig +++ b/templates/components/Molecules/NostrPreviewContent.html.twig @@ -1,21 +1,36 @@ {% if preview.type == 'naddr' %} -
- {% set _na_share = nostr_event_share(preview) %} + {% set naddr_title = null %} + {% set naddr_summary = null %} + {% if preview.tags is defined %} + {% for tag in preview.tags %} + {% if tag[0] == 'title' and naddr_title is null and tag[1] is defined and tag[1]|default('')|trim != '' %} + {% set naddr_title = tag[1] %} + {% elseif tag[0] == 'summary' and naddr_summary is null and tag[1] is defined and tag[1]|default('')|trim != '' %} + {% set naddr_summary = tag[1] %} + {% endif %} + {% endfor %} + {% endif %} + {% set _na_share = nostr_event_share(preview) %} +
{% if _na_share %}
{% include 'components/Molecules/NostrShareMenu.html.twig' with { share: _na_share, event_menu: true } only %}
{% endif %} - {% for tag in preview.tags %} - {% if tag[0] == 'title' %} -
-
{{ tag[1] }}
-
+
+ {% if naddr_title %} +
{{ naddr_title }}
{% endif %} - {% if tag[0] == 'summary' %} -

{{ tag[1] }}

- {% endif %} - {% endfor %} +

+ {% if naddr_summary %} + {{ naddr_summary }} + {% elseif preview.content is defined and preview.content|trim != '' %} + {{ preview.content|length > 220 ? preview.content|slice(0, 220) ~ '…' : preview.content }} + {% else %} + + {% endif %} +

+
{% elseif preview.type == 'nevent' %} {% if preview.kind == 9802 %}