You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

769 lines
21 KiB

<script lang="ts">
import { onMount } from 'svelte';
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 { 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';
const npub = ($page.params as { npub?: string }).npub || '';
let loading = $state(true);
let error = $state<string | null>(null);
let userPubkey = $state<string | null>(null);
let viewerPubkeyHex = $state<string | null>(null);
let lastViewerPubkeyHex = $state<string | null>(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<NostrEvent[]>([]);
let userProfile = $state<{ name?: string; about?: string; picture?: string } | null>(null);
// Messages tab
let activeTab = $state<'repos' | 'messages'>('repos');
let messages = $state<PublicMessage[]>([]);
let loadingMessages = $state(false);
let showSendMessageDialog = $state(false);
let newMessageContent = $state('');
let sendingMessage = $state(false);
let messagesService: PublicMessagesService | null = null;
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
const gitDomain = $page.data.gitDomain || 'localhost:6543';
onMount(async () => {
await loadViewerPubkey();
await loadUserProfile();
});
// Load messages when messages tab is active
$effect(() => {
if (activeTab === 'messages' && userPubkey && messages.length === 0) {
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)
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;
}
// Fetch user's repositories via API (with privacy filtering)
const url = `/api/users/${npub}/repos?domain=${encodeURIComponent(gitDomain)}`;
const response = await fetch(url, {
headers: viewerPubkeyHex ? {
'X-User-Pubkey': viewerPubkeyHex
} : {}
});
if (!response.ok) {
throw new Error(`Failed to load repositories: ${response.statusText}`);
}
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) {
try {
const profile = JSON.parse(profileEvents[0].content);
userProfile = {
name: profile.name,
about: profile.about,
picture: profile.picture
};
} catch {
// Invalid JSON, ignore
}
}
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load user profile';
console.error('Error loading user profile:', err);
} finally {
loading = 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 getForwardingPubkey(): string | null {
if (userPubkey && viewerPubkeyHex && viewerPubkeyHex === userPubkey) {
return userPubkey;
}
return null;
}
async function loadMessages() {
if (!userPubkey) return;
loadingMessages = true;
error = null;
try {
if (!messagesService) {
messagesService = new PublicMessagesService(DEFAULT_NOSTR_RELAYS);
}
messages = await messagesService.getAllMessagesForUser(userPubkey, 100);
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load messages';
console.error('Error loading messages:', err);
} finally {
loadingMessages = false;
}
}
async function sendMessage() {
if (!newMessageContent.trim() || !viewerPubkeyHex || !userPubkey) {
alert('Please enter a message and make sure you are logged in');
return;
}
if (viewerPubkeyHex === userPubkey) {
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 }]
);
// 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);
} finally {
sendingMessage = false;
}
}
function getMessageRecipients(message: PublicMessage): string[] {
return message.tags
.filter(tag => tag[0] === 'p' && tag[1])
.map(tag => tag[1]);
}
function formatMessageTime(timestamp: number): string {
const date = new Date(timestamp * 1000);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
</script>
<div class="container">
<header>
<div class="profile-header">
{#if userProfile?.picture}
<img src={userProfile.picture} alt="Profile" class="profile-picture" />
{:else}
<div class="profile-picture-placeholder">
{npub.slice(0, 2).toUpperCase()}
</div>
{/if}
<div class="profile-info">
<h1>{userProfile?.name || npub.slice(0, 16)}...</h1>
{#if userProfile?.about}
<p class="profile-about">{userProfile.about}</p>
{/if}
<p class="profile-npub">npub: {npub}</p>
</div>
</div>
{#if getForwardingPubkey()}
<ForwardingConfig userPubkeyHex={getForwardingPubkey()!} />
<div class="dashboard-link">
<a href="/dashboard" class="dashboard-button">
<img src="/icons/layout-dashboard.svg" alt="Dashboard" class="icon-inline" />
View Universal Git Dashboard
</a>
</div>
{/if}
</header>
<main>
{#if error}
<div class="error">Error: {error}</div>
{/if}
{#if loading}
<div class="loading">Loading profile...</div>
{:else}
<!-- Tabs -->
<div class="tabs">
<button
class="tab-button"
class:active={activeTab === 'repos'}
onclick={() => activeTab = 'repos'}
>
Repositories ({repos.length})
</button>
<button
class="tab-button"
class:active={activeTab === 'messages'}
onclick={() => activeTab = 'messages'}
>
Messages ({messages.length})
</button>
</div>
<!-- Repositories Tab -->
{#if activeTab === 'repos'}
<div class="repos-section">
<h2>Repositories ({repos.length})</h2>
{#if repos.length === 0}
<div class="empty">No repositories found</div>
{:else}
<div class="repo-grid">
{#each repos as event}
<div
class="repo-card"
role="button"
tabindex="0"
onclick={() => goto(`/repos/${npub}/${getRepoId(event)}`)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
goto(`/repos/${npub}/${getRepoId(event)}`);
}
}}
style="cursor: pointer;">
<h3>{getRepoName(event)}</h3>
{#if getRepoDescription(event)}
<p class="repo-description">{getRepoDescription(event)}</p>
{/if}
<div class="repo-meta">
<span class="repo-date">
{new Date(event.created_at * 1000).toLocaleDateString()}
</span>
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
<!-- Messages Tab -->
{#if activeTab === 'messages'}
<div class="messages-section">
<div class="messages-header">
<h2>Public Messages</h2>
{#if viewerPubkeyHex && viewerPubkeyHex !== userPubkey}
<button onclick={() => showSendMessageDialog = true} class="send-message-button">
Send Message
</button>
{/if}
</div>
{#if loadingMessages}
<div class="loading">Loading messages...</div>
{:else if messages.length === 0}
<div class="empty">No messages found</div>
{:else}
<div class="messages-list">
{#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)}
<div class="message-item" class:from-viewer={isFromViewer} class:to-viewer={isToViewer && !isFromViewer}>
<div class="message-header">
<UserBadge pubkey={message.pubkey} />
<span class="message-time">{formatMessageTime(message.created_at)}</span>
</div>
<div class="message-recipients">
{#if getMessageRecipients(message).length > 0}
<span class="recipients-label">To:</span>
{#each getMessageRecipients(message) as recipientPubkey}
<UserBadge pubkey={recipientPubkey} />
{/each}
{/if}
</div>
<div class="message-content">{message.content}</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
{/if}
</main>
<!-- Send Message Dialog -->
{#if showSendMessageDialog}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="modal-overlay"
role="dialog"
aria-modal="true"
aria-label="Send message"
onclick={() => showSendMessageDialog = false}
onkeydown={(e) => e.key === 'Escape' && (showSendMessageDialog = false)}
tabindex="-1"
>
<div
class="modal"
role="document"
onclick={(e) => e.stopPropagation()}
>
<h3>Send Public Message</h3>
<p class="modal-note">This message will be publicly visible, but will usually not be displayed outside of notifications.</p>
<label>
Message:
<textarea
bind:value={newMessageContent}
rows="6"
placeholder="Type your message..."
disabled={sendingMessage}
></textarea>
</label>
<div class="modal-actions">
<button
onclick={() => { showSendMessageDialog = false; newMessageContent = ''; }}
class="cancel-button"
disabled={sendingMessage}
>
Cancel
</button>
<button
onclick={sendMessage}
disabled={!newMessageContent.trim() || sendingMessage}
class="send-button"
>
{sendingMessage ? 'Sending...' : 'Send Message'}
</button>
</div>
</div>
</div>
{/if}
</div>
<style>
.profile-header {
display: flex;
gap: 1.5rem;
align-items: flex-start;
margin-bottom: 2rem;
padding-bottom: 2rem;
border-bottom: 1px solid var(--border-color);
}
.profile-picture {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
}
.profile-picture-placeholder {
width: 80px;
height: 80px;
border-radius: 50%;
background: var(--accent);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
font-weight: bold;
}
.profile-about {
color: var(--text-secondary);
margin: 0.5rem 0;
}
.profile-npub {
color: var(--text-muted);
font-size: 0.9rem;
margin: 0.5rem 0 0 0;
font-family: 'IBM Plex Mono', monospace;
}
.repos-section {
margin-top: 2rem;
}
.repo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
}
.tabs {
display: flex;
gap: 0.5rem;
border-bottom: 1px solid var(--border-color);
margin-bottom: 2rem;
}
.tab-button {
padding: 0.75rem 1.5rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-size: 1rem;
color: var(--text-secondary);
transition: all 0.2s;
}
.tab-button:hover {
color: var(--text-primary);
background: var(--bg-secondary);
}
.tab-button.active {
color: var(--accent);
border-bottom-color: var(--accent);
font-weight: 500;
}
.messages-section {
margin-top: 1rem;
}
.messages-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.messages-header h2 {
margin: 0;
}
.send-message-button {
padding: 0.5rem 1rem;
background: var(--accent);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
transition: background 0.2s;
}
.send-message-button:hover {
background: var(--accent-dark);
}
.messages-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.message-item {
padding: 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
}
.message-item.from-viewer {
background: var(--accent-light);
border-color: var(--accent);
}
.message-item.to-viewer {
border-left: 3px solid var(--accent);
}
.message-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.message-time {
color: var(--text-muted);
font-size: 0.85rem;
}
.message-recipients {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.recipients-label {
color: var(--text-muted);
font-size: 0.85rem;
font-weight: 500;
}
.message-content {
color: var(--text-primary);
white-space: pre-wrap;
word-wrap: break-word;
line-height: 1.5;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: var(--card-bg);
border-radius: 8px;
padding: 2rem;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
}
.modal h3 {
margin: 0 0 1rem 0;
}
.modal-note {
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 1rem;
padding: 0.75rem;
background: var(--bg-secondary);
border-radius: 4px;
}
.modal label {
display: block;
margin-bottom: 1rem;
}
.modal label textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border-color);
border-radius: 4px;
font-family: inherit;
font-size: 1rem;
resize: vertical;
box-sizing: border-box;
}
.modal-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
.cancel-button,
.send-button {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
}
.cancel-button {
background: var(--bg-secondary);
color: var(--text-primary);
}
.send-button {
background: var(--accent);
color: white;
}
.send-button:hover:not(:disabled) {
background: var(--accent-dark);
}
.send-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.dashboard-link {
margin-top: 1rem;
}
.dashboard-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: var(--accent);
color: white;
text-decoration: none;
border-radius: 6px;
font-size: 0.95rem;
font-weight: 500;
transition: background 0.2s;
}
.icon-inline {
width: 16px;
height: 16px;
display: inline-block;
vertical-align: middle;
}
.dashboard-button:hover {
background: var(--accent-dark);
}
</style>