diff --git a/backend/src/webhooks/webhooks.service.ts b/backend/src/webhooks/webhooks.service.ts index 7a92d66..2c693c4 100644 --- a/backend/src/webhooks/webhooks.service.ts +++ b/backend/src/webhooks/webhooks.service.ts @@ -63,8 +63,12 @@ export class WebhooksService implements OnModuleInit { ); switch (event.type) { - case 'InvoiceSettled': - case 'InvoicePaymentSettled': { + // Only process InvoiceSettled — this fires when the invoice is + // confirmed paid. InvoicePaymentSettled fires earlier (when + // payment is received but not yet confirmed) and can cause + // "Invoice not paid" errors due to the invoice still being + // in Processing state. + case 'InvoiceSettled': { try { const invoiceId = event.invoiceId; const invoice = await this.btcpayService.getInvoice(invoiceId); @@ -97,6 +101,16 @@ export class WebhooksService implements OnModuleInit { } } + case 'InvoicePaymentSettled': { + // Payment received but invoice may still be processing. + // We wait for InvoiceSettled to confirm the payment. + Logger.log( + `Invoice ${event.invoiceId} payment received — waiting for InvoiceSettled to confirm`, + 'WebhooksService', + ); + break; + } + case 'InvoiceExpired': case 'InvoiceInvalid': { Logger.log( diff --git a/src/components/VideoPlayer.vue b/src/components/VideoPlayer.vue index f097322..2d10a5f 100644 --- a/src/components/VideoPlayer.vue +++ b/src/components/VideoPlayer.vue @@ -686,6 +686,9 @@ function formatTime(seconds: number): string { background: #0a0a0a; display: flex; flex-direction: column; + overflow: hidden; + height: 100vh; + height: 100dvh; /* dynamic viewport height for mobile */ } .video-player-container { @@ -694,6 +697,7 @@ function formatTime(seconds: number): string { flex-direction: column; position: relative; cursor: none; + min-height: 0; /* prevent video intrinsic size from overflowing */ } .video-player-container:hover { @@ -734,10 +738,13 @@ function formatTime(seconds: number): string { align-items: center; justify-content: center; overflow: hidden; + min-height: 0; /* prevent video intrinsic size from overflowing flex container */ } .video-area video { object-fit: contain; + max-height: 100%; + max-width: 100%; } /* YouTube iframe fills the entire area */ diff --git a/src/composables/useAccounts.ts b/src/composables/useAccounts.ts index 8c00c94..a19fab6 100644 --- a/src/composables/useAccounts.ts +++ b/src/composables/useAccounts.ts @@ -57,15 +57,21 @@ export function useAccounts() { }) /** - * Display name: prefer Nostr profile → test persona → pubkey slice. + * Display name: prefer Nostr profile → test persona → npub slice. + * Skips generic placeholder names like "Nostr" so the user sees + * a more useful identifier while they set up their profile. */ const activeName = computed(() => { if (!activeAccount.value) return null // First: check if we have kind 0 profile metadata const profile = activeProfile.value - if (profile?.display_name) return profile.display_name - if (profile?.name) return profile.name + if (profile?.display_name && !/^nostr$/i.test(profile.display_name.trim())) { + return profile.display_name + } + if (profile?.name && !/^nostr$/i.test(profile.name.trim())) { + return profile.name + } // Second: check test personas const allPersonas: Persona[] = [ @@ -77,8 +83,10 @@ export function useAccounts() { ) if (match?.name) return match.name - // Fallback: first 8 chars of pubkey - return activeAccount.value?.pubkey?.slice(0, 8) ?? 'Unknown' + // Fallback: truncated npub for a friendlier display + const pk = activeAccount.value?.pubkey + if (!pk) return 'Unknown' + return `npub...${pk.slice(-6)}` }) /** @@ -93,11 +101,15 @@ export function useAccounts() { /** * Fetch kind 0 profile metadata for a pubkey from relays. - * Results are cached to avoid redundant relay queries. + * Queries multiple relays and keeps the MOST RECENT event + * (highest created_at) to ensure we get the latest profile. */ function fetchProfile(pubkey: string) { if (!pubkey || profileCache.value.has(pubkey)) return + // Track the best (most recent) event seen so far for this fetch + let bestCreatedAt = 0 + const relays = [...APP_RELAYS, ...LOOKUP_RELAYS] try { const profileSub = pool @@ -108,18 +120,34 @@ export function useAccounts() { .subscribe({ next: (event: any) => { try { + // Only accept this event if it's newer than what we already have + const eventTime = event.created_at ?? 0 + if (eventTime < bestCreatedAt) return + const metadata: NostrProfileMetadata = JSON.parse(event.content) - const updated = new Map(profileCache.value) - updated.set(pubkey, metadata) - profileCache.value = updated + + // Skip profiles with no meaningful data (empty or + // generic placeholder names without a picture) + const hasName = !!(metadata.display_name || metadata.name) + const isGeneric = hasName && /^nostr$/i.test((metadata.display_name || metadata.name || '').trim()) + const hasPicture = !!metadata.picture + + // Accept if: has a non-generic name, has a picture, or + // this is the first event we've received + if (bestCreatedAt === 0 || !isGeneric || hasPicture) { + bestCreatedAt = eventTime + const updated = new Map(profileCache.value) + updated.set(pubkey, metadata) + profileCache.value = updated + } } catch { // Invalid JSON in profile event } }, }) - // Close subscription after 5 seconds - setTimeout(() => profileSub.unsubscribe(), 5000) + // Close subscription after 8 seconds (allows slower relays to respond) + setTimeout(() => profileSub.unsubscribe(), 8000) subscriptions.push(profileSub) } catch (err) { console.error(`[useAccounts] Failed to fetch profile for ${pubkey}:`, err) diff --git a/src/lib/relay.ts b/src/lib/relay.ts index 03a39ca..e4d8ab3 100644 --- a/src/lib/relay.ts +++ b/src/lib/relay.ts @@ -31,8 +31,15 @@ function getAppRelays(): string[] { // App relays (local dev relay or proxied production relay) export const APP_RELAYS = getAppRelays() -// Lookup relays for profile metadata -export const LOOKUP_RELAYS = ['wss://purplepag.es'] +// Lookup relays for profile metadata (kind 0, kind 3, NIP-65). +// Multiple popular relays ensures we find the user's most up-to-date profile. +export const LOOKUP_RELAYS = [ + 'wss://purplepag.es', + 'wss://relay.damus.io', + 'wss://relay.nostr.band', + 'wss://nos.lol', + 'wss://relay.primal.net', +] // Observable relay list for reactive subscriptions export const appRelays = new BehaviorSubject([...APP_RELAYS]) diff --git a/src/services/indeehub-api.service.ts b/src/services/indeehub-api.service.ts index 817e66c..7617a49 100644 --- a/src/services/indeehub-api.service.ts +++ b/src/services/indeehub-api.service.ts @@ -22,13 +22,18 @@ class IndeehubApiService { }, }) - // Attach JWT token from NIP-98 session. - // If the token has expired but we have a refresh token, proactively - // refresh before sending the request to avoid unnecessary 401 round-trips. + // Attach JWT token from NIP-98 session if available. + // Refresh failures are caught so public endpoints still work + // without auth (e.g. GET /projects after switching users). this.client.interceptors.request.use(async (config) => { let token = nip98Service.accessToken if (!token && sessionStorage.getItem('indeehub_api_refresh')) { - token = await nip98Service.refresh() + try { + token = await nip98Service.refresh() + } catch { + // Refresh failed (stale token, wrong user, etc.) + // Continue without auth — public endpoints don't need it + } } if (token) { config.headers.Authorization = `Bearer ${token}` @@ -37,17 +42,23 @@ class IndeehubApiService { }) // Auto-refresh on 401 (fallback if the proactive refresh above - // didn't happen or the token expired mid-flight) + // didn't happen or the token expired mid-flight). + // Refresh failures are caught so the original error propagates + // cleanly instead of masking it with a refresh error. this.client.interceptors.response.use( (response) => response, async (error) => { const originalRequest = error.config if (error.response?.status === 401 && !originalRequest._retry) { originalRequest._retry = true - const newToken = await nip98Service.refresh() - if (newToken) { - originalRequest.headers.Authorization = `Bearer ${newToken}` - return this.client(originalRequest) + try { + const newToken = await nip98Service.refresh() + if (newToken) { + originalRequest.headers.Authorization = `Bearer ${newToken}` + return this.client(originalRequest) + } + } catch { + // Refresh failed — fall through to reject the original error } } return Promise.reject(error) @@ -100,7 +111,10 @@ class IndeehubApiService { } /** - * Get all published projects + * Get all published projects. + * Pass `fresh: true` to bypass the backend's 5-minute cache + * (e.g. after login or content source switch) so newly published + * backstage content appears immediately. */ async getProjects(filters?: { status?: string @@ -108,6 +122,7 @@ class IndeehubApiService { genre?: string limit?: number offset?: number + fresh?: boolean }): Promise { const params = new URLSearchParams() if (filters?.status) params.append('status', filters.status) @@ -115,6 +130,7 @@ class IndeehubApiService { if (filters?.genre) params.append('genre', filters.genre) if (filters?.limit) params.append('limit', String(filters.limit)) if (filters?.offset) params.append('offset', String(filters.offset)) + if (filters?.fresh) params.append('_t', String(Date.now())) const url = `/projects${params.toString() ? `?${params.toString()}` : ''}` const response = await this.client.get(url) diff --git a/src/stores/content.ts b/src/stores/content.ts index fd84aae..ed01262 100644 --- a/src/stores/content.ts +++ b/src/stores/content.ts @@ -7,7 +7,8 @@ import { contentService } from '../services/content.service' import { mapApiProjectsToContents } from '../utils/mappers' import { useContentSourceStore } from './contentSource' import { indeehubApiService } from '../services/indeehub-api.service' -import { useFilmmaker } from '../composables/useFilmmaker' +// useFilmmaker import removed — mergePublishedFilmmakerProjects +// now fetches from the public API so all users see backstage content import { USE_MOCK as USE_MOCK_DATA } from '../utils/mock' export const useContentStore = defineStore('content', () => { @@ -73,7 +74,7 @@ export const useContentStore = defineStore('content', () => { */ async function fetchContentFromIndeehubApi() { try { - const response = await indeehubApiService.getProjects() + const response = await indeehubApiService.getProjects({ fresh: true }) // Handle both array responses and wrapped responses like { data: [...] } const projects = Array.isArray(response) ? response : (response as any)?.data ?? [] @@ -181,77 +182,67 @@ export const useContentStore = defineStore('content', () => { } /** - * Convert published filmmaker projects to Content format and merge - * them into the existing content rows so they appear on the browse page. + * Fetch ALL published projects from the public API and merge them + * into the existing content rows so backstage content appears for + * every user, regardless of which content source is active. * - * If the filmmaker projects haven't been loaded yet (e.g. first visit - * is the homepage, not backstage), attempt to fetch them first — but - * only when the user has an active session. + * Ownership is determined by comparing the project's filmmaker ID + * to the current user's filmmaker profile. Only the actual creator + * sees the "my movie" flag — not other logged-in users. */ async function mergePublishedFilmmakerProjects() { + const apiUrl = import.meta.env.VITE_INDEEHUB_API_URL || '' + if (!apiUrl) return // No backend configured + try { - const filmmaker = useFilmmaker() + // Fetch ALL published projects from the public API endpoint. + // This works for any user (authenticated or not) — no auth required. + // Use fresh: true to bypass the backend's 5-minute cache + // so newly published backstage content appears immediately. + const response = await indeehubApiService.getProjects({ fresh: true }) + const projects = Array.isArray(response) ? response : (response as any)?.data ?? [] + if (!Array.isArray(projects) || projects.length === 0) return - // If projects aren't loaded yet, check whether the user has - // session tokens. We check sessionStorage directly because - // authStore.initialize() may still be in-flight on first load. - if (filmmaker.projects.value.length === 0) { - const nostrToken = sessionStorage.getItem('nostr_token') - const cognitoToken = sessionStorage.getItem('auth_token') - const hasSession = !!nostrToken || !!cognitoToken - - if (hasSession) { - // Always sync nip98Service tokens on page load. The token - // may exist in sessionStorage but its expiry record may be - // stale, causing nip98Service.accessToken to return null. - // Re-storing refreshes the expiry so the interceptor can - // include the token; if it really expired the backend 401 - // interceptor will auto-refresh via the refresh token. - if (nostrToken) { - const { nip98Service } = await import('../services/nip98.service') - nip98Service.storeTokens( - nostrToken, - sessionStorage.getItem('indeehub_api_refresh') ?? sessionStorage.getItem('refresh_token') ?? '', - ) - } - - try { - await filmmaker.fetchProjects() - } catch (err) { - console.warn('[content-store] Failed to fetch filmmaker projects for merge:', err) - } - } + // Determine the current user's filmmaker ID (if any) for + // ownership tagging. Import lazily to avoid circular deps. + let currentFilmmakerId: string | null = null + try { + const { useAuthStore } = await import('./auth') + const authStore = useAuthStore() + currentFilmmakerId = authStore.user?.filmmaker?.id ?? null + } catch { + // Auth store not ready — ownership will be unknown } - const published = filmmaker.projects.value.filter(p => p.status === 'published') - if (published.length === 0) return + const publishedContent: Content[] = projects.map((p: any) => { + const projectFilmmakerId = p.filmmaker?.id ?? p.filmmakerId ?? null + return { + id: p.id, + contentId: p.film?.id, + title: p.title || p.name, + description: p.synopsis || '', + thumbnail: p.poster || '/images/placeholder-poster.jpg', + backdrop: p.poster || '/images/placeholder-poster.jpg', + type: p.type === 'episodic' ? 'series' as const : 'film' as const, + rating: p.format || undefined, + releaseYear: p.releaseDate ? new Date(p.releaseDate).getFullYear() : new Date().getFullYear(), + categories: p.genres?.map((g: any) => g.name) || [], + slug: p.slug, + rentalPrice: p.film?.rentalPrice ?? p.rentalPrice, + status: p.status, + apiData: p, + // Only mark as "own project" if the current user is the actual filmmaker + isOwnProject: !!(currentFilmmakerId && projectFilmmakerId && currentFilmmakerId === projectFilmmakerId), + } + }) console.debug( - '[content-store] Merging published projects:', - published.map(p => ({ id: p.id, title: p.title, filmId: p.film?.id, rentalPrice: p.film?.rentalPrice ?? p.rentalPrice })), + '[content-store] Merging API published projects:', + publishedContent.map(c => ({ id: c.id, title: c.title, isOwn: c.isOwnProject })), ) - const publishedContent: Content[] = published.map(p => ({ - id: p.id, - contentId: p.film?.id, - title: p.title || p.name, - description: p.synopsis || '', - thumbnail: p.poster || '/images/placeholder-poster.jpg', - backdrop: p.poster || '/images/placeholder-poster.jpg', - type: p.type === 'episodic' ? 'series' as const : 'film' as const, - rating: p.format || undefined, - releaseYear: p.releaseDate ? new Date(p.releaseDate).getFullYear() : new Date().getFullYear(), - categories: p.genres?.map(g => g.name) || [], - slug: p.slug, - rentalPrice: p.film?.rentalPrice ?? p.rentalPrice, - status: p.status, - apiData: p, - isOwnProject: true, - })) - // Merge into each content row (prepend so they appear first) for (const key of Object.keys(contentRows.value)) { - // Avoid duplicates by filtering out any already-present IDs const existingIds = new Set(contentRows.value[key].map(c => c.id)) const newItems = publishedContent.filter(c => !existingIds.has(c.id)) if (newItems.length > 0) { @@ -263,8 +254,8 @@ export const useContentStore = defineStore('content', () => { if (!featuredContent.value && publishedContent.length > 0) { featuredContent.value = publishedContent[0] } - } catch { - // Filmmaker composable may not be initialized yet — safe to ignore + } catch (err) { + console.warn('[content-store] Failed to merge API published projects:', err) } }