From 7a8aed117cb4a66f3c1b7d67532ba772308f3ca6 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 21 Feb 2026 09:55:26 +0100 Subject: [PATCH] update profile page, dashboard, and connections Nostr-Signature: 862b888e52bf4fc3e53c80afd9f301b22ce674366f48d006bca520479394c0f9 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc c2e895f67ff5a68e87dcdc54a0312e169f4729a05a62f1ffbe92afd6e57b7d232b36ef4291c07969e531cdc4f22f5ac32723a2aecc57a0b613b945217ecc651a --- README.md | 11 +- docs/NIP-A3.md | 163 +++ docs/tutorial.md | 77 ++ nostr/commit-signatures.jsonl | 1 + src/app.html | 1 + src/lib/components/SettingsButton.svelte | 16 +- src/lib/components/SettingsModal.svelte | 382 +++--- src/lib/services/nostr/nostr-client.ts | 16 +- .../services/nostr/persistent-event-cache.ts | 49 +- src/routes/+layout.svelte | 88 +- .../api/users/[npub]/profile/+server.ts | 151 +++ src/routes/dashboard/+page.svelte | 199 +++- src/routes/docs/nip-a3/+page.server.ts | 20 + src/routes/docs/nip-a3/+page.svelte | 241 ++++ src/routes/repos/[npub]/[repo]/+page.svelte | 4 - src/routes/settings/[[tab]]/+page.svelte | 618 ++++++++++ src/routes/users/[npub]/+page.svelte | 1052 +++++++++++------ 17 files changed, 2543 insertions(+), 546 deletions(-) create mode 100644 docs/NIP-A3.md create mode 100644 src/routes/api/users/[npub]/profile/+server.ts create mode 100644 src/routes/docs/nip-a3/+page.server.ts create mode 100644 src/routes/docs/nip-a3/+page.svelte create mode 100644 src/routes/settings/[[tab]]/+page.svelte diff --git a/README.md b/README.md index dba11f2..3dc18a6 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,10 @@ All three interfaces use the same underlying Nostr-based authentication and repo - **Tag Management**: Create and view git tags - **README Rendering**: Automatic markdown rendering for README files - **Search**: Search repositories by name, description, or author -- **User Profiles**: View user repositories and activity +- **User Profiles**: View user repositories and activity with full profile event support + - Supports both old JSON format (in content) and new tags format + - Displays payment targets (NIP-A3 kind 10133) with payto:// URIs + - Merges and deduplicates lightning addresses from NIP-01 (lud16) and kind 10133 - **Raw File View**: Direct access to raw file content - **Download Repository**: Download repositories as ZIP archives - **OpenGraph Metadata**: Rich social media previews with repository images and banners @@ -265,6 +268,11 @@ This project uses the following Nostr event kinds. For complete JSON examples an - **10002**: Relay list metadata (NIP-65, for relay discovery) - **24**: Public message (NIP-24, for relay write proof) - **5**: Event deletion request (NIP-09) +- **10133**: Payment targets (NIP-A3, payto:// URI scheme) + - Replaceable event kind for payment and tip invocations + - Tags: `payto` (type, authority, optional extras) + - Supports lightning, bitcoin, nano, monero, ethereum, and other payment types + - See [docs/NIP-A3.md](./docs/NIP-A3.md) for complete documentation ### Custom Event Kinds @@ -519,6 +527,7 @@ src/ - [Architecture FAQ](./docs/ARCHITECTURE_FAQ.md) - Answers to common architecture questions - [NIP Compliance](./docs/NIP_COMPLIANCE.md) - Complete event kind reference with JSON examples +- [NIP-A3: Payment Targets](./docs/NIP-A3.md) - Payment targets (payto://) support and documentation - [Security Documentation](./docs/SECURITY.md) - Security features and considerations - [CLI Documentation](./gitrepublic-cli/README.md) - Complete CLI usage guide - [Enterprise Mode](./k8s/ENTERPRISE_MODE.md) - Kubernetes deployment guide diff --git a/docs/NIP-A3.md b/docs/NIP-A3.md new file mode 100644 index 0000000..9e3290e --- /dev/null +++ b/docs/NIP-A3.md @@ -0,0 +1,163 @@ +# NIP-A3: Payment Targets (payto://) + +GitRepublic supports NIP-A3 (Payment Targets) for displaying payment information in user profiles. This allows users to specify payment targets using the [RFC-8905 (payto:) URI scheme](https://www.rfc-editor.org/rfc/rfc8905.html). + +## Overview + +NIP-A3 defines `kind:10133` for payment target events. This kind is **replaceable**, meaning users can update their payment targets by publishing a new event with the same kind. + +## Event Structure + +### Kind 10133: Payment Targets + +```json +{ + "pubkey": "afc93622eb4d79c0fb75e56e0c14553f7214b0a466abeba14cb38968c6755e6a", + "kind": 10133, + "content": "", + "tags": [ + ["payto", "bitcoin", "bc1qxq66e0t8d7ugdecwnmv58e90tpry23nc84pg9k"], + ["payto", "lightning", "user@wallet.example.com"], + ["payto", "nano", "nano_1dctqbmqxfppo9pswbm6kg9d4s4mbraqn8i4m7ob9gnzz91aurmuho48jx3c"] + ], + "created_at": 1234567890, + "id": "...", + "sig": "..." +} +``` + +### Tag Format + +Payment targets are specified using `payto` tags with the following structure: + +```text +["payto", "", "", "", "", ...] +``` + +Where: +- The first element is always the literal string `"payto"` +- The second element is the payment `type` (e.g., `"bitcoin"`, `"lightning"`) +- The third element is the `authority` (e.g., address, username) +- Additional elements are optional and reserved for future RFC-8905 features + +## Supported Payment Types + +GitRepublic recognizes and displays the following payment target types: + +| Payment Target Type | Long Stylization | Short Stylization | Symbol | References | +| :------------------ | :---------------- | :---------------- | :----- | :--------- | +| bitcoin | Bitcoin | BTC | ₿ | https://bitcoin.design/ | +| cashme | Cash App | Cash App | $,£ | https://cash.app/press | +| ethereum | Ethereum | ETH | Ξ | https://ethereum.org/assets/#brand | +| lightning | Lightning Network | LBTC | 丰 | https://github.com/shocknet/bitcoin-lightning-logo | +| monero | Monero | XMR | ɱ | https://www.getmonero.org/press-kit/ | +| nano | Nano | XNO | Ӿ | https://nano.org/en/currency | +| revolut | Revolut | Revolut | N/A | https://revolut.me | +| venmo | Venmo | Venmo | $ | https://venmo.com/pay | + +Unrecognized types are still displayed but without special styling. + +## Integration with NIP-01 Profiles + +GitRepublic merges payment targets from multiple sources: + +1. **NIP-01 (kind 0)**: Lightning addresses from `lud16` tags or JSON `lud16` field +2. **NIP-A3 (kind 10133)**: All payment targets from `payto` tags + +The system: +- Normalizes all addresses to lowercase for deduplication +- Merges lightning addresses from both sources +- Displays all payment targets together in the profile +- Formats each target as a `payto:///` URI + +## Display Format + +Payment targets are displayed on user profile pages with: +- Payment type (e.g., "lightning", "bitcoin") +- Full `payto://` URI +- Copy button for easy sharing + +Example display: +``` +Payments +├─ lightning payto://lightning/user@wallet.example.com [Copy] +├─ bitcoin payto://bitcoin/bc1q... [Copy] +└─ nano payto://nano/nano_1... [Copy] +``` + +## API Access + +### GET `/api/users/{npub}/profile` + +Returns the full user profile including payment targets: + +```json +{ + "npub": "npub1...", + "pubkey": "afc93622...", + "profile": { + "name": "Alice", + "about": "Developer", + "picture": "https://...", + "websites": [], + "nip05": [] + }, + "profileEvent": { ... }, + "paymentTargets": [ + { + "type": "lightning", + "authority": "user@wallet.example.com", + "payto": "payto://lightning/user@wallet.example.com" + }, + { + "type": "bitcoin", + "authority": "bc1qxq66e0t8d7ugdecwnmv58e90tpry23nc84pg9k", + "payto": "payto://bitcoin/bc1qxq66e0t8d7ugdecwnmv58e90tpry23nc84pg9k" + } + ], + "paymentEvent": { ... } +} +``` + +## CLI Access + +The GitRepublic CLI also supports fetching payment targets: + +```bash +# Profile fetcher automatically includes payment targets +gitrep profile fetch npub1... +``` + +The CLI's `profile-fetcher.js` module fetches both kind 0 and kind 10133 events and merges the payment information. + +## Creating Payment Target Events + +To create a payment target event, publish a kind 10133 event with `payto` tags: + +```javascript +const event = { + kind: 10133, + content: "", + tags: [ + ["payto", "lightning", "user@wallet.example.com"], + ["payto", "bitcoin", "bc1qxq66e0t8d7ugdecwnmv58e90tpry23nc84pg9k"] + ], + created_at: Math.floor(Date.now() / 1000), + pubkey: yourPubkey +}; + +// Sign and publish to relays +``` + +## References + +- [NIP-A3 Specification](https://github.com/nostr-protocol/nips/pull/XXX) (when published) +- [RFC-8905: The "payto" URI Scheme](https://www.rfc-editor.org/rfc/rfc8905.html) +- [NIP-57: Lightning Zaps](https://github.com/nostr-protocol/nips/blob/master/57.md) - Related specification for lightning payments + +## Notes + +- Payment targets are **replaceable** - publish a new kind 10133 event to update +- GitRepublic checks cache first, then relays for profile and payment events +- Lightning addresses from NIP-01 (lud16) are automatically merged with kind 10133 +- All addresses are normalized (lowercase) and deduplicated before display diff --git a/docs/tutorial.md b/docs/tutorial.md index 152a4a5..6e1da43 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -728,6 +728,83 @@ git commit -m "Add user authentication with NIP-07 support --- +## User Profiles and Payment Targets + +GitRepublic displays full user profiles with support for payment targets using NIP-A3 (kind 10133). + +### Viewing User Profiles + +1. Navigate to a user's profile page: `/users/{npub}` +2. View their repositories, profile information, and payment targets +3. Profiles support both old JSON format (in content) and new tags format + +### Payment Targets (NIP-A3) + +Payment targets allow users to specify how they want to receive payments using the `payto://` URI scheme (RFC-8905). + +#### Supported Payment Types + +- **Lightning**: Lightning Network addresses (e.g., `user@wallet.example.com`) +- **Bitcoin**: Bitcoin addresses +- **Ethereum**: Ethereum addresses +- **Nano**: Nano addresses +- **Monero**: Monero addresses +- And more (see [NIP-A3 documentation](./NIP-A3.md)) + +#### How Payment Targets Work + +GitRepublic merges payment information from multiple sources: + +1. **NIP-01 (kind 0)**: Lightning addresses from `lud16` tags or JSON `lud16` field +2. **NIP-A3 (kind 10133)**: All payment targets from `payto` tags + +The system: +- Normalizes addresses (lowercase) for deduplication +- Merges lightning addresses from both sources +- Displays all payment targets with `payto://` URIs +- Provides copy buttons for easy sharing + +#### Creating Payment Target Events + +To add payment targets to your profile, publish a kind 10133 event: + +```json +{ + "kind": 10133, + "content": "", + "tags": [ + ["payto", "lightning", "user@wallet.example.com"], + ["payto", "bitcoin", "bc1qxq66e0t8d7ugdecwnmv58e90tpry23nc84pg9k"] + ], + "created_at": 1234567890 +} +``` + +#### API Access + +Fetch user profiles with payment targets via the API: + +```bash +GET /api/users/{npub}/profile +``` + +Response includes: +- Full profile event (kind 0) +- Payment targets array with `payto://` URIs +- Payment event (kind 10133) if available + +#### CLI Access + +The GitRepublic CLI automatically fetches payment targets when fetching profiles: + +```bash +gitrep profile fetch npub1... +``` + +See [NIP-A3 documentation](./NIP-A3.md) for complete details. + +--- + ## Advanced Topics ### NIP-34 Specification diff --git a/nostr/commit-signatures.jsonl b/nostr/commit-signatures.jsonl index 0bd2115..1ace412 100644 --- a/nostr/commit-signatures.jsonl +++ b/nostr/commit-signatures.jsonl @@ -34,3 +34,4 @@ {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771624450,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes and doc updates"]],"content":"Signed commit: bug-fixes and doc updates","id":"d089915a2d9a9d46ba25d2d3c1cb4608a2b658ecc4260f17e73efa4ccc63a28d","sig":"3d447f05a55704d45ed843b7cc5fa16e49f3da0e452b1523392aefbb7a2ae3e79400a763df5705db8e38abc89e9a89480ab2c529890b531b171c4e980520d9b8"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771625218,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fix"]],"content":"Signed commit: bug-fix","id":"1cc16c438c4b1cc5170a90a7e4b540afa24d0c698538dc332fa4753437b21dfe","sig":"3caddc0d00e29995f4920bd4035ea61b4fd2d17e366bdd18889ede38a5ea960cd9f83a9f524b777b8de7bf7e4cdf59ab55c8fb4e46932655985ba8c6f3d7e7da"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771626015,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"f5bde3d9199d8cbacca481959663f1e14c43e143ef2b5686502559408e1c526b","sig":"3ed47cd283746d290d8609cbfdefbcee31a19d8e43e1a6ebf5a2829904000d79b83d3235296af4b5f7b555051214fbf2fa5c7a6d7986dca853112bb4e122a6d5"} +{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771627873,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"5726811907af73d3b478f3938cdc6421200040542cb1a586b3497c56a24c33cb","sig":"3833d05ba5a34cad78caacbc8382fcd7a85c60b56dd3b18f9a5c68c890d7a611fa6b885ef02be465f541629b0afaeec0e9d57d3b00db332c5c8ae42fd72fc83d"} diff --git a/src/app.html b/src/app.html index ff2c747..06f427a 100644 --- a/src/app.html +++ b/src/app.html @@ -7,6 +7,7 @@ - showSettings = false} /> - diff --git a/src/routes/docs/nip-a3/+page.server.ts b/src/routes/docs/nip-a3/+page.server.ts new file mode 100644 index 0000000..cb0070b --- /dev/null +++ b/src/routes/docs/nip-a3/+page.server.ts @@ -0,0 +1,20 @@ +/** + * Server-side loader for NIP-A3 documentation + */ + +import { readFile } from 'fs/promises'; +import { join } from 'path'; +import type { PageServerLoad } from './$types'; +import logger from '$lib/services/logger.js'; + +export const load: PageServerLoad = async () => { + try { + // Read NIP-A3 documentation from docs/NIP-A3.md + const filePath = join(process.cwd(), 'docs', 'NIP-A3.md'); + const content = await readFile(filePath, 'utf-8'); + return { content }; + } catch (error) { + logger.error({ error }, 'Error loading NIP-A3 documentation'); + return { content: null, error: 'Failed to load NIP-A3 documentation' }; + } +}; diff --git a/src/routes/docs/nip-a3/+page.svelte b/src/routes/docs/nip-a3/+page.svelte new file mode 100644 index 0000000..8b51673 --- /dev/null +++ b/src/routes/docs/nip-a3/+page.svelte @@ -0,0 +1,241 @@ + + +
+
+

NIP-A3: Payment Targets

+

Payment targets (payto://) support and documentation

+
+ +
+ {#if loading} +
Loading documentation...
+ {:else if error} +
{error}
+ {:else} +
+ {@html content} +
+ {/if} +
+
+ + diff --git a/src/routes/repos/[npub]/[repo]/+page.svelte b/src/routes/repos/[npub]/[repo]/+page.svelte index 7dd2bc1..5e5e821 100644 --- a/src/routes/repos/[npub]/[repo]/+page.svelte +++ b/src/routes/repos/[npub]/[repo]/+page.svelte @@ -5,7 +5,6 @@ import CodeEditor from '$lib/components/CodeEditor.svelte'; import PRDetail from '$lib/components/PRDetail.svelte'; import UserBadge from '$lib/components/UserBadge.svelte'; - import ForwardingConfig from '$lib/components/ForwardingConfig.svelte'; import EventCopyButton from '$lib/components/EventCopyButton.svelte'; import { getPublicKeyWithNIP07, isNIP07Available, signEventWithNIP07 } from '$lib/services/nostr/nip07-signer.js'; import { NostrClient } from '$lib/services/nostr/nostr-client.js'; @@ -3528,9 +3527,6 @@ {/if} {/if} - {#if pageData.repoOwnerPubkey && userPubkey === pageData.repoOwnerPubkey} - - {/if}
diff --git a/src/routes/settings/[[tab]]/+page.svelte b/src/routes/settings/[[tab]]/+page.svelte new file mode 100644 index 0000000..431f4cb --- /dev/null +++ b/src/routes/settings/[[tab]]/+page.svelte @@ -0,0 +1,618 @@ + + +
+
+

Settings

+ +
+ + {#if loading && !settingsLoaded} +
Loading settings...
+ {:else} + +
+ + + +
+ +
+ + {#if activeTab === 'general'} +
+
+ Theme +
+
+ + + +
+
+ {/if} + + + {#if activeTab === 'git-setup'} + +
+ +

+ When enabled, changes are automatically committed every 10 minutes if there are unsaved changes. +

+
+ + +
+ + + {#if presetUserName} +

+ {#if userName.trim()} + Custom value saved. Default would be: {presetUserName} + {:else} + Will use: {presetUserName} (from your Nostr profile: display_name → name → shortened npub) + {/if} +

+ {/if} +

+ Your name as it will appear in git commits. Leave empty to use the preset value from your Nostr profile. +

+
+ + +
+ + + {#if presetUserEmail} +

+ {#if userEmail.trim()} + Custom value saved. Default would be: {presetUserEmail} + {:else} + Will use: {presetUserEmail} (from your Nostr profile: NIP-05 → shortenednpub@gitrepublic.web) + {/if} +

+ {/if} +

+ Your email as it will appear in git commits. Leave empty to use the preset value from your Nostr profile. +

+
+ + +
+ + +

+ Default branch name to use when creating new repositories. This will be used as the base branch when creating the first branch in a new repo. +

+
+ {/if} + + + {#if activeTab === 'connections'} +
+ +
+ {/if} +
+ +
+ +
+ {/if} +
+ + diff --git a/src/routes/users/[npub]/+page.svelte b/src/routes/users/[npub]/+page.svelte index c8fd44f..fb771bb 100644 --- a/src/routes/users/[npub]/+page.svelte +++ b/src/routes/users/[npub]/+page.svelte @@ -3,57 +3,32 @@ import { page } from '$app/stores'; import { goto } from '$app/navigation'; import { NostrClient } from '$lib/services/nostr/nostr-client.js'; - import { DEFAULT_NOSTR_RELAYS, combineRelays } from '$lib/config.js'; - import { KIND } from '$lib/types/nostr.js'; + import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; import { nip19 } from 'nostr-tools'; import type { NostrEvent } from '$lib/types/nostr.js'; import { getPublicKeyWithNIP07, isNIP07Available, signEventWithNIP07 } from '$lib/services/nostr/nip07-signer.js'; - import ForwardingConfig from '$lib/components/ForwardingConfig.svelte'; import { PublicMessagesService, type PublicMessage } from '$lib/services/nostr/public-messages-service.js'; import { getUserRelays } from '$lib/services/nostr/user-relays.js'; import UserBadge from '$lib/components/UserBadge.svelte'; - // forwardEventIfEnabled is server-side only - import dynamically if needed import { userStore } from '$lib/stores/user-store.js'; + import { fetchUserProfile, extractProfileData } from '$lib/utils/user-profile.js'; + import { combineRelays } from '$lib/config.js'; const npub = ($page.params as { npub?: string }).npub || ''; + // State let loading = $state(true); let error = $state(null); - let userPubkey = $state(null); + let profileOwnerPubkeyHex = $state(null); let viewerPubkeyHex = $state(null); - let lastViewerPubkeyHex = $state(null); // Track last viewer pubkey to detect changes - let isReloading = $state(false); // Guard to prevent concurrent reloads - - // Sync with userStore - only reload if viewer pubkey actually changed - $effect(() => { - const currentUser = $userStore; - const newViewerPubkeyHex = currentUser.userPubkeyHex; - - // Only update if viewer pubkey actually changed (not just any store change) - if (newViewerPubkeyHex !== lastViewerPubkeyHex) { - const wasLoggedIn = viewerPubkeyHex !== null; - const isNowLoggedIn = newViewerPubkeyHex !== null; - - // Update viewer pubkey - viewerPubkeyHex = newViewerPubkeyHex; - lastViewerPubkeyHex = newViewerPubkeyHex; - - // Only reload if login state actually changed (logged in -> logged out or vice versa) - // AND we're not already loading/reloading - if ((wasLoggedIn !== isNowLoggedIn) && !loading && !isReloading) { - isReloading = true; - loadUserProfile() - .catch(err => console.warn('Failed to reload user profile after login state change:', err)) - .finally(() => { - isReloading = false; - }); - } - } - }); let repos = $state([]); let userProfile = $state<{ name?: string; about?: string; picture?: string } | null>(null); + let profileEvent = $state(null); + let profileData = $state(null); + let profileTags = $state>([]); + let paymentTargets = $state>([]); - // Messages tab + // Messages let activeTab = $state<'repos' | 'messages'>('repos'); let messages = $state([]); let loadingMessages = $state(false); @@ -65,78 +40,40 @@ const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); const gitDomain = $page.data.gitDomain || 'localhost:6543'; + // Sync viewer pubkey from store + $effect(() => { + const currentUser = $userStore; + viewerPubkeyHex = currentUser.userPubkeyHex || null; + }); + onMount(async () => { - await loadViewerPubkey(); await loadUserProfile(); }); - // Load messages when messages tab is active + // Load messages when tab is active $effect(() => { - if (activeTab === 'messages' && userPubkey && messages.length === 0) { + if (activeTab === 'messages' && profileOwnerPubkeyHex && messages.length === 0 && !loadingMessages) { loadMessages(); } }); - async function loadViewerPubkey() { - // Check userStore first - const currentUser = $userStore; - if (currentUser.userPubkey && currentUser.userPubkeyHex) { - userPubkey = currentUser.userPubkey; - viewerPubkeyHex = currentUser.userPubkeyHex; - return; - } - - // Fallback: try NIP-07 if store doesn't have it - if (!isNIP07Available()) { - return; - } - - try { - const viewerPubkey = await getPublicKeyWithNIP07(); - userPubkey = viewerPubkey; - // Convert npub to hex for API calls - try { - const decoded = nip19.decode(viewerPubkey); - if (decoded.type === 'npub') { - viewerPubkeyHex = decoded.data as string; - } - } catch { - viewerPubkeyHex = viewerPubkey; // Assume it's already hex - } - } catch (err) { - console.warn('Failed to load viewer pubkey:', err); - } - } - async function loadUserProfile() { - // Prevent concurrent loads - if (loading && !isReloading) { - return; - } - loading = true; error = null; try { - // Decode npub to get pubkey (this is the profile owner, not the viewer) + // Decode npub const decoded = nip19.decode(npub); if (decoded.type !== 'npub') { error = 'Invalid npub format'; return; } - const profileOwnerPubkey = decoded.data as string; - - // Only update userPubkey if it's different (avoid triggering effects) - if (userPubkey !== profileOwnerPubkey) { - userPubkey = profileOwnerPubkey; - } + profileOwnerPubkeyHex = decoded.data as string; - // Fetch user's repositories via API (with privacy filtering) + // Load repositories const url = `/api/users/${npub}/repos?domain=${encodeURIComponent(gitDomain)}`; const response = await fetch(url, { - headers: viewerPubkeyHex ? { - 'X-User-Pubkey': viewerPubkeyHex - } : {} + headers: viewerPubkeyHex ? { 'X-User-Pubkey': viewerPubkeyHex } : {} }); if (!response.ok) { @@ -146,27 +83,84 @@ const data = await response.json(); repos = data.repos || []; - // Try to fetch user profile (kind 0) - const profileEvents = await nostrClient.fetchEvents([ - { - kinds: [0], - authors: [userPubkey], - limit: 1 - } - ]); - - if (profileEvents.length > 0) { + // Load profile + profileEvent = await fetchUserProfile(profileOwnerPubkeyHex, DEFAULT_NOSTR_RELAYS); + + if (profileEvent) { + // Parse JSON content try { - const profile = JSON.parse(profileEvents[0].content); - userProfile = { - name: profile.name, - about: profile.about, - picture: profile.picture - }; + if (profileEvent.content?.trim()) { + profileData = JSON.parse(profileEvent.content); + } } catch { - // Invalid JSON, ignore + profileData = null; + } + + // Extract tags + profileTags = profileEvent.tags + .filter(t => t.length > 0 && t[0]) + .map(t => ({ name: t[0], value: t.slice(1).join(', ') })); + + // Extract profile fields + const nameTag = profileEvent.tags.find(t => t[0] === 'name' || t[0] === 'display_name')?.[1]; + const aboutTag = profileEvent.tags.find(t => t[0] === 'about')?.[1]; + const pictureTag = profileEvent.tags.find(t => t[0] === 'picture' || t[0] === 'avatar')?.[1]; + + userProfile = { + name: nameTag || profileData?.display_name || profileData?.name, + about: aboutTag || profileData?.about, + picture: pictureTag || profileData?.picture + }; + } + + // Load payment targets (kind 10133) + const paymentEvents = await nostrClient.fetchEvents([{ + kinds: [10133], + authors: [profileOwnerPubkeyHex], + limit: 1 + }]); + + const lightningAddresses = new Set(); + + // Extract from profile event + if (profileEvent) { + const lud16Tags = profileEvent.tags.filter(t => t[0] === 'lud16').map(t => t[1]).filter(Boolean); + lud16Tags.forEach(addr => lightningAddresses.add(addr.toLowerCase())); + if (profileData?.lud16) { + lightningAddresses.add(profileData.lud16.toLowerCase()); } } + + // Extract from kind 10133 + if (paymentEvents.length > 0) { + const paytoTags = paymentEvents[0].tags.filter(t => t[0] === 'payto' && t[1] === 'lightning' && t[2]); + paytoTags.forEach(tag => { + if (tag[2]) lightningAddresses.add(tag[2].toLowerCase()); + }); + } + + // Build payment targets + const targets: Array<{ type: string; authority: string; payto: string }> = + Array.from(lightningAddresses).map(authority => ({ + type: 'lightning', + authority, + payto: `payto://lightning/${authority}` + })); + + if (paymentEvents.length > 0) { + const otherPaytoTags = paymentEvents[0].tags.filter(t => + t[0] === 'payto' && t[1] && t[1] !== 'lightning' && t[2] + ); + otherPaytoTags.forEach(tag => { + const type = tag[1]?.toLowerCase() || ''; + const authority = tag[2] || ''; + if (type && authority && !targets.find(p => p.type === type && p.authority.toLowerCase() === authority.toLowerCase())) { + targets.push({ type, authority, payto: `payto://${type}/${authority}` }); + } + }); + } + + paymentTargets = targets; } catch (err) { error = err instanceof Error ? err.message : 'Failed to load user profile'; console.error('Error loading user profile:', err); @@ -175,40 +169,24 @@ } } - function getRepoName(event: NostrEvent): string { - return event.tags.find(t => t[0] === 'name')?.[1] || - event.tags.find(t => t[0] === 'd')?.[1] || - 'Unnamed'; - } - - function getRepoDescription(event: NostrEvent): string { - return event.tags.find(t => t[0] === 'description')?.[1] || ''; - } - - function getRepoId(event: NostrEvent): string { - return event.tags.find(t => t[0] === 'd')?.[1] || ''; - } - - function getForwardingPubkey(): string | null { - if (userPubkey && viewerPubkeyHex && viewerPubkeyHex === userPubkey) { - return userPubkey; - } - return null; - } - async function loadMessages() { - if (!userPubkey) return; + if (!profileOwnerPubkeyHex || loadingMessages) return; loadingMessages = true; - error = null; - try { if (!messagesService) { messagesService = new PublicMessagesService(DEFAULT_NOSTR_RELAYS); } - messages = await messagesService.getAllMessagesForUser(userPubkey, 100); + const allMessages = await messagesService.getAllMessagesForUser(profileOwnerPubkeyHex, 100); + // Filter out gitrepublic-write-proof kind 24 events + messages = allMessages.filter(msg => { + // Skip kind 24 events that contain "gitrepublic-write-proof" in content + if (msg.kind === 24 && msg.content && msg.content.includes('gitrepublic-write-proof')) { + return false; + } + return true; + }); } catch (err) { - error = err instanceof Error ? err.message : 'Failed to load messages'; console.error('Error loading messages:', err); } finally { loadingMessages = false; @@ -216,66 +194,62 @@ } async function sendMessage() { - if (!newMessageContent.trim() || !viewerPubkeyHex || !userPubkey) { + if (!newMessageContent.trim() || !viewerPubkeyHex || !profileOwnerPubkeyHex) { alert('Please enter a message and make sure you are logged in'); return; } - if (viewerPubkeyHex === userPubkey) { + if (viewerPubkeyHex === profileOwnerPubkeyHex) { alert('You cannot send a message to yourself'); return; } sendingMessage = true; - error = null; - try { if (!messagesService) { messagesService = new PublicMessagesService(DEFAULT_NOSTR_RELAYS); } - // Create the message event const messageEvent = await messagesService.sendPublicMessage( viewerPubkeyHex, newMessageContent.trim(), - [{ pubkey: userPubkey }] + [{ pubkey: profileOwnerPubkeyHex }] ); - // Get user's relays for publishing const { outbox } = await getUserRelays(viewerPubkeyHex, nostrClient); const combinedRelays = combineRelays(outbox); - - // Sign the event const signedEvent = await signEventWithNIP07(messageEvent); - - // Publish to relays const result = await nostrClient.publishEvent(signedEvent, combinedRelays); if (result.failed.length > 0 && result.success.length === 0) { throw new Error('Failed to publish message to all relays'); } - // Forward to messaging platforms if user has unlimited access and preferences configured - // This is done server-side via API endpoints, not from client - // The server-side API endpoints (issues, prs, highlights) handle forwarding automatically - - // Reload messages await loadMessages(); - - // Close dialog and clear content showSendMessageDialog = false; newMessageContent = ''; - alert('Message sent successfully!'); } catch (err) { - error = err instanceof Error ? err.message : 'Failed to send message'; - console.error('Error sending message:', err); - alert(error); + alert(err instanceof Error ? err.message : 'Failed to send message'); } finally { sendingMessage = false; } } + function getRepoName(event: NostrEvent): string { + return event.tags.find(t => t[0] === 'name')?.[1] || + event.tags.find(t => t[0] === 'd')?.[1] || + 'Unnamed'; + } + + function getRepoDescription(event: NostrEvent): string { + return event.tags.find(t => t[0] === 'description')?.[1] || ''; + } + + function getRepoId(event: NostrEvent): string { + return event.tags.find(t => t[0] === 'd')?.[1] || ''; + } + function getMessageRecipients(message: PublicMessage): string[] { return message.tags .filter(tag => tag[0] === 'p' && tag[1]) @@ -296,89 +270,136 @@ if (diffDays < 7) return `${diffDays}d ago`; return date.toLocaleDateString(); } + + async function copyPaytoAddress(payto: string) { + try { + await navigator.clipboard.writeText(payto); + alert('Payment address copied to clipboard!'); + } catch (err) { + console.error('Failed to copy:', err); + } + } + + const isOwnProfile = $derived(viewerPubkeyHex === profileOwnerPubkeyHex); -
-
-
- {#if userProfile?.picture} - Profile - {:else} -
- {npub.slice(0, 2).toUpperCase()} -
- {/if} +
+ {#if loading} +
+
+

Loading profile...

+
+ {:else if error} +
+

Error

+

{error}

+
+ {:else} + +
+
+ {#if userProfile?.picture} + Profile + {:else} +
+ {npub.slice(0, 2).toUpperCase()} +
+ {/if} +
+
-

{userProfile?.name || npub.slice(0, 16)}...

+

{userProfile?.name || npub.slice(0, 16) + '...'}

{#if userProfile?.about} -

{userProfile.about}

+

{userProfile.about}

{/if} -

npub: {npub}

-
-
- {#if getForwardingPubkey()} - - - {/if} -
-
- {#if error} -
Error: {error}
+ {#if isOwnProfile} + + {/if} + + + + {#if paymentTargets.length > 0} +
+

Payment Methods

+
+ {#each paymentTargets as target} +
+
+ {target.type} +
+ {target.payto} + +
+ {/each} +
+
{/if} - {#if loading} -
Loading profile...
- {:else} - + +
+
- + +
{#if activeTab === 'repos'} -
-

Repositories ({repos.length})

+
{#if repos.length === 0} -
No repositories found
+
+

No repositories found

+
{:else}
{#each repos as event} + {@const repoId = getRepoId(event)}
goto(`/repos/${npub}/${getRepoId(event)}`)} + onclick={() => goto(`/repos/${npub}/${repoId}`)} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); - goto(`/repos/${npub}/${getRepoId(event)}`); + goto(`/repos/${npub}/${repoId}`); } }} - style="cursor: pointer;"> -

{getRepoName(event)}

+ > +

{getRepoName(event)}

{#if getRepoDescription(event)}

{getRepoDescription(event)}

{/if} -
+ {/if} -
- {/if} - - - {#if activeTab === 'messages'} -
+
+ {:else if activeTab === 'messages'} +

Public Messages

- {#if viewerPubkeyHex && viewerPubkeyHex !== userPubkey} - {/if}
{#if loadingMessages} -
Loading messages...
+
+
+

Loading messages...

+
{:else if messages.length === 0} -
No messages found
+
+

No messages found

+
{:else}
{#each messages as message} {@const isFromViewer = viewerPubkeyHex !== null && message.pubkey === viewerPubkeyHex} {@const isToViewer = viewerPubkeyHex !== null && getMessageRecipients(message).includes(viewerPubkeyHex)} - {@const isFromUser = userPubkey !== null && message.pubkey === userPubkey} - {@const isToUser = userPubkey !== null && getMessageRecipients(message).includes(userPubkey)} -
+
{formatMessageTime(message.created_at)}
-
- {#if getMessageRecipients(message).length > 0} + {#if getMessageRecipients(message).length > 0} +
To: {#each getMessageRecipients(message) as recipientPubkey} {/each} - {/if} -
-
{message.content}
+
+ {/if} +
{message.content}
{/each}
{/if} -
+
{/if} - {/if} -
- - - {#if showSendMessageDialog} - - - +
{/if}
+ +{#if showSendMessageDialog} + +{/if} +