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.
 
 
 
 
 

505 lines
16 KiB

<script lang="ts">
import '../app.css';
import { onMount, onDestroy, setContext } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import Footer from '$lib/components/Footer.svelte';
import NavBar from '$lib/components/NavBar.svelte';
import TransferNotification from '$lib/components/TransferNotification.svelte';
import type { Snippet } from 'svelte';
import { getPublicKeyWithNIP07, isNIP07Available } from '$lib/services/nostr/nip07-signer.js';
import { determineUserLevel, decodePubkey } from '$lib/services/nostr/user-level-service.js';
import { userStore } from '$lib/stores/user-store.js';
import { updateActivity } from '$lib/services/activity-tracker.js';
import { settingsStore } from '$lib/services/settings-store.js';
// Accept children as a snippet prop (Svelte 5)
let { children }: { children: Snippet } = $props();
// Component mount tracking to prevent state updates after destruction
let isMounted = $state(true);
// Store cleanup references
let handlePendingTransfersEvent: ((event: Event) => void) | null = null;
let handleThemeChanged: ((event: Event) => void) | null = null;
// Theme management - default to gitrepublic-dark (purple)
let theme = $state<'gitrepublic-dark' | 'gitrepublic-black'>('gitrepublic-dark');
// User level checking state
let checkingUserLevel = $state(false);
// Transfer notification state
type PendingTransfer = {
eventId: string;
fromPubkey: string;
toPubkey: string;
repoTag: string;
repoName: string;
originalOwner: string;
timestamp: number;
createdAt: string;
event: any;
};
let pendingTransfers = $state<PendingTransfer[]>([]);
let dismissedTransfers = $state<Set<string>>(new Set());
// Load theme on mount and watch for changes
onMount(() => {
// Only run client-side code
if (typeof window === 'undefined' || !isMounted) return;
// Load theme from settings store (async)
(async () => {
if (!isMounted) return;
try {
const settings = await settingsStore.getSettings();
if (isMounted) {
theme = settings.theme;
themeLoaded = true;
applyTheme(theme);
// Also sync to localStorage for app.html flash prevention
localStorage.setItem('theme', theme);
}
} catch (err) {
if (!isMounted) return;
console.warn('Failed to load theme from settings, using default:', err);
// Fallback to localStorage for migration
try {
const savedTheme = localStorage.getItem('theme') as 'gitrepublic-dark' | 'gitrepublic-black' | null;
if (savedTheme === 'gitrepublic-dark' || savedTheme === 'gitrepublic-black') {
if (isMounted) {
theme = savedTheme;
themeLoaded = true;
applyTheme(theme);
// Migrate to settings store
settingsStore.setSetting('theme', theme).catch(console.error);
}
} else if (isMounted) {
// Migrate old light theme to dark
if (savedTheme === 'gitrepublic-light') {
theme = 'gitrepublic-dark';
} else {
theme = 'gitrepublic-dark';
}
themeLoaded = true;
applyTheme(theme);
localStorage.setItem('theme', theme);
settingsStore.setSetting('theme', theme).catch(console.error);
}
} catch {
// Ignore localStorage errors
}
}
})();
// Update activity on mount (if user is logged in)
// Session expiry is handled by user store initialization and NavBar
try {
const currentState = $userStore;
if (currentState && currentState.userPubkey && currentState.userPubkeyHex && isMounted) {
updateActivity();
}
} catch (err) {
if (isMounted) {
console.warn('Failed to update activity on mount:', err);
}
}
// Check user level if not on splash page
// Only check if user store is not already initialized with a logged-in user
// Guard against SSR - $page store can only be accessed in component context
if (typeof window !== 'undefined' && isMounted) {
try {
const pageUrl = $page.url;
if (pageUrl && pageUrl.pathname !== '/') {
const currentState = $userStore;
// Only check if we don't have a user or if user level is strictly_rate_limited
if (isMounted && (!currentState.userPubkey || currentState.userLevel === 'strictly_rate_limited')) {
checkUserLevel();
}
}
} catch (err) {
// Ignore errors accessing $page during SSR or destruction
if (isMounted) {
console.warn('Failed to check user level on mount:', err);
}
}
}
// Listen for pending transfers events from login functions
handlePendingTransfersEvent = (event: Event) => {
if (!isMounted) return;
try {
const customEvent = event as CustomEvent;
if (customEvent.detail?.transfers && isMounted) {
// Filter out dismissed transfers
pendingTransfers = customEvent.detail.transfers.filter(
(t: { eventId: string }) => !dismissedTransfers.has(t.eventId)
);
}
} catch (err) {
// Ignore errors during destruction
if (isMounted) {
console.warn('Pending transfers event handler error:', err);
}
}
};
if (handlePendingTransfersEvent) {
window.addEventListener('pendingTransfers', handlePendingTransfersEvent);
}
// Listen for theme changes from SettingsModal
handleThemeChanged = (event: Event) => {
if (!isMounted) return;
try {
const customEvent = event as CustomEvent<{ theme: 'gitrepublic-dark' | 'gitrepublic-black' }>;
if (customEvent.detail?.theme && isMounted) {
theme = customEvent.detail.theme;
// Sync to localStorage for app.html flash prevention
localStorage.setItem('theme', theme);
// Theme will be applied via $effect
}
} catch (err) {
// Ignore errors during destruction
if (isMounted) {
console.warn('Theme changed event handler error:', err);
}
}
};
if (handleThemeChanged) {
window.addEventListener('themeChanged', handleThemeChanged);
}
// Session expiry checking is handled by:
// 1. User store initialization (checks on load)
// 2. NavBar component (checks on mount and periodically)
// 3. Splash page (+page.svelte) (checks on mount)
// No need for redundant checks here
});
// Only register onDestroy on client side to prevent SSR errors
if (typeof window !== 'undefined') {
onDestroy(() => {
// Mark component as unmounted first
isMounted = false;
// Clean up event listeners
try {
if (handlePendingTransfersEvent) {
window.removeEventListener('pendingTransfers', handlePendingTransfersEvent);
handlePendingTransfersEvent = null;
}
} catch (err) {
// Ignore errors during cleanup
}
try {
if (handleThemeChanged) {
window.removeEventListener('themeChanged', handleThemeChanged);
handleThemeChanged = null;
}
} catch (err) {
// Ignore errors during cleanup
}
});
}
async function checkUserLevel() {
// Only run client-side
if (typeof window === 'undefined' || !isMounted) return;
// Skip if already checking or if user store is already set
try {
const currentState = $userStore;
if (!currentState || !isMounted) return;
if (checkingUserLevel || (currentState.userPubkey && currentState.userLevel !== 'strictly_rate_limited')) {
return;
}
// Only check user level if user has explicitly logged in (has pubkey in store)
// Don't automatically get pubkey from NIP-07 - that should only happen on explicit login
if (!currentState.userPubkey) {
// User not logged in - set to strictly rate limited without checking
if (isMounted) {
userStore.setUser(null, null, 'strictly_rate_limited', null);
}
return;
}
if (!isMounted) return;
checkingUserLevel = true;
userStore.setChecking(true);
try {
// Use pubkey from store (user has explicitly logged in)
const userPubkey = currentState.userPubkey;
const userPubkeyHex = currentState.userPubkeyHex;
if (!isMounted) {
checkingUserLevel = false;
userStore.setChecking(false);
return;
}
// Determine user level
const levelResult = await determineUserLevel(userPubkey, userPubkeyHex);
if (!isMounted) {
checkingUserLevel = false;
userStore.setChecking(false);
return;
}
// Update user store
userStore.setUser(
levelResult.userPubkey,
levelResult.userPubkeyHex,
levelResult.level,
levelResult.error || null
);
// Update activity if user is logged in
if (levelResult.userPubkey && levelResult.userPubkeyHex && isMounted) {
updateActivity();
// Check for pending transfers
checkPendingTransfers(levelResult.userPubkeyHex);
}
} catch (err) {
if (isMounted) {
console.error('Failed to check user level:', err);
// Set to strictly rate limited on error
userStore.setUser(null, null, 'strictly_rate_limited', 'Failed to check user level');
}
} finally {
if (isMounted) {
checkingUserLevel = false;
userStore.setChecking(false);
}
}
} catch (err) {
// Ignore errors during destruction
if (isMounted) {
console.warn('User level check error:', err);
}
}
}
async function checkPendingTransfers(userPubkeyHex: string) {
if (!isMounted) return;
try {
// Add timeout to prevent hanging
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
const response = await fetch('/api/transfers/pending', {
headers: {
'X-User-Pubkey': userPubkeyHex
},
signal: controller.signal
});
clearTimeout(timeoutId);
if (response.ok && isMounted) {
const data = await response.json();
if (data.pendingTransfers && data.pendingTransfers.length > 0 && isMounted) {
// Filter out dismissed transfers
pendingTransfers = data.pendingTransfers.filter(
(t: { eventId: string }) => !dismissedTransfers.has(t.eventId)
);
}
}
} catch (err) {
// Only log if it's not an abort (timeout) and component is still mounted
if (isMounted && err instanceof Error && err.name !== 'AbortError') {
console.error('Failed to check for pending transfers:', err);
}
// Silently ignore timeouts - they're expected if the server is slow
}
}
function dismissTransfer(eventId: string) {
dismissedTransfers.add(eventId);
pendingTransfers = pendingTransfers.filter(t => t.eventId !== eventId);
}
function applyTheme(newTheme?: 'gitrepublic-dark' | 'gitrepublic-black') {
const themeToApply = newTheme || theme;
// Only run client-side
if (typeof window === 'undefined') return;
// Remove all theme attributes first
document.documentElement.removeAttribute('data-theme');
document.documentElement.removeAttribute('data-theme-light');
document.documentElement.removeAttribute('data-theme-black');
// Apply the selected theme
if (themeToApply === 'gitrepublic-dark') {
document.documentElement.setAttribute('data-theme', 'dark');
} else if (themeToApply === 'gitrepublic-black') {
document.documentElement.setAttribute('data-theme', 'black');
}
// Save to settings store (async, don't await)
if (newTheme) {
settingsStore.setSetting('theme', themeToApply).catch(console.error);
}
}
// Watch for theme changes and apply them (but only after initial load)
let themeLoaded = $state(false);
$effect(() => {
if (typeof window !== 'undefined' && themeLoaded && isMounted) {
applyTheme(theme);
}
});
function toggleTheme() {
// Cycle between dark and black themes
if (theme === 'gitrepublic-dark') {
theme = 'gitrepublic-black';
} else {
theme = 'gitrepublic-dark';
}
// Theme change will be applied via $effect
}
// Provide theme context to child components
// Guard against SSR issues where setContext might be called outside component initialization
try {
setContext('theme', {
get theme() { return { value: theme }; },
toggleTheme
});
} catch (err) {
// Silently ignore setContext errors during SSR or if called outside component initialization
// This can happen during server-side rendering or in certain edge cases
if (typeof window !== 'undefined') {
// Only log in browser to avoid cluttering SSR logs
console.warn('Failed to set theme context:', err);
}
}
// Hide nav bar and footer on splash page (root path)
// Use state that gets updated on mount to avoid SSR issues with $page store
let isSplashPage = $state(false);
// Update splash page state on mount (client-side only)
$effect(() => {
if (typeof window !== 'undefined' && isMounted) {
try {
const pageUrl = $page.url;
if (pageUrl && isMounted) {
isSplashPage = pageUrl.pathname === '/';
}
} catch (err) {
// Ignore errors accessing $page during SSR or destruction
if (isMounted) {
console.warn('Failed to check splash page state:', err);
}
}
}
});
// Subscribe to user store
const userState = $derived($userStore);
// Check for transfers when user logs in
$effect(() => {
if (!isMounted || typeof window === 'undefined') return;
try {
const currentUser = $userStore;
if (!currentUser || !isMounted) return;
if (currentUser.userPubkeyHex && !checkingUserLevel && isMounted) {
checkPendingTransfers(currentUser.userPubkeyHex);
} else if (!currentUser.userPubkeyHex && isMounted) {
// Clear transfers when user logs out
pendingTransfers = [];
dismissedTransfers.clear();
}
} catch (err) {
// Ignore errors during destruction
if (isMounted) {
console.warn('Transfer check effect error:', err);
}
}
});
</script>
{#if !isSplashPage}
<NavBar />
{/if}
<!-- Transfer notifications -->
{#each pendingTransfers as transfer (transfer.eventId)}
<TransferNotification {transfer} on:dismiss={(e) => dismissTransfer(e.detail.eventId)} />
{/each}
{#if !isSplashPage && checkingUserLevel}
<div class="user-level-check">
<div class="check-message">
<p>Checking user access level...</p>
<div class="spinner"></div>
</div>
</div>
{:else}
{@render children()}
{/if}
{#if !isSplashPage}
<Footer />
{/if}
<style>
.user-level-check {
display: flex;
align-items: center;
justify-content: center;
min-height: 50vh;
padding: 2rem;
}
.check-message {
text-align: center;
}
.check-message p {
margin-bottom: 1rem;
color: var(--text-primary, #1a1a1a);
font-size: 1.1rem;
}
.spinner {
border: 3px solid var(--bg-secondary, #e8e8e8);
border-top: 3px solid var(--accent, #007bff);
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@media (prefers-color-scheme: dark) {
.check-message p {
color: var(--text-primary, #f5f5f5);
}
.spinner {
border-color: var(--bg-secondary, #2d2d2d);
border-top-color: var(--accent, #007bff);
}
}
</style>