diff --git a/assets/styles/app.css b/assets/styles/app.css index 6be7357..f5ddc5a 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -534,8 +534,86 @@ footer a { margin-top: 0.25em; } +.author-profile__header-meta { + margin-top: 0.5rem; + max-width: 28rem; + margin-left: auto; + margin-right: auto; + text-align: left; +} + +.author-profile__identity { + list-style: none; + margin: 0.5rem 0 0; + padding: 0; +} + +.author-profile__identity-row { + 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__identity-type { + flex: 0 0 7.5rem; + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text); + opacity: 0.75; +} + +.author-profile__identity-link { + word-break: break-all; + min-width: 0; +} + +.author-profile__payments { + list-style: none; + margin: 0.5rem 0 0; + padding: 0; + max-width: 100%; + 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; + letter-spacing: 0.04em; + color: var(--color-text); + opacity: 0.75; +} + +.author-profile__payment-link { + word-break: break-all; + min-width: 0; +} + +.author-profile__jumble { + margin: 1rem 0 0; + text-align: center; +} + .author-profile__about { text-align: left; + margin-top: 1rem; } .author-profile__divider { diff --git a/config/unfold.yaml b/config/unfold.yaml index e55630c..1bf6cce 100644 --- a/config/unfold.yaml +++ b/config/unfold.yaml @@ -17,6 +17,8 @@ parameters: - 'wss://relay.damus.io' - 'wss://nos.lol' - 'wss://profiles.nostr1.com' + - 'wss://thecitadel.nostr1.com' + - 'wss://nostr.wine' # Example: # article_relays: # - 'wss://nos.lol' @@ -29,6 +31,8 @@ parameters: npub: 'npub1m4ny6hjqzepn4rxknuq94c2gpqzr29ufkkw7ttcxyak7v43n6vvsajc2jl' d_tag: 'newsroom-magazine-on-imwald-by-laeserin' community_articles: true + # Base URL for "Open in Jumble" on author profile (trailing slash optional; npub is appended as /{npub}). + jumble_profile_users_base: 'https://jumble.imwald.eu/users' external_links: - title: "Unfold" url: "https://github.com/decent-newsroom/unfold" diff --git a/src/Controller/AuthorController.php b/src/Controller/AuthorController.php index 6ca8001..3793a95 100644 --- a/src/Controller/AuthorController.php +++ b/src/Controller/AuthorController.php @@ -5,8 +5,10 @@ declare(strict_types=1); namespace App\Controller; use App\Repository\ArticleRepository; -use App\Service\NostrClient; use App\Service\CacheService; +use App\Service\NostrClient; +use App\Service\ProfileIdentityLinksBuilder; +use App\Service\ProfilePaymentLinksBuilder; use Exception; use swentel\nostr\Key\Key; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -19,12 +21,20 @@ class AuthorController extends AbstractController * @throws Exception */ #[Route('/p/{npub}', name: 'author-profile', requirements: ['npub' => '^npub1.*'])] - public function index($npub, NostrClient $nostrClient, CacheService $cacheService, ArticleRepository $articleRepository): Response - { + public function index( + $npub, + NostrClient $nostrClient, + CacheService $cacheService, + ArticleRepository $articleRepository, + ProfilePaymentLinksBuilder $profilePaymentLinks, + ProfileIdentityLinksBuilder $profileIdentityLinks, + ): Response { $keys = new Key(); $pubkey = $keys->convertToHex($npub); - $author = $cacheService->getMetadata($npub); + $bundle = $cacheService->getMetadataBundle($npub); + $author = $bundle['content']; + $kind0Tags = $bundle['kind0_tags']; // Retrieve long-form content for the author try { $list = $nostrClient->getLongFormContentForPubkey($npub); @@ -49,11 +59,26 @@ class AuthorController extends AbstractController return $b->getCreatedAt() <=> $a->getCreatedAt(); }); + $kind10133 = []; + try { + $kind10133 = $nostrClient->getKind10133PaymentTargetEventsForNpub($npub, 20); + } catch (Exception) { + } + $extraPayto = $profilePaymentLinks->collectPaytoUrisFromNipA3Kind10133Events($kind10133); + + $jumbleBase = (string) $this->getParameter('jumble_profile_users_base'); + $jumbleBase = rtrim($jumbleBase, '/'); + $jumbleProfileHref = $jumbleBase !== '' ? $jumbleBase.'/'.$npub : null; + 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_payment_links' => $profilePaymentLinks->buildPaymentRows($author, $kind0Tags, $extraPayto), + 'jumble_profile_href' => $jumbleProfileHref, ]); } @@ -65,6 +90,7 @@ class AuthorController extends AbstractController { $keys = new Key(); $npub = $keys->convertPublicKeyToBech32($pubkey); + return $this->redirectToRoute('author-profile', ['npub' => $npub]); } } diff --git a/src/Enum/KindsEnum.php b/src/Enum/KindsEnum.php index 95f7295..879023a 100644 --- a/src/Enum/KindsEnum.php +++ b/src/Enum/KindsEnum.php @@ -20,5 +20,6 @@ enum KindsEnum: int case ZAP = 9735; // NIP-57, Zaps case HIGHLIGHTS = 9802; case RELAY_LIST = 10002; // NIP-65, Relay list metadata + case PAYMENT_TARGETS = 10133; // NIP-A3, payto: payment targets (replaceable) case APP_DATA = 30078; // NIP-78, Arbitrary custom app data } diff --git a/src/Service/CacheService.php b/src/Service/CacheService.php index f2250d1..eb3c56c 100644 --- a/src/Service/CacheService.php +++ b/src/Service/CacheService.php @@ -24,20 +24,52 @@ readonly class CacheService * @return \stdClass */ public function getMetadata(string $npub): \stdClass + { + return $this->getMetadataBundle($npub)['content']; + } + + /** + * Kind-0 content JSON, tags (for payto/website/nip05), and any relay round trip once per cache item. + * + * @return array{content: \stdClass, kind0_tags: list>} + */ + public function getMetadataBundle(string $npub): array { $aggr = $this->nostrClient->getNostrLandAggrReaderCacheSuffix(); $cacheKey = $aggr === '' ? '0_'.$npub : '0_'.$aggr.'_'.$npub; try { - return $this->cache->get($cacheKey, function (ItemInterface $item) use ($npub) { + $cached = $this->cache->get($cacheKey, function (ItemInterface $item) use ($npub) { $item->expiresAfter(3600); // 1 hour, adjust as needed try { - $meta = $this->nostrClient->getNpubMetadata($npub); + $ev = $this->nostrClient->getNpubMetadata($npub); + $tags = self::normalizeEventTagsList($ev->tags ?? null); + try { + $data = \json_decode((string) $ev->content, false, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException) { + $data = new \stdClass(); + } + if (!\is_object($data)) { + $data = new \stdClass(); + } - return json_decode($meta->content); + return [ + 'content' => $data, + 'kind0_tags' => $tags, + ]; } catch (\Exception $e) { throw new MetadataRetrievalException('Failed to retrieve metadata', 0, $e); } }); + if (\is_array($cached) && isset($cached['content']) && $cached['content'] instanceof \stdClass) { + return [ + 'content' => $cached['content'], + 'kind0_tags' => \is_array($cached['kind0_tags'] ?? null) ? $cached['kind0_tags'] : [], + ]; + } + // Legacy: cache stored only the decoded content object + if ($cached instanceof \stdClass) { + return ['content' => $cached, 'kind0_tags' => []]; + } } catch (\Exception|InvalidArgumentException $e) { $root = $e->getPrevious() ?? $e; $this->logger->warning('Profile metadata fetch failed; using npub placeholder.', [ @@ -47,8 +79,50 @@ readonly class CacheService $content = new \stdClass(); $content->name = substr($npub, 0, 8) . '…' . substr($npub, -4); - return $content; + return [ + 'content' => $content, + 'kind0_tags' => [], + ]; + } + + $content = new \stdClass(); + $content->name = substr($npub, 0, 8) . '…' . substr($npub, -4); + + return [ + 'content' => $content, + 'kind0_tags' => [], + ]; + } + + /** + * @return list> + */ + private static function normalizeEventTagsList(mixed $tags): array + { + if (!\is_array($tags)) { + return []; + } + $out = []; + foreach ($tags as $row) { + if (!\is_array($row) && !\is_object($row)) { + continue; + } + $seq = \is_object($row) ? get_object_vars($row) : $row; + if ($seq === []) { + continue; + } + $r = array_values( + array_map( + static fn (mixed $v): string => (string) $v, + array_values($seq) + ) + ); + if ($r !== [] && (string) ($r[0] ?? '') !== '') { + $out[] = $r; + } } + + return $out; } /** diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index 9f14f9c..6f88c23 100644 --- a/src/Service/NostrClient.php +++ b/src/Service/NostrClient.php @@ -527,6 +527,43 @@ class NostrClient return $events[0]; } + /** + * NIP-A3 kind 10133: payment target events (replaceable) with `["payto", type, authority, ...]` tags. + * + * @return list + */ + public function getKind10133PaymentTargetEventsForNpub(string $npub, int $limit = 20): array + { + $relaysTried = $this->profileMetadataQueryRelayUrlList(); + $relaysTriedStr = implode(', ', array_map(self::relayLogLabel(...), $relaysTried)); + $relaySet = $this->relaySetForProfileMetadataFetch(); + try { + $request = $this->createNostrRequest( + kinds: [KindsEnum::PAYMENT_TARGETS], + filters: ['authors' => [$npub], 'limit' => max(1, min(50, $limit))], + relaySet: $relaySet + ); + $events = $this->processResponse( + $request->send(), + static fn ($ev) => $ev, + ); + } catch (\Throwable $e) { + $this->logger->warning('nostr.kind10133.fetch_failed', [ + 'npub' => $npub, + 'relays' => $relaysTriedStr, + 'error' => $e->getMessage(), + ]); + + return []; + } + if (!\is_array($events) || $events === []) { + return []; + } + usort($events, static fn ($a, $b) => (int) ($b->created_at ?? 0) <=> (int) ($a->created_at ?? 0)); + + return array_values($events); + } + public function getNpubLongForm($npub): void { $subscription = new Subscription(); diff --git a/src/Service/ProfileIdentityLinksBuilder.php b/src/Service/ProfileIdentityLinksBuilder.php new file mode 100644 index 0000000..40dadb5 --- /dev/null +++ b/src/Service/ProfileIdentityLinksBuilder.php @@ -0,0 +1,201 @@ +> $kind0Tags + * + * @return list + */ + public function buildWebsites(object $content, array $kind0Tags): array + { + $raw = []; + foreach ($this->stringsFromJsonField($content, 'website') as $s) { + $raw[] = $s; + } + foreach ($this->stringsFromJsonField($content, 'websites') as $s) { + $raw[] = $s; + } + foreach (self::tagValuesForNames($kind0Tags, ['url', 'website', 'web']) as $s) { + $raw[] = $s; + } + $out = []; + $seen = []; + foreach ($raw as $u) { + $u = trim($u); + if ($u === '') { + continue; + } + $href = $this->normalizeHttpUrl($u); + if ($href === null) { + continue; + } + $k = self::urlDedupKey($href); + if (isset($seen[$k])) { + continue; + } + $seen[$k] = true; + $out[] = [ + 'label' => $this->displayLabelForHttpUrl($href), + 'href' => $href, + ]; + } + usort($out, static fn (array $a, array $b): int => strcasecmp($a['label'], $b['label'])); + + return $out; + } + + /** + * @param list> $kind0Tags + * + * @return list + */ + public function buildNip05(object $content, array $kind0Tags): array + { + $raw = []; + foreach ($this->stringsFromJsonField($content, 'nip05') as $s) { + $raw[] = $s; + } + foreach (self::tagValuesForNames($kind0Tags, ['nip05']) as $s) { + $raw[] = $s; + } + $out = []; + $seen = []; + foreach ($raw as $id) { + $id = trim(strtolower($id)); + if ($id === '' || !str_contains($id, '@')) { + continue; + } + if (isset($seen[$id])) { + continue; + } + $seen[$id] = true; + $parts = explode('@', $id, 2); + $local = $parts[0] ?? ''; + $domain = $parts[1] ?? ''; + if ($local === '' || $domain === '' || str_contains($domain, ' ')) { + continue; + } + $href = 'https://'.$domain.'/.well-known/nostr.json?name='.rawurlencode($local); + $out[] = [ + 'label' => $id, + 'href' => $href, + ]; + } + usort($out, static fn (array $a, array $b): int => strcasecmp($a['label'], $b['label'])); + + return $out; + } + + /** + * @return list + */ + private function stringsFromJsonField(object $o, string $key): array + { + if (!isset($o->{$key})) { + return []; + } + $v = $o->{$key}; + if (\is_string($v)) { + return [trim($v)]; + } + if (!\is_array($v)) { + return []; + } + $out = []; + foreach ($v as $x) { + if (\is_string($x) && trim($x) !== '') { + $out[] = trim($x); + } + } + + return $out; + } + + /** + * @param list> $tags + * @param list $tagNames lowercased + * @return list + */ + private static function tagValuesForNames(array $tags, array $tagNames): array + { + $want = array_fill_keys(array_map(static fn (string $s): string => strtolower($s), $tagNames), true); + $out = []; + foreach ($tags as $t) { + if (!isset($t[0], $t[1])) { + continue; + } + $name = strtolower((string) $t[0]); + if (!isset($want[$name])) { + continue; + } + $v = trim((string) $t[1]); + if ($v !== '') { + $out[] = $v; + } + } + + return $out; + } + + private function normalizeHttpUrl(string $url): ?string + { + $u = trim($url); + if ($u === '') { + return null; + } + if (!preg_match('#^[a-zA-Z][a-zA-Z0-9+.-]*:#', $u)) { + $u = 'https://'.$u; + } + if (!str_starts_with(strtolower($u), 'http://') && !str_starts_with(strtolower($u), 'https://')) { + return null; + } + + return $u; + } + + private static function urlDedupKey(string $href): string + { + $p = @parse_url($href); + if (!\is_array($p)) { + return strtolower($href); + } + $host = strtolower((string) ($p['host'] ?? '')); + $path = (string) ($p['path'] ?? ''); + if ($path !== '/' && str_ends_with($path, '/')) { + $path = rtrim($path, '/'); + } + + return $host.$path.($p['query'] ?? ''); + } + + private function displayLabelForHttpUrl(string $href): string + { + $p = @parse_url($href); + if (\is_array($p) && !empty($p['host'])) { + $host = (string) $p['host']; + $path = (string) ($p['path'] ?? ''); + if ($path === '' || $path === '/') { + return $host; + } + if (strlen($path) > 32) { + return $host.$path; + } + + return $host.$path; + } + + if (strlen($href) > 48) { + return substr($href, 0, 32).'…'; + } + + return $href; + } +} diff --git a/src/Service/ProfilePaymentLinksBuilder.php b/src/Service/ProfilePaymentLinksBuilder.php new file mode 100644 index 0000000..0a771b0 --- /dev/null +++ b/src/Service/ProfilePaymentLinksBuilder.php @@ -0,0 +1,419 @@ +", "", ...]` → `payto:///…` (RFC 8905 family). + * + * NIP-A3: kind 10133 replaceable payment target events; tag `["payto", type, authority, ...]`. + * @see https://github.com/CodyTseng/jumble/blob/master/src/lib/lightning.ts getLightningAddressFromProfile + */ +final class ProfilePaymentLinksBuilder +{ + public const TYPE_LIGHTNING_ADDRESS = 'lightning_address'; + + public const TYPE_LNURL_PAY = 'lnurl_pay'; + + public const TYPE_PAYTO = 'payto'; + + /** + * @param list> $kind0Tags + * @param list $extraPaytoUris from kind 10133 + * + * @return list + */ + public function buildPaymentRows(object $content, array $kind0Tags, array $extraPaytoUris = []): array + { + $a = $this->stringField($content, 'lud16'); + $b = $this->stringField($content, 'lud06'); + $resolved = $this->resolveLud16Lud06($a, $b); + + $rows = []; + $seen = []; + + if ($resolved['lightning_address'] !== null) { + $addr = $resolved['lightning_address']; + $norm = 'la:'.strtolower($addr); + if (!isset($seen[$norm])) { + $seen[$norm] = true; + $rows[] = [ + 'type' => self::TYPE_LIGHTNING_ADDRESS, + 'type_label' => 'Lightning address', + 'label' => $addr, + 'href' => 'lightning:'.$addr, + 'sort' => 0, + ]; + } + } + + if ($resolved['lnurl_pay'] !== null) { + $ln = $resolved['lnurl_pay']; + $norm = 'lnurl:'.strtolower($ln); + if (!isset($seen[$norm])) { + $seen[$norm] = true; + $rows[] = [ + 'type' => self::TYPE_LNURL_PAY, + 'type_label' => 'LNURL Pay', + 'label' => $this->shortenLnurl($ln), + 'href' => 'lightning:'.$ln, + 'sort' => 1, + ]; + } + } + + $allPayto = array_merge( + $this->paytoUrisFromJsonObject($content), + $this->paytoUrisFromNipA3StyleTags($kind0Tags), + $extraPaytoUris + ); + foreach ($allPayto as $uri) { + $uri = self::trimPaytoString($uri); + if ($uri === null) { + continue; + } + if (!self::isPaytoOrLegacyPaytoScheme($uri)) { + continue; + } + $canon = self::normalizePaytoUriForDedup($uri); + if (isset($seen[$canon])) { + continue; + } + $seen[$canon] = true; + $rows[] = [ + 'type' => self::TYPE_PAYTO, + 'type_label' => 'Payto', + 'label' => $this->labelForPaytoUri($uri), + 'href' => $uri, + 'sort' => 2, + ]; + } + + $rows = array_values( + array_filter( + $rows, + static fn (array $r): bool => self::isAllowedPaymentHref($r['href']) + ) + ); + + usort( + $rows, + static function (array $x, array $y): int { + if ($x['sort'] !== $y['sort']) { + return $x['sort'] <=> $y['sort']; + } + + return strcasecmp($x['label'], $y['label']); + } + ); + + return $rows; + } + + /** + * @param list $kind10133Events + * + * @return list + */ + public function collectPaytoUrisFromNipA3Kind10133Events(array $kind10133Events): array + { + $out = []; + foreach ($kind10133Events as $ev) { + if (!\is_object($ev) || (int) ($ev->kind ?? 0) !== KindsEnum::PAYMENT_TARGETS->value) { + continue; + } + $tags = self::normalizeTagsArray($ev->tags ?? null); + foreach ($tags as $t) { + $u = $this->buildPaytoUriFromNipA3Tag($t); + if ($u !== null) { + $out[] = $u; + } + } + } + + return $out; + } + + /** + * NIP-A3: `["payto", type, authority, ...]` or legacy `["payto", "payto:..."]` / `["payto", "payto://..."]`. + * + * @param list $t + */ + private function buildPaytoUriFromNipA3Tag(array $t): ?string + { + if (!isset($t[0]) || strtolower((string) $t[0]) !== 'payto') { + return null; + } + if (\count($t) >= 3) { + $type = strtolower(trim((string) $t[1])); + if ($type === '' || 1 !== preg_match('/^[a-z0-9-]+$/', $type)) { + return null; + } + $rawParts = []; + for ($i = 2; $i < \count($t); ++$i) { + $p = trim((string) $t[$i]); + if ($p !== '') { + $rawParts[] = $p; + } + } + if ($rawParts === []) { + return null; + } + $segs = array_map(static fn (string $p): string => rawurlencode($p), $rawParts); + + return 'payto://'.$type.'/'.implode('/', $segs); + } + if (isset($t[1])) { + $s = self::trimPaytoString((string) $t[1]); + if ($s !== null && self::isPaytoOrLegacyPaytoScheme($s)) { + return $s; + } + } + + return null; + } + + /** + * @param list> $tags + * + * @return list + */ + private function paytoUrisFromNipA3StyleTags(array $tags): array + { + $out = []; + foreach ($tags as $t) { + $u = $this->buildPaytoUriFromNipA3Tag($t); + if ($u !== null) { + $out[] = $u; + } + } + + return $out; + } + + private static function normalizeTagsArray(mixed $tags): array + { + if (!\is_array($tags)) { + return []; + } + $out = []; + foreach ($tags as $row) { + if (!\is_array($row) && !\is_object($row)) { + continue; + } + $seq = \is_object($row) ? get_object_vars($row) : $row; + if ($seq === []) { + continue; + } + $r = array_values( + array_map( + static fn (mixed $v): string => (string) $v, + array_values($seq) + ) + ); + if ($r !== []) { + $out[] = $r; + } + } + + return $out; + } + + private static function isPaytoOrLegacyPaytoScheme(string $s): bool + { + $l = strtolower($s); + + return str_starts_with($l, 'payto:'); + } + + /** + * Deduplication key (lowercase; paths may be lossy for odd encodings, acceptable for same-target merge). + */ + private static function normalizePaytoUriForDedup(string $uri): string + { + if (str_starts_with(strtolower($uri), 'payto://')) { + if (1 === preg_match('#^payto://([a-z0-9-]+)/(.+)$#i', $uri, $m)) { + return 'payto://'.strtolower($m[1]).'/'.strtolower($m[2]); + } + } + + return strtolower($uri); + } + + private static function trimPaytoString(string $s): ?string + { + $s = trim($s); + + return $s === '' ? null : $s; + } + + private function labelForPaytoUri(string $u): string + { + if (1 === preg_match('#^payto://([a-z0-9-]+)/(.+)$#i', $u, $m)) { + $t = $this->stylizePaytoTypeName($m[1]); + $path = rawurldecode((string) $m[2]); + if (str_contains($path, '/')) { + $a = (string) strstr($path, '/', true); + } else { + $a = $path; + } + if (strlen($a) > 42) { + $a = substr($a, 0, 20).'…'.substr($a, -10); + } + + return $t.' · '.$a; + } + if (strlen($u) > 64) { + return substr($u, 0, 36).'…'; + } + + return $u; + } + + private function stylizePaytoTypeName(string $type): string + { + $type = strtolower($type); + + return match ($type) { + 'bitcoin' => 'Bitcoin', + 'lightning' => 'Lightning', + 'ethereum' => 'Ethereum', + 'nano' => 'Nano', + 'monero' => 'Monero', + 'cashme' => 'Cash App', + 'revolut' => 'Revolut', + 'venmo' => 'Venmo', + default => $type, + }; + } + + private static function isAllowedPaymentHref(string $href): bool + { + if ($href === '') { + return false; + } + $h = strtolower($href); + + return str_starts_with($h, 'lightning:') || str_starts_with($h, 'payto:'); + } + + /** + * @return array{lightning_address: ?string, lnurl_pay: ?string} + */ + private function resolveLud16Lud06(?string $a, ?string $b): array + { + $lightningAddress = null; + if ($a !== null && $this->isEmail($a)) { + $lightningAddress = $a; + } elseif ($b !== null && $this->isEmail($b)) { + $lightningAddress = $b; + } + + $lnurl = null; + if ($a !== null && $this->isLnurlBech32($a) && $a !== $lightningAddress) { + $lnurl = $a; + } elseif ($b !== null && $this->isLnurlBech32($b) && $b !== $lightningAddress) { + $lnurl = $b; + } + + return [ + 'lightning_address' => $lightningAddress, + 'lnurl_pay' => $lnurl, + ]; + } + + private function stringField(object $o, string $key): ?string + { + if (!isset($o->{$key})) { + return null; + } + $v = $o->{$key}; + if (!\is_string($v)) { + return null; + } + $v = trim($v); + if ($v === '') { + return null; + } + + return $v; + } + + private function isEmail(string $s): bool + { + return 1 === preg_match('/^[^\s@]+@[^\s@]+\.[^\s@]+$/', $s); + } + + private function isLnurlBech32(string $s): bool + { + $lower = strtolower($s); + + return str_starts_with($lower, 'lnurl'); + } + + private function shortenLnurl(string $lnurl): string + { + if (strlen($lnurl) <= 24) { + return $lnurl; + } + + return substr($lnurl, 0, 10).'…'.substr($lnurl, -8); + } + + /** + * JSON: strings (full `payto:` / `payto://` URI), or objects with `type`+`authority` (NIP-A3-style). + * + * @return list + */ + private function paytoUrisFromJsonObject(object $metadata): array + { + $out = []; + if (!isset($metadata->payto)) { + return $out; + } + $p = $metadata->payto; + if (\is_string($p)) { + if (trim($p) !== '') { + $out[] = $p; + } + + return $out; + } + if (!\is_array($p)) { + return $out; + } + foreach ($p as $item) { + if (\is_string($item) && trim($item) !== '') { + $out[] = $item; + + continue; + } + if (!\is_object($item) && !\is_array($item)) { + continue; + } + $o = (object) $item; + if (isset($o->type, $o->authority) && \is_string($o->type) && \is_string($o->authority)) { + $t = strtolower(trim($o->type)); + $auth = trim($o->authority); + if ($t !== '' && $auth !== '' && 1 === preg_match('/^[a-z0-9-]+$/', $t)) { + $out[] = 'payto://'.$t.'/'.rawurlencode($auth); + + continue; + } + } + foreach (['uri', 'url', 'payto', 'href', 'value'] as $k) { + if (isset($o->{$k}) && \is_string($o->{$k}) && trim($o->{$k}) !== '') { + $out[] = trim($o->{$k}); + break; + } + } + } + + return $out; + } +} diff --git a/templates/pages/author.html.twig b/templates/pages/author.html.twig index 7ac348f..7deda61 100644 --- a/templates/pages/author.html.twig +++ b/templates/pages/author.html.twig @@ -17,12 +17,52 @@ {% endif %}

+ +
+ {% if profile_websites is not empty %} +
    + {% for row in profile_websites %} +
  • + Website + {{ row.label|e }} +
  • + {% endfor %} +
+ {% endif %} + {% if profile_nip05 is not empty %} + + {% endif %} + {% if profile_payment_links is not empty %} +
    + {% for row in profile_payment_links %} +
  • + {{ row.type_label }} + {{ row.label|e }} +
  • + {% endfor %} +
+ {% endif %} +
+
{% if author.about is defined %} {{ author.about|markdown_to_html|mentionify|linkify }} {% endif %}
+ {% if jumble_profile_href is not null and jumble_profile_href != '' %} +

+ View on Jumble +

+ {% endif %} +