Browse Source

user badge is a universal hyperlink to the profile page

Nostr-Signature: 973a406714e586037d81cca323024ff5e2cc1fbaeda8846f6f2994c3829c4fe0 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc e7a58526a3786fc1b9ab1f957c87c13a42d3c2cc95effcf4ce4f4710e01ecc45fcff3ca542c5fa223961d7b99fe336a2851c133aebe3bfc1a591ffe1c34b221a
main
Silberengel 3 weeks ago
parent
commit
d864bf8a4c
  1. 1
      nostr/commit-signatures.jsonl
  2. 36
      src/lib/components/NavBar.svelte
  3. 55
      src/lib/components/UserBadge.svelte
  4. 85
      src/lib/types/nostr.ts
  5. 6
      src/routes/repos/[npub]/[repo]/+page.svelte
  6. 4
      src/routes/search/+page.svelte
  7. 125
      src/routes/users/[npub]/+page.svelte

1
nostr/commit-signatures.jsonl

@ -37,3 +37,4 @@ @@ -37,3 +37,4 @@
{"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"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771664126,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","update profile page, dashboard, and connections"]],"content":"Signed commit: update profile page, dashboard, and connections","id":"862b888e52bf4fc3e53c80afd9f301b22ce674366f48d006bca520479394c0f9","sig":"c2e895f67ff5a68e87dcdc54a0312e169f4729a05a62f1ffbe92afd6e57b7d232b36ef4291c07969e531cdc4f22f5ac32723a2aecc57a0b613b945217ecc651a"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771664339,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","added lightning address copy button"]],"content":"Signed commit: added lightning address copy button","id":"f0973d13a903f64895d265643390fe54bd86fe492a53c3ffea303dad8cf8a2f6","sig":"8c98969c5755bf8742733e05ca4be53f4f3ba276a2445ee7b903e443947fc53808b046c188dd91f26b6dcaecbe93585e1f2539855c8eba57e17a915e81bfa2d4"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771668002,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","finish profile page"]],"content":"Signed commit: finish profile page","id":"8a5aed2f8ac370f781dca9db96ade991c18b7cc3b0d27149d9e2741e8276f16f","sig":"16e9a9242f7c22dab8e37fd9d618419b4d51d7c0156f52c1289e275d2528312f4006696473c6836b5a661425fe0412fe54127291fb9b0d14777f93c8228cffb0"}

36
src/lib/components/NavBar.svelte

@ -229,31 +229,7 @@ @@ -229,31 +229,7 @@
<div class="auth-section">
<SettingsButton />
{#if userPubkey}
{@const userNpub = (() => {
try {
// Check if it's already an npub
if (userPubkey.startsWith('npub')) {
return userPubkey;
}
// Try to decode first (might already be npub)
try {
const decoded = nip19.decode(userPubkey);
if (decoded.type === 'npub') {
return userPubkey;
}
} catch {
// Not an npub, continue to encode
}
// Convert hex pubkey to npub
return nip19.npubEncode(userPubkey);
} catch {
// If all fails, return as-is (will be handled by route)
return userPubkey;
}
})()}
<a href={`/users/${userNpub}`} class="user-badge-link">
<UserBadge pubkey={userPubkey} />
</a>
<UserBadge pubkey={userPubkey} />
<button onclick={logout} class="logout-button">Logout</button>
{:else}
<button onclick={login} class="login-button" disabled={!isNIP07Available()}>
@ -367,16 +343,6 @@ @@ -367,16 +343,6 @@
flex-shrink: 0;
}
.user-badge-link {
text-decoration: none;
color: inherit;
display: flex;
align-items: center;
}
.user-badge-link:hover {
text-decoration: none;
}
.mobile-menu-toggle {
display: none;

55
src/lib/components/UserBadge.svelte

@ -8,9 +8,32 @@ @@ -8,9 +8,32 @@
interface Props {
pubkey: string;
disableLink?: boolean;
}
let { pubkey }: Props = $props();
let { pubkey, disableLink = false }: Props = $props();
// Convert pubkey to npub for navigation
function getNpub(): string {
try {
// Check if already npub format
try {
const decoded = nip19.decode(pubkey);
if (decoded.type === 'npub') {
return pubkey;
}
} catch {
// Not an npub, continue to encode
}
// Convert hex pubkey to npub
return nip19.npubEncode(pubkey);
} catch {
// If all fails, return as-is (will be handled by route)
return pubkey;
}
}
const profileUrl = `/users/${getNpub()}`;
let userProfile = $state<{ name?: string; picture?: string } | null>(null);
let loading = $state(true);
@ -161,14 +184,25 @@ @@ -161,14 +184,25 @@
}
</script>
<div class="user-badge">
{#if userProfile?.picture}
<img src={userProfile.picture} alt="Profile" class="user-badge-avatar" />
{:else}
<img src="/favicon.png" alt="Profile" class="user-badge-avatar user-badge-avatar-fallback" />
{/if}
<span class="user-badge-name">{truncateHandle(userProfile?.name)}</span>
</div>
{#if disableLink}
<div class="user-badge">
{#if userProfile?.picture}
<img src={userProfile.picture} alt="Profile" class="user-badge-avatar" />
{:else}
<img src="/favicon.png" alt="Profile" class="user-badge-avatar user-badge-avatar-fallback" />
{/if}
<span class="user-badge-name">{truncateHandle(userProfile?.name)}</span>
</div>
{:else}
<a href={profileUrl} class="user-badge">
{#if userProfile?.picture}
<img src={userProfile.picture} alt="Profile" class="user-badge-avatar" />
{:else}
<img src="/favicon.png" alt="Profile" class="user-badge-avatar user-badge-avatar-fallback" />
{/if}
<span class="user-badge-name">{truncateHandle(userProfile?.name)}</span>
</a>
{/if}
<style>
.user-badge {
@ -180,6 +214,9 @@ @@ -180,6 +214,9 @@
background: var(--card-bg);
border: 1px solid var(--border-color);
transition: all 0.2s ease;
text-decoration: none;
color: inherit;
cursor: pointer;
}
.user-badge:hover {

85
src/lib/types/nostr.ts

@ -56,8 +56,93 @@ export const KIND = { @@ -56,8 +56,93 @@ export const KIND = {
NIP98_AUTH: 27235, // NIP-98: HTTP authentication event
HIGHLIGHT: 9802, // NIP-84: Highlight event
PUBLIC_MESSAGE: 24, // NIP-24: Public message (direct chat)
PROFILE_METADATA: 0, // NIP-01: User metadata
REPOST: 6, // NIP-18: Repost
} as const;
/**
* Kind range definitions per NIP-01
* https://github.com/nostr-protocol/nips/blob/master/01.md
*/
export const KIND_RANGES = {
/**
* Regular events: 1000 <= n < 10000 || 4 <= n < 45 || n == 1 || n == 2
* All expected to be stored by relays
* Note: Special cases and ranges are handled in isRegularKind()
*/
REGULAR: {
MIN: 1,
MAX: 9999,
// Special cases: n == 1 || n == 2
// Ranges: 4 <= n < 45, 1000 <= n < 10000
// (handled separately in isRegularKind())
},
/**
* Replaceable events: 10000 <= n < 20000 || n == 0 || n == 3
* Only the latest event for each (pubkey, kind) combination is stored
* Note: Special cases n == 0 and n == 3 are handled separately in isReplaceableKind()
*/
REPLACEABLE: {
MIN: 10000,
MAX: 19999,
// Special cases: n == 0 || n == 3 (handled separately in isReplaceableKind())
// Continuous range: 10000 <= n < 20000
},
/**
* Ephemeral events: 20000 <= n < 30000
* Not expected to be stored by relays
*/
EPHEMERAL: {
MIN: 20000,
MAX: 29999,
},
/**
* Addressable events: 30000 <= n < 40000
* Addressable by (kind, pubkey, d-tag), only latest stored
*/
ADDRESSABLE: {
MIN: 30000,
MAX: 39999,
},
} as const;
/**
* Check if a kind is in the regular range per NIP-01
*/
export function isRegularKind(kind: number): boolean {
return (
kind === 1 ||
kind === 2 ||
(kind >= 4 && kind < 45) ||
(kind >= 1000 && kind < 10000)
);
}
/**
* Check if a kind is replaceable per NIP-01
*/
export function isReplaceableKind(kind: number): boolean {
return (
kind === 0 ||
kind === 3 ||
(kind >= 10000 && kind < 20000)
);
}
/**
* Check if a kind is ephemeral per NIP-01
*/
export function isEphemeralKind(kind: number): boolean {
return kind >= KIND_RANGES.EPHEMERAL.MIN && kind < KIND_RANGES.EPHEMERAL.MAX;
}
/**
* Check if a kind is addressable per NIP-01
*/
export function isAddressableKind(kind: number): boolean {
return kind >= KIND_RANGES.ADDRESSABLE.MIN && kind < KIND_RANGES.ADDRESSABLE.MAX;
}
export interface Issue extends NostrEvent {
kind: typeof KIND.ISSUE;
}

6
src/routes/repos/[npub]/[repo]/+page.svelte

@ -3435,7 +3435,7 @@ @@ -3435,7 +3435,7 @@
class="contributor-item"
class:contributor-owner={maintainer.isOwner}
>
<UserBadge pubkey={maintainer.pubkey} />
<UserBadge pubkey={maintainer.pubkey} disableLink={true} />
{#if maintainer.isOwner}
<span class="contributor-badge owner">Owner</span>
{:else}
@ -3446,13 +3446,13 @@ @@ -3446,13 +3446,13 @@
{:else if pageData.repoOwnerPubkey}
<!-- Fallback to pageData if maintainers not loaded yet -->
<a href={`/users/${npub}`} class="contributor-item contributor-owner">
<UserBadge pubkey={pageData.repoOwnerPubkey} />
<UserBadge pubkey={pageData.repoOwnerPubkey} disableLink={true} />
<span class="contributor-badge owner">Owner</span>
</a>
{#if pageData.repoMaintainers}
{#each pageData.repoMaintainers.filter(m => m !== pageData.repoOwnerPubkey) as maintainerPubkey}
<a href={`/users/${nip19.npubEncode(maintainerPubkey)}`} class="contributor-item">
<UserBadge pubkey={maintainerPubkey} />
<UserBadge pubkey={maintainerPubkey} disableLink={true} />
<span class="contributor-badge maintainer">Maintainer</span>
</a>
{/each}

4
src/routes/search/+page.svelte

@ -243,7 +243,7 @@ @@ -243,7 +243,7 @@
class:contributor-owner={maintainer.isOwner}
onclick={(e) => e.stopPropagation()}
>
<UserBadge pubkey={maintainer.pubkey} />
<UserBadge pubkey={maintainer.pubkey} disableLink={true} />
{#if maintainer.isOwner}
<span class="contributor-badge owner">Owner</span>
{:else}
@ -256,7 +256,7 @@ @@ -256,7 +256,7 @@
{:else}
<!-- Fallback: show owner if maintainers not available -->
<a href={`/users/${repo.npub}`} onclick={(e) => e.stopPropagation()}>
<UserBadge pubkey={repo.owner} />
<UserBadge pubkey={repo.owner} disableLink={true} />
</a>
{/if}
</div>

125
src/routes/users/[npub]/+page.svelte

@ -13,7 +13,7 @@ @@ -13,7 +13,7 @@
import { userStore } from '$lib/stores/user-store.js';
import { fetchUserProfile, extractProfileData } from '$lib/utils/user-profile.js';
import { combineRelays } from '$lib/config.js';
import { KIND } from '$lib/types/nostr.js';
import { KIND, isEphemeralKind, isReplaceableKind } from '$lib/types/nostr.js';
const npub = ($page.params as { npub?: string }).npub || '';
@ -275,22 +275,62 @@ @@ -275,22 +275,62 @@
profileTags = updatedTags;
}
// Shared function to filter out user's own events and write-proof messages
function shouldExcludeEvent(event: NostrEvent, userPubkey: string): boolean {
// Exclude write-proof kind 24 events
if (event.kind === KIND.PUBLIC_MESSAGE && event.content && event.content.includes('gitrepublic-write-proof')) {
return true;
}
// Exclude user's own events (messages FROM the user)
// Note: We want to SHOW messages TO the user from other people, so we only exclude messages FROM the user
/**
* Determines if an event should be excluded from the activity feed.
i *
* @param event - The event to check
* @param userPubkey - The pubkey of the user whose profile we're viewing
* @param forActivityTab - If true, applies stricter filtering for activity tab (excludes ephemeral, replaceable, and metadata kinds)
* @returns true if the event should be excluded
*/
function shouldExcludeEvent(event: NostrEvent, userPubkey: string, forActivityTab: boolean = false): boolean {
// Always exclude user's own events (events FROM the user)
// Note: We want to SHOW events TO the user from other people, so we only exclude events FROM the user
if (event.pubkey === userPubkey) {
return true;
}
// Note: We don't exclude messages TO the user from other people - those should be shown
// The check for "messages to themselves" is already covered by the check above (event.pubkey === userPubkey)
// When filtering for activity tab, apply stricter exclusions
if (forActivityTab) {
// Exclude all ephemeral events (20000-29999) - not meant to be stored
if (isEphemeralKind(event.kind)) {
return true;
}
// Exclude all replaceable events (0, 3, 10000-19999) - these are metadata/configuration
if (isReplaceableKind(event.kind)) {
return true;
}
// Exclude specific regular kinds that are not repo-related:
// Kind 1: Keep this one in, just for the user's convenience
// Kind 2: Client metadata (not relevant for activity)
if (event.kind === 2) {
return true;
}
// Kind 5: Deletion requests
if (event.kind === KIND.DELETION_REQUEST) {
return true;
}
// Kind 6: User's like to see reposts
// Kind 7: User's like to see reactions
// Kind 8: Badge awards (not relevant for repo activity)
if (event.kind === 8) {
return true;
}
// Kind 24: Public messages (shown in messages tab)
if (event.kind === KIND.PUBLIC_MESSAGE) {
return true;
}
}
return false;
}
@ -613,8 +653,12 @@ @@ -613,8 +653,12 @@
// Step 5: Deduplicate, filter, and sort by created_at (newest first)
const eventMap = new Map<string, NostrEvent>();
for (const event of allActivityEvents) {
// Use shared exclusion function to filter out user's own events and write-proof messages
if (shouldExcludeEvent(event, userPubkey)) {
// Use shared exclusion function to filter out:
// - User's own events
// - Ephemeral events (20000-29999)
// - Replaceable events (0, 3, 10000-19999) - metadata/configuration
// - Non-repo regular kinds (1, 2, 5, 6, 7, 8, 24)
if (shouldExcludeEvent(event, userPubkey, true)) {
continue;
}
@ -640,6 +684,16 @@ @@ -640,6 +684,16 @@
}
function getEventContext(event: NostrEvent): string {
// Special handling for reaction events (kind 7)
if (event.kind === 7) {
const reaction = event.content?.trim() || '+';
const eTag = event.tags.find(t => t[0] === 'e')?.[1];
if (eTag) {
return `Reacted ${reaction} to event ${eTag.slice(0, 8)}...`;
}
return `Reacted ${reaction}`;
}
// Extract context from event content or tags
if (event.content && event.content.trim()) {
// Limit to first 200 characters
@ -1077,9 +1131,20 @@ @@ -1077,9 +1131,20 @@
{:else}
<div class="activity-list">
{#each activityEvents as event}
<div class="activity-card">
<div class="activity-card" class:reaction-event={event.kind === 7}>
<div class="activity-context">
<p class="activity-blurb">{getEventContext(event)}</p>
{#if event.kind === 7}
{@const reaction = event.content?.trim() || '+'}
{@const eTag = event.tags.find(t => t[0] === 'e')?.[1]}
<div class="reaction-display">
<span class="reaction-emoji">{reaction}</span>
<span class="reaction-text">
{eTag ? `Reacted to event ${eTag.slice(0, 8)}...` : 'Reacted'}
</span>
</div>
{:else}
<p class="activity-blurb">{getEventContext(event)}</p>
{/if}
</div>
<div class="activity-footer">
<div class="activity-author">
@ -1124,7 +1189,7 @@ @@ -1124,7 +1189,7 @@
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div class="modal" role="document" onclick={(e) => e.stopPropagation()}>
<h3>Send Public Message</h3>
<p class="modal-note">This message will be publicly visible.</p>
<p class="modal-note">This message can be found and read on relays, but is not usuaally displayed in the main feeds.</p>
<label>
<textarea
bind:value={newMessageContent}
@ -1765,6 +1830,28 @@ @@ -1765,6 +1830,28 @@
word-wrap: break-word;
}
.reaction-display {
display: flex;
align-items: center;
gap: 0.75rem;
}
.reaction-emoji {
font-size: 2rem;
line-height: 1;
display: inline-block;
}
.reaction-text {
color: var(--text-primary);
font-size: 0.875rem;
line-height: 1.6;
}
.reaction-event {
border-left: 3px solid var(--accent);
}
.activity-footer {
display: flex;
justify-content: space-between;

Loading…
Cancel
Save