Browse Source

add payment information to the profiles

imwald
Silberengel 1 week ago
parent
commit
889dccc3f9
  1. 78
      assets/styles/app.css
  2. 4
      config/unfold.yaml
  3. 34
      src/Controller/AuthorController.php
  4. 1
      src/Enum/KindsEnum.php
  5. 82
      src/Service/CacheService.php
  6. 37
      src/Service/NostrClient.php
  7. 201
      src/Service/ProfileIdentityLinksBuilder.php
  8. 419
      src/Service/ProfilePaymentLinksBuilder.php
  9. 40
      templates/pages/author.html.twig

78
assets/styles/app.css

@ -534,8 +534,86 @@ footer a {
margin-top: 0.25em; 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 { .author-profile__about {
text-align: left; text-align: left;
margin-top: 1rem;
} }
.author-profile__divider { .author-profile__divider {

4
config/unfold.yaml

@ -17,6 +17,8 @@ parameters:
- 'wss://relay.damus.io' - 'wss://relay.damus.io'
- 'wss://nos.lol' - 'wss://nos.lol'
- 'wss://profiles.nostr1.com' - 'wss://profiles.nostr1.com'
- 'wss://thecitadel.nostr1.com'
- 'wss://nostr.wine'
# Example: # Example:
# article_relays: # article_relays:
# - 'wss://nos.lol' # - 'wss://nos.lol'
@ -29,6 +31,8 @@ parameters:
npub: 'npub1m4ny6hjqzepn4rxknuq94c2gpqzr29ufkkw7ttcxyak7v43n6vvsajc2jl' npub: 'npub1m4ny6hjqzepn4rxknuq94c2gpqzr29ufkkw7ttcxyak7v43n6vvsajc2jl'
d_tag: 'newsroom-magazine-on-imwald-by-laeserin' d_tag: 'newsroom-magazine-on-imwald-by-laeserin'
community_articles: true 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: external_links:
- title: "Unfold" - title: "Unfold"
url: "https://github.com/decent-newsroom/unfold" url: "https://github.com/decent-newsroom/unfold"

34
src/Controller/AuthorController.php

@ -5,8 +5,10 @@ declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use App\Repository\ArticleRepository; use App\Repository\ArticleRepository;
use App\Service\NostrClient;
use App\Service\CacheService; use App\Service\CacheService;
use App\Service\NostrClient;
use App\Service\ProfileIdentityLinksBuilder;
use App\Service\ProfilePaymentLinksBuilder;
use Exception; use Exception;
use swentel\nostr\Key\Key; use swentel\nostr\Key\Key;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@ -19,12 +21,20 @@ class AuthorController extends AbstractController
* @throws Exception * @throws Exception
*/ */
#[Route('/p/{npub}', name: 'author-profile', requirements: ['npub' => '^npub1.*'])] #[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(); $keys = new Key();
$pubkey = $keys->convertToHex($npub); $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 // Retrieve long-form content for the author
try { try {
$list = $nostrClient->getLongFormContentForPubkey($npub); $list = $nostrClient->getLongFormContentForPubkey($npub);
@ -49,11 +59,26 @@ class AuthorController extends AbstractController
return $b->getCreatedAt() <=> $a->getCreatedAt(); 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', [ return $this->render('pages/author.html.twig', [
'author' => $author, 'author' => $author,
'npub' => $npub, 'npub' => $npub,
'articles' => $articles, 'articles' => $articles,
'is_author_profile' => true, '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(); $keys = new Key();
$npub = $keys->convertPublicKeyToBech32($pubkey); $npub = $keys->convertPublicKeyToBech32($pubkey);
return $this->redirectToRoute('author-profile', ['npub' => $npub]); return $this->redirectToRoute('author-profile', ['npub' => $npub]);
} }
} }

1
src/Enum/KindsEnum.php

@ -20,5 +20,6 @@ enum KindsEnum: int
case ZAP = 9735; // NIP-57, Zaps case ZAP = 9735; // NIP-57, Zaps
case HIGHLIGHTS = 9802; case HIGHLIGHTS = 9802;
case RELAY_LIST = 10002; // NIP-65, Relay list metadata 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 case APP_DATA = 30078; // NIP-78, Arbitrary custom app data
} }

82
src/Service/CacheService.php

@ -24,20 +24,52 @@ readonly class CacheService
* @return \stdClass * @return \stdClass
*/ */
public function getMetadata(string $npub): \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<list<string>>}
*/
public function getMetadataBundle(string $npub): array
{ {
$aggr = $this->nostrClient->getNostrLandAggrReaderCacheSuffix(); $aggr = $this->nostrClient->getNostrLandAggrReaderCacheSuffix();
$cacheKey = $aggr === '' ? '0_'.$npub : '0_'.$aggr.'_'.$npub; $cacheKey = $aggr === '' ? '0_'.$npub : '0_'.$aggr.'_'.$npub;
try { 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 $item->expiresAfter(3600); // 1 hour, adjust as needed
try { 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) { } catch (\Exception $e) {
throw new MetadataRetrievalException('Failed to retrieve metadata', 0, $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) { } catch (\Exception|InvalidArgumentException $e) {
$root = $e->getPrevious() ?? $e; $root = $e->getPrevious() ?? $e;
$this->logger->warning('Profile metadata fetch failed; using npub placeholder.', [ $this->logger->warning('Profile metadata fetch failed; using npub placeholder.', [
@ -47,8 +79,50 @@ readonly class CacheService
$content = new \stdClass(); $content = new \stdClass();
$content->name = substr($npub, 0, 8) . '…' . substr($npub, -4); $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<list<string>>
*/
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;
} }
/** /**

37
src/Service/NostrClient.php

@ -527,6 +527,43 @@ class NostrClient
return $events[0]; return $events[0];
} }
/**
* NIP-A3 kind 10133: payment target events (replaceable) with `["payto", type, authority, ...]` tags.
*
* @return list<object>
*/
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 public function getNpubLongForm($npub): void
{ {
$subscription = new Subscription(); $subscription = new Subscription();

201
src/Service/ProfileIdentityLinksBuilder.php

@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
namespace App\Service;
/**
* Website and NIP-05 links from kind-0 JSON and kind-0 tags, normalized and deduplicated.
*/
final class ProfileIdentityLinksBuilder
{
/**
* @param list<list<string>> $kind0Tags
*
* @return list<array{label: string, href: string}>
*/
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<list<string>> $kind0Tags
*
* @return list<array{label: string, href: string}>
*/
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<string>
*/
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<list<string>> $tags
* @param list<string> $tagNames lowercased
* @return list<string>
*/
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;
}
}

419
src/Service/ProfilePaymentLinksBuilder.php

@ -0,0 +1,419 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Enum\KindsEnum;
/**
* Lightning (lud06 / lud16) and NIP-A3 payto: kind-0 JSON, kind-0 tags, and kind 10133 events.
*
* NIP-A3: `["payto", "<type>", "<authority>", ...]` → `payto://<type>/<authority>…` (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<list<string>> $kind0Tags
* @param list<string> $extraPaytoUris from kind 10133
*
* @return list<array{type: string, type_label: string, label: string, href: string, sort: int}>
*/
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<object> $kind10133Events
*
* @return list<string>
*/
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<string> $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<list<string>> $tags
*
* @return list<string>
*/
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<string>
*/
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;
}
}

40
templates/pages/author.html.twig

@ -17,12 +17,52 @@
{% endif %} {% endif %}
<h1 class="author-profile__title"><twig:Atoms:NameOrNpub :author="author" :npub="npub"></twig:Atoms:NameOrNpub></h1> <h1 class="author-profile__title"><twig:Atoms:NameOrNpub :author="author" :npub="npub"></twig:Atoms:NameOrNpub></h1>
<div class="author-profile__header-meta">
{% if profile_websites is not empty %}
<ul class="author-profile__identity" aria-label="Websites">
{% for row in profile_websites %}
<li class="author-profile__identity-row">
<span class="author-profile__identity-type">Website</span>
<a class="author-profile__identity-link" href="{{ row.href|e('html_attr') }}" rel="nofollow noopener">{{ row.label|e }}</a>
</li>
{% endfor %}
</ul>
{% endif %}
{% if profile_nip05 is not empty %}
<ul class="author-profile__identity" aria-label="NIP-05">
{% for row in profile_nip05 %}
<li class="author-profile__identity-row">
<span class="author-profile__identity-type">NIP-05</span>
<a class="author-profile__identity-link" href="{{ row.href|e('html_attr') }}" rel="nofollow noopener" title="NIP-05 verification document">{{ row.label|e }}</a>
</li>
{% endfor %}
</ul>
{% endif %}
{% if profile_payment_links is not empty %}
<ul class="author-profile__payments" aria-label="Payment options">
{% for row in profile_payment_links %}
<li class="author-profile__payment">
<span class="author-profile__payment-type">{{ row.type_label }}</span>
<a class="author-profile__payment-link" href="{{ row.href|e('html_attr') }}" rel="nofollow noopener">{{ row.label|e }}</a>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
<div class="author-profile__about"> <div class="author-profile__about">
{% if author.about is defined %} {% if author.about is defined %}
{{ author.about|markdown_to_html|mentionify|linkify }} {{ author.about|markdown_to_html|mentionify|linkify }}
{% endif %} {% endif %}
</div> </div>
{% if jumble_profile_href is not null and jumble_profile_href != '' %}
<p class="author-profile__jumble">
<a class="btn btn-secondary" href="{{ jumble_profile_href|e('html_attr') }}" rel="nofollow noopener">View on Jumble</a>
</p>
{% endif %}
<hr class="author-profile__divider" /> <hr class="author-profile__divider" />
<twig:Organisms:CardList :list="articles" class="article-list"></twig:Organisms:CardList> <twig:Organisms:CardList :list="articles" class="article-list"></twig:Organisms:CardList>

Loading…
Cancel
Save