diff --git a/assets/controllers/progress_bar_controller.js b/assets/controllers/progress_bar_controller.js index 43dbcd5..e53505d 100644 --- a/assets/controllers/progress_bar_controller.js +++ b/assets/controllers/progress_bar_controller.js @@ -43,6 +43,8 @@ export default class extends Controller { return; } this.barTarget.classList.add('pb-indeterminate'); + this.barTarget.style.transition = 'none'; + this.barTarget.style.width = '100%'; const finish = () => { this.completeToDone(); }; @@ -136,7 +138,8 @@ export default class extends Controller { } sessionStorage.setItem(STORAGE_KEY, '1'); this.barTarget.style.transition = 'none'; - this.barTarget.style.width = '0'; this.barTarget.classList.add('pb-indeterminate'); + /* Full-width track; motion is the ::before sweep in CSS (avoids keyframed width 20%↔55%). */ + this.barTarget.style.width = '100%'; } } diff --git a/assets/styles/app.css b/assets/styles/app.css index ba181f1..1d0efa6 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -536,7 +536,8 @@ footer a { .author-profile__header-meta { margin-top: 0.5rem; - max-width: 28rem; + max-width: min(100%, 40rem); + width: 100%; margin-left: auto; margin-right: auto; text-align: left; @@ -548,18 +549,19 @@ footer a { padding: 0; } -.author-profile__identity-row { - display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: 0.35rem 0.5rem; +.author-profile__meta-line, +.author-profile__identity-row, +.author-profile__payment { + display: grid; + grid-template-columns: 7.5rem minmax(0, 1fr); + column-gap: 0.5rem; + align-items: center; margin: 0.35rem 0; font-size: 0.9rem; line-height: 1.35; } .author-profile__identity-type { - flex: 0 0 7.5rem; font-size: 0.72rem; font-weight: 600; text-transform: uppercase; @@ -568,9 +570,36 @@ footer a { opacity: 0.75; } -.author-profile__identity-link { - word-break: break-all; +.author-profile__meta-value, +.author-profile__identity-link, +.author-profile__payment-link { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.author-profile__nip05-value { + display: flex; + align-items: center; + gap: 0.3rem; + min-width: 0; + overflow: hidden; +} + +.author-profile__nip05-value .author-profile__identity-link { + flex: 1 1 0; min-width: 0; + word-break: normal; +} + +.author-profile__nip05-verified { + color: #2e7d32; + font-size: 0.88em; + font-weight: 600; + line-height: 1; + opacity: 0.85; + user-select: none; } .author-profile__payments { @@ -581,18 +610,7 @@ footer a { text-align: left; } -.author-profile__payment { - display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: 0.35rem 0.5rem; - margin: 0.35rem 0; - font-size: 0.9rem; - line-height: 1.35; -} - .author-profile__payment-type { - flex: 0 0 7.5rem; font-size: 0.72rem; font-weight: 600; text-transform: uppercase; @@ -601,11 +619,6 @@ footer a { opacity: 0.75; } -.author-profile__payment-link { - word-break: break-all; - min-width: 0; -} - .author-profile__jumble { margin: 1rem 0 0; text-align: center; diff --git a/assets/styles/layout.css b/assets/styles/layout.css index 23c1157..79ae118 100644 --- a/assets/styles/layout.css +++ b/assets/styles/layout.css @@ -88,25 +88,41 @@ header { background: var(--color-primary); transition: width 0.4s ease; z-index: 1000; + overflow: hidden; + pointer-events: none; } -/* In-flight navigation: loops until the next page fires `load`, then the bar completes. */ +/* + * In-flight navigation: a full-width track with a short segment that sweeps left → right + * (do not keyframe the track width: 20% / 55% / 28% read as a half-screen rubber band). + */ #progress-bar.pb-indeterminate { transition: none; - animation: pb-indeterminate 0.9s ease-in-out infinite; + /* Tinted track: solid fill comes from ::before while loading */ + background: color-mix(in srgb, var(--color-primary) 20%, transparent); + animation: none; } -@keyframes pb-indeterminate { - 0% { - width: 20%; - } +#progress-bar.pb-indeterminate::before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 35%; + height: 100%; + background: var(--color-primary); + border-radius: 0 2px 2px 0; + animation: pb-sweep 1.15s cubic-bezier(0.4, 0, 0.2, 1) infinite; +} - 50% { - width: 55%; +@keyframes pb-sweep { + 0% { + transform: translateX(-100%); } 100% { - width: 28%; + /* Move one segment width past the right edge of the 100% track */ + transform: translateX(calc(100% / 0.35 + 100%)); } } diff --git a/config/services.yaml b/config/services.yaml index 86ebdce..5b32f32 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -54,3 +54,6 @@ services: App\Service\CacheService: arguments: $appCache: '@cache.app' + App\Service\Nip05VerificationService: + arguments: + $appCache: '@cache.app' diff --git a/src/Command/PrewarmCommand.php b/src/Command/PrewarmCommand.php index f868531..936f891 100644 --- a/src/Command/PrewarmCommand.php +++ b/src/Command/PrewarmCommand.php @@ -6,13 +6,16 @@ namespace App\Command; use App\Entity\Article; use App\Repository\ArticleRepository; +use App\Repository\FeaturedAuthorRepository; use App\Service\ArticleCommentThreadLoader; use App\Service\CacheService; use App\Service\FeaturedAuthorSync; use App\Service\MagazineContentService; +use App\Service\Nip05VerificationService; use App\Service\MagazineRefresher; use App\Service\Nip09DeletionApplier; use App\Service\NostrClient; +use App\Service\ProfileIdentityLinksBuilder; use Psr\Log\LoggerInterface; use swentel\nostr\Key\Key; use Symfony\Component\Console\Attribute\AsCommand; @@ -32,7 +35,7 @@ use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; */ #[AsCommand( name: 'app:prewarm', - description: 'Refresh magazine indices, NIP-09 deletions, profile metadata, and comment caches', + description: 'Refresh magazine indices, NIP-09 deletions, profile metadata, NIP-05 verification cache, and comment caches', )] final class PrewarmCommand extends Command { @@ -47,6 +50,9 @@ final class PrewarmCommand extends Command private readonly ParameterBagInterface $params, private readonly LoggerInterface $logger, private readonly FeaturedAuthorSync $featuredAuthorSync, + private readonly Nip05VerificationService $nip05Verification, + private readonly ProfileIdentityLinksBuilder $profileIdentityLinks, + private readonly FeaturedAuthorRepository $featuredAuthorRepository, ) { parent::__construct(); } @@ -89,6 +95,23 @@ final class PrewarmCommand extends Command } elseif ($phase === 'after_root') { $hb->silent = true; $this->cancelPcntlAlarm(); + $planned = $p['slugs'] ?? null; + if (!\is_array($planned)) { + $planned = []; + } + if ($planned === []) { + $io->writeln(' Magazine root has no child a tag categories; only the root index was stored.'); + } else { + $n = \count($planned); + $io->writeln(sprintf(' Magazine child categories in root (%d):', $n)); + foreach ($planned as $slug) { + $s = (string) $slug; + if (strlen($s) > 120) { + $s = substr($s, 0, 117).'…'; + } + $io->writeln(sprintf(' · %s', $s)); + } + } $bar = $this->createPrewarmProgressBar( $io, max(1, (int) ($p['total_steps'] ?? 1)), @@ -99,10 +122,25 @@ final class PrewarmCommand extends Command } elseif ($phase === 'category_fetched' && $bar !== null) { $bar->advance(1); $slug = (string) ($p['slug'] ?? ''); - if (strlen($slug) > 70) { - $slug = substr($slug, 0, 67).'…'; + $tSlug = $slug; + if (strlen($tSlug) > 70) { + $tSlug = substr($tSlug, 0, 67).'…'; + } + $bar->setMessage($tSlug !== '' ? 'Category: '.$tSlug : 'Category'); + if ($tSlug !== '') { + $step = (int) ($p['step'] ?? 0); + $tot = (int) ($p['total_steps'] ?? 0); + if ($tot > 0) { + $io->writeln(sprintf( + ' [%d/%d] Fetched category index%s', + $step, + $tot, + $tSlug + )); + } else { + $io->writeln(sprintf(' Fetched category index%s', $tSlug)); + } } - $bar->setMessage($slug !== '' ? 'Category: '.$slug : 'Category'); } }); }, $hb); @@ -230,12 +268,25 @@ final class PrewarmCommand extends Command if ($limit > 0) { $pubkeys = \array_slice($pubkeys, 0, $limit); } - $toWarm = []; - foreach ($pubkeys as $pubkey) { - if (strlen($pubkey) === 64) { - $toWarm[] = $pubkey; + $pubkeysSeen = []; + foreach ($pubkeys as $pk) { + if (!\is_string($pk) || 64 !== \strlen($pk)) { + continue; + } + $h = strtolower($pk); + if (ctype_xdigit($h) && !isset($pubkeysSeen[$h])) { + $pubkeysSeen[$h] = true; + } + } + $pubkeys = array_keys($pubkeysSeen); + foreach ($this->featuredAuthorRepository->findAllListedOrderByLocalPart() as $fa) { + $hx = strtolower($fa->getPubkeyHex()); + if (64 === \strlen($hx) && ctype_xdigit($hx) && !isset($pubkeysSeen[$hx])) { + $pubkeys[] = $hx; + $pubkeysSeen[$hx] = true; } } + $toWarm = $pubkeys; $total = \count($toWarm); $n = 0; if ($total === 0) { @@ -269,6 +320,43 @@ final class PrewarmCommand extends Command $io->newLine(2); } $io->success(sprintf('Warmed metadata for %d of %d author(s).', $n, $total)); + + if ($toWarm !== []) { + $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; + } + $hex = strtolower($hex); + $npub = $keys->convertPublicKeyToBech32($hex); + $bundle = $this->cacheService->getMetadataBundle($npub); + $rows = $this->profileIdentityLinks->buildNip05($bundle['content'], $bundle['kind0_tags'] ?? []); + $fa = $this->featuredAuthorRepository->findOneByPubkeyHex($hex); + if ($fa !== null && $fa->isListed() && $domain !== '') { + $rows = $this->profileIdentityLinks->mergeSiteNip05IntoList( + $rows, + $fa->getLocalPart().'@'.$domain + ); + } + foreach ($rows as $r) { + ++$nt; + $label = (string) ($r['label'] ?? ''); + if ($this->nip05Verification->verifyAndCache($hex, $label)) { + ++$nv; + } + } + } + $failed = $nt - $nv; + $io->writeln(sprintf( + ' %d identifier(s) checked: %d verified, %d not verified.', + $nt, + $nv, + $failed + )); + } } else { $io->note('Skipping metadata (--no-metadata).'); } diff --git a/src/Controller/AuthorController.php b/src/Controller/AuthorController.php index 3793a95..791dfbd 100644 --- a/src/Controller/AuthorController.php +++ b/src/Controller/AuthorController.php @@ -5,7 +5,9 @@ declare(strict_types=1); namespace App\Controller; use App\Repository\ArticleRepository; +use App\Repository\FeaturedAuthorRepository; use App\Service\CacheService; +use App\Service\Nip05VerificationService; use App\Service\NostrClient; use App\Service\ProfileIdentityLinksBuilder; use App\Service\ProfilePaymentLinksBuilder; @@ -26,6 +28,8 @@ class AuthorController extends AbstractController NostrClient $nostrClient, CacheService $cacheService, ArticleRepository $articleRepository, + FeaturedAuthorRepository $featuredAuthorRepository, + Nip05VerificationService $nip05Verification, ProfilePaymentLinksBuilder $profilePaymentLinks, ProfileIdentityLinksBuilder $profileIdentityLinks, ): Response { @@ -70,13 +74,22 @@ class AuthorController extends AbstractController $jumbleBase = rtrim($jumbleBase, '/'); $jumbleProfileHref = $jumbleBase !== '' ? $jumbleBase.'/'.$npub : null; + $profileNip05 = $profileIdentityLinks->buildNip05($author, $kind0Tags); + $fa = $featuredAuthorRepository->findOneByPubkeyHex($pubkey); + if ($fa !== null && $fa->isListed()) { + $nipDomain = trim((string) $this->getParameter('nip05_domain')); + $siteNip = $fa->getLocalPart().($nipDomain !== '' ? '@'.$nipDomain : ''); + $profileNip05 = $profileIdentityLinks->mergeSiteNip05IntoList($profileNip05, $siteNip); + } + $profileNip05 = $nip05Verification->enrichRowsWithCache($pubkey, $profileNip05); + return $this->render('pages/author.html.twig', [ 'author' => $author, 'npub' => $npub, 'articles' => $articles, 'is_author_profile' => true, 'profile_websites' => $profileIdentityLinks->buildWebsites($author, $kind0Tags), - 'profile_nip05' => $profileIdentityLinks->buildNip05($author, $kind0Tags), + 'profile_nip05' => $profileNip05, 'profile_payment_links' => $profilePaymentLinks->buildPaymentRows($author, $kind0Tags, $extraPayto), 'jumble_profile_href' => $jumbleProfileHref, ]); diff --git a/src/Controller/FeaturedAuthorsController.php b/src/Controller/FeaturedAuthorsController.php index e76d770..c839dac 100644 --- a/src/Controller/FeaturedAuthorsController.php +++ b/src/Controller/FeaturedAuthorsController.php @@ -36,14 +36,11 @@ final class FeaturedAuthorsController extends AbstractController $bundle = $cacheService->getMetadataBundle($npub); $author = $bundle['content']; $kind0Tags = $bundle['kind0_tags']; - $siteNip05 = $fa->getLocalPart().($domain !== '' ? '@'.$domain : ''); $jumbleProfileHref = $jumbleBase !== '' ? $jumbleBase.'/'.$npub : null; $authors[] = [ 'author' => $author, 'npub' => $npub, - 'site_nip05' => $siteNip05, 'profile_websites' => $profileIdentityLinks->buildWebsites($author, $kind0Tags), - 'profile_nip05' => $profileIdentityLinks->buildNip05($author, $kind0Tags), 'profile_payment_links' => $profilePaymentLinks->buildPaymentRows($author, $kind0Tags, []), 'jumble_profile_href' => $jumbleProfileHref, ]; diff --git a/src/Service/MagazineRefresher.php b/src/Service/MagazineRefresher.php index 17067f0..8ba3cd3 100644 --- a/src/Service/MagazineRefresher.php +++ b/src/Service/MagazineRefresher.php @@ -32,7 +32,8 @@ final class MagazineRefresher * are requested first (e.g. current /cat route) so they are less likely to miss the budget. * * @param (callable(string, array): void)|null $onProgress - * Phases: `before_root`, `after_root` (total_steps, step, slug_count), `category_fetched` (step, total_steps, slug) + * Phases: `before_root`, `after_root` (total_steps, step, slug_count, slugs: list), + * `category_fetched` (step, total_steps, slug) */ public function refreshFromRelays(int $budgetSeconds = 8, array $preferSlugs = [], ?callable $onProgress = null): void { @@ -72,6 +73,7 @@ final class MagazineRefresher 'total_steps' => $totalSteps, 'step' => 1, 'slug_count' => \count($slugs), + 'slugs' => $slugs, ]); $step = 1; foreach ($slugs as $slug) { diff --git a/src/Service/Nip05VerificationService.php b/src/Service/Nip05VerificationService.php new file mode 100644 index 0000000..6e727ab --- /dev/null +++ b/src/Service/Nip05VerificationService.php @@ -0,0 +1,214 @@ +/.well-known/nostr.json and checks the listed pubkey (NIP-05). + * Results are stored in the app cache for UI badges and to avoid re-fetching on every request. + */ +final readonly class Nip05VerificationService +{ + private const CACHE_PREFIX = 'nip05v1_'; + + private const FETCH_TIMEOUT_SEC = 8; + + public function __construct( + private CacheItemPoolInterface $appCache, + private LoggerInterface $logger, + ) { + } + + /** + * @param list $rows + * + * @return list + */ + public function enrichRowsWithCache(string $authorPubkeyHex, array $rows): array + { + if ($rows === []) { + return []; + } + $h = strtolower($authorPubkeyHex); + if (64 !== \strlen($h) || !ctype_xdigit($h)) { + return array_map(static function (array $r): array { + return [...$r, 'verified' => false]; + }, $rows); + } + $out = []; + foreach ($rows as $r) { + $label = (string) ($r['label'] ?? ''); + $n = $this->normalizeNip05($label); + if ($n === null) { + $out[] = [...$r, 'verified' => false]; + + continue; + } + $k = $this->cacheKey($h, $n); + $verified = false; + try { + $item = $this->appCache->getItem($k); + if ($item->isHit() && is_bool($item->get())) { + $verified = (bool) $item->get(); + } + } catch (InvalidArgumentException) { + } + $out[] = [...$r, 'verified' => $verified]; + } + + return $out; + } + + /** + * Fetches the document and records success or failure in cache (24h). + */ + public function verifyAndCache(string $authorPubkeyHex, string $nip05Label): bool + { + $h = strtolower($authorPubkeyHex); + if (64 !== \strlen($h) || !ctype_xdigit($h)) { + return false; + } + $n = $this->normalizeNip05($nip05Label); + if ($n === null) { + return false; + } + $k = $this->cacheKey($h, $n); + $ok = $this->checkRemote($h, $n); + try { + $item = $this->appCache->getItem($k); + $item->set($ok); + $item->expiresAfter(86_400); + $this->appCache->save($item); + } catch (InvalidArgumentException $e) { + $this->logger->warning('nip05.verify_cache_write_failed', [ + 'message' => $e->getMessage(), + ]); + } + + return $ok; + } + + private function cacheKey(string $hexLower, string $nip05Lower): string + { + return self::CACHE_PREFIX.hash('sha256', $hexLower."\0".$nip05Lower); + } + + private function normalizeNip05(string $raw): ?string + { + $s = trim(strtolower($raw)); + if ($s === '' || !str_contains($s, '@')) { + return null; + } + $p = explode('@', $s, 2); + if (($p[0] ?? '') === '' || ($p[1] ?? '') === '' || str_contains($p[1], ' ')) { + return null; + } + + return $s; + } + + private function checkRemote(string $expectedHex, string $nip05Lower): bool + { + $parts = explode('@', $nip05Lower, 2); + $local = (string) ($parts[0] ?? ''); + $domain = (string) ($parts[1] ?? ''); + if ($local === '' || $domain === '') { + return false; + } + $url = 'https://'.$domain.'/.well-known/nostr.json?name='.rawurlencode($local); + $http_response_header = []; + $ctx = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'header' => "User-Agent: Unfold-NIP05-Verify/1.0\r\nAccept: application/json,\r\n", + 'timeout' => self::FETCH_TIMEOUT_SEC, + 'ignore_errors' => true, + ], + 'ssl' => [ + 'verify_peer' => true, + 'verify_peer_name' => true, + ], + ]); + $raw = @file_get_contents($url, false, $ctx); + if ($raw === false) { + $this->logger->info('nip05.verify_fetch_failed', [ + 'nip05' => $nip05Lower, + ]); + + return false; + } + $statusLine = (isset($http_response_header) && \is_array($http_response_header)) + ? (string) ($http_response_header[0] ?? '') + : ''; + if (!preg_match('#\b200\b#', $statusLine)) { + $this->logger->info('nip05.verify_not_200', [ + 'nip05' => $nip05Lower, + 'status' => $statusLine, + ]); + + return false; + } + try { + $data = json_decode($raw, true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return false; + } + if (!\is_array($data) || !isset($data['names']) || !\is_array($data['names'])) { + return false; + } + $val = $this->lookupNameInNames($data['names'], $local); + if (!\is_string($val) || $val === '') { + return false; + } + $rowHex = $this->toHex64($val); + if ($rowHex === null) { + return false; + } + + return hash_equals($expectedHex, $rowHex); + } + + /** + * @param array $names + */ + private function lookupNameInNames(array $names, string $localWanted): mixed + { + if (isset($names[$localWanted])) { + return $names[$localWanted]; + } + $lw = strtolower($localWanted); + foreach ($names as $k => $v) { + if (\is_string($k) && strtolower($k) === $lw) { + return $v; + } + } + + return null; + } + + private function toHex64(string $v): ?string + { + $v = trim($v); + if (64 === \strlen($v) && ctype_xdigit($v)) { + return strtolower($v); + } + if (str_starts_with($v, 'npub1')) { + try { + $k = new Key(); + $hex = $k->convertToHex($v); + if (64 === \strlen($hex) && ctype_xdigit($hex)) { + return strtolower($hex); + } + } catch (\Throwable) { + } + } + + return null; + } +} diff --git a/src/Service/ProfileIdentityLinksBuilder.php b/src/Service/ProfileIdentityLinksBuilder.php index 40dadb5..f893834 100644 --- a/src/Service/ProfileIdentityLinksBuilder.php +++ b/src/Service/ProfileIdentityLinksBuilder.php @@ -94,6 +94,43 @@ final class ProfileIdentityLinksBuilder return $out; } + /** + * Adds a site-assigned NIP-05 (e.g. under the blog domain) into the same list as profile NIP-05, + * with the same link shape as {@see buildNip05}, deduped by label. + * + * @param list $rows + * + * @return list + */ + public function mergeSiteNip05IntoList(array $rows, string $siteNip05): array + { + $siteNip05 = trim(strtolower($siteNip05)); + if ($siteNip05 === '' || !str_contains($siteNip05, '@')) { + return $rows; + } + $seen = []; + foreach ($rows as $r) { + $seen[strtolower((string) ($r['label'] ?? ''))] = true; + } + if (isset($seen[$siteNip05])) { + return $rows; + } + $parts = explode('@', $siteNip05, 2); + $local = $parts[0] ?? ''; + $domain = $parts[1] ?? ''; + if ($local === '' || $domain === '' || str_contains($domain, ' ')) { + return $rows; + } + $href = 'https://'.$domain.'/.well-known/nostr.json?name='.rawurlencode($local); + $rows[] = [ + 'label' => $siteNip05, + 'href' => $href, + ]; + usort($rows, static fn (array $a, array $b): int => strcasecmp($a['label'], $b['label'])); + + return $rows; + } + /** * @return list */ diff --git a/src/Service/ProfilePaymentLinksBuilder.php b/src/Service/ProfilePaymentLinksBuilder.php index 0a771b0..9228fb9 100644 --- a/src/Service/ProfilePaymentLinksBuilder.php +++ b/src/Service/ProfilePaymentLinksBuilder.php @@ -26,7 +26,15 @@ final class ProfilePaymentLinksBuilder * @param list> $kind0Tags * @param list $extraPaytoUris from kind 10133 * - * @return list + * @return list */ public function buildPaymentRows(object $content, array $kind0Tags, array $extraPaytoUris = []): array { @@ -44,7 +52,7 @@ final class ProfilePaymentLinksBuilder $seen[$norm] = true; $rows[] = [ 'type' => self::TYPE_LIGHTNING_ADDRESS, - 'type_label' => 'Lightning address', + 'type_label' => 'Lightning', 'label' => $addr, 'href' => 'lightning:'.$addr, 'sort' => 0, @@ -59,7 +67,7 @@ final class ProfilePaymentLinksBuilder $seen[$norm] = true; $rows[] = [ 'type' => self::TYPE_LNURL_PAY, - 'type_label' => 'LNURL Pay', + 'type_label' => 'Lightning', 'label' => $this->shortenLnurl($ln), 'href' => 'lightning:'.$ln, 'sort' => 1, @@ -67,6 +75,8 @@ final class ProfilePaymentLinksBuilder } } + $lud16ForDedup = $resolved['lightning_address']; + $allPayto = array_merge( $this->paytoUrisFromJsonObject($content), $this->paytoUrisFromNipA3StyleTags($kind0Tags), @@ -80,6 +90,9 @@ final class ProfilePaymentLinksBuilder if (!self::isPaytoOrLegacyPaytoScheme($uri)) { continue; } + if ($lud16ForDedup !== null && self::paytoLightningUriMatchesLightningAddress($uri, $lud16ForDedup)) { + continue; + } $canon = self::normalizePaytoUriForDedup($uri); if (isset($seen[$canon])) { continue; @@ -87,7 +100,7 @@ final class ProfilePaymentLinksBuilder $seen[$canon] = true; $rows[] = [ 'type' => self::TYPE_PAYTO, - 'type_label' => 'Payto', + 'type_label' => 'Pay to', 'label' => $this->labelForPaytoUri($uri), 'href' => $uri, 'sort' => 2, @@ -112,7 +125,77 @@ final class ProfilePaymentLinksBuilder } ); - return $rows; + return $this->collapseGroupLabels($rows); + } + + /** + * Consecutive rows with the same {@see group_key} only show the first column label on the first row + * (e.g. multiple Lightning lines, then Monero). + * + * @param list> $rows + * + * @return list> + */ + private function collapseGroupLabels(array $rows): array + { + $prevKey = null; + $out = []; + foreach ($rows as $r) { + $gk = $this->rowGroupKey($r); + $col = $this->rowGroupColumnLabel($r, $gk); + $r['group_key'] = $gk; + $r['display_type_label'] = $gk === $prevKey ? '' : $col; + $prevKey = $gk; + $out[] = $r; + } + + return $out; + } + + /** + * @param array $r + */ + private function rowGroupKey(array $r): string + { + $t = (string) ($r['type'] ?? ''); + if ($t === self::TYPE_LIGHTNING_ADDRESS || $t === self::TYPE_LNURL_PAY) { + return 'lightning'; + } + if ($t === self::TYPE_PAYTO) { + $h = strtolower((string) ($r['href'] ?? '')); + if (1 === preg_match('#^payto://([a-z0-9-]+)/#i', $h, $m)) { + $sc = strtolower($m[1]); + if ($sc === 'lightning') { + return 'lightning'; + } + + return 'payto:'.$sc; + } + + return 'payto:other'; + } + + return 'other'; + } + + /** + * @param array $r + */ + private function rowGroupColumnLabel(array $r, string $groupKey): string + { + if ($groupKey === 'lightning') { + return 'Lightning'; + } + if (str_starts_with($groupKey, 'payto:')) { + $s = substr($groupKey, 6); + if ($s === 'other') { + return 'Pay to'; + } + + return $this->stylizePaytoTypeName($s); + } + + return (string) ($r['type_label'] ?? 'Pay to'); } /** @@ -365,6 +448,32 @@ final class ProfilePaymentLinksBuilder return substr($lnurl, 0, 10).'…'.substr($lnurl, -8); } + /** + * Skips NIP-A3 / JSON {@see payto://lightning/…} rows that repeat the LUD16 lightning address + * (e.g. same as {@see TYPE_LIGHTNING_ADDRESS} with {@code lightning:user@host}). + */ + private static function paytoLightningUriMatchesLightningAddress(string $uri, string $lud16Email): bool + { + if (!str_starts_with(strtolower($uri), 'payto://lightning/')) { + return false; + } + $lud = strtolower(trim($lud16Email)); + if ($lud === '' || !str_contains($lud, '@')) { + return false; + } + if (1 !== preg_match('#^payto://lightning/(.+)$#i', $uri, $m)) { + return false; + } + $tail = (string) $m[1]; + $first = (string) (str_contains($tail, '/') ? strstr($tail, '/', true) : $tail); + if ($first === '') { + $first = $tail; + } + $first = strtolower(rawurldecode($first)); + + return $first === $lud; + } + /** * JSON: strings (full `payto:` / `payto://` URI), or objects with `type`+`authority` (NIP-A3-style). * diff --git a/templates/pages/author.html.twig b/templates/pages/author.html.twig index c096dc6..9fa2c53 100644 --- a/templates/pages/author.html.twig +++ b/templates/pages/author.html.twig @@ -5,6 +5,7 @@ {% include 'partial/author_profile_header.html.twig' with { author: author, npub: npub, + show_nip05: true, profile_websites: profile_websites, profile_nip05: profile_nip05, profile_payment_links: profile_payment_links, diff --git a/templates/pages/featured_authors.html.twig b/templates/pages/featured_authors.html.twig index 57cc7e0..8c2d849 100644 --- a/templates/pages/featured_authors.html.twig +++ b/templates/pages/featured_authors.html.twig @@ -22,11 +22,11 @@ author: row.author, npub: row.npub, header_tag: 'h2', + show_nip05: false, + profile_nip05: [], profile_websites: row.profile_websites, - profile_nip05: row.profile_nip05, profile_payment_links: row.profile_payment_links, jumble_profile_href: row.jumble_profile_href, - site_nip05: row.site_nip05, } only %} {% endif %} - {% if site_nip05|default('')|trim != '' %} -
    -
  • - Magazine - {{ site_nip05|e }} -
  • -
- {% endif %} - {% if profile_nip05 is not empty %} + {% if show_nip05|default(false) and profile_nip05 is not empty %} {% endif %} {% if profile_payment_links is not empty %} -