Browse Source

open live-stream

imwald
Silberengel 3 weeks ago
parent
commit
5c26aba22b
  1. 99
      src/components/LiveActivitiesStrip.tsx
  2. 4
      src/i18n/locales/de.ts
  3. 2
      src/i18n/locales/en.ts
  4. 5
      src/lib/live-activities.ts
  5. 30
      src/providers/LiveActivitiesProvider.tsx
  6. 9
      src/providers/live-activities-context.ts
  7. 14
      src/providers/useLiveActivities.ts

99
src/components/LiveActivitiesStrip.tsx

@ -1,16 +1,19 @@ @@ -1,16 +1,19 @@
import { LIVE_ACTIVITIES_SLIDE_INTERVAL_MS } from '@/lib/live-activities'
import { toNote } from '@/lib/link'
import { cn } from '@/lib/utils'
import { useLiveActivitiesOptional } from '@/providers/LiveActivitiesProvider'
import { useSmartNoteNavigation } from '@/PageManager'
import { useLiveActivitiesOptional } from '@/providers/useLiveActivities'
import { useUserPreferencesOptional } from '@/providers/UserPreferencesProvider'
import storage from '@/services/local-storage.service'
import { ExternalLink } from 'lucide-react'
import { useEffect, useLayoutEffect, useState } from 'react'
import { useCallback, useEffect, useLayoutEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
type TPlacement = 'sidebar' | 'mobile'
export default function LiveActivitiesStrip({ placement }: { placement: TPlacement }) {
const { t } = useTranslation()
const { navigateToNote } = useSmartNoteNavigation()
const userPrefs = useUserPreferencesOptional()
const showLiveActivitiesBanner =
userPrefs?.showLiveActivitiesBanner ?? storage.getShowLiveActivitiesBanner()
@ -45,12 +48,21 @@ export default function LiveActivitiesStrip({ placement }: { placement: TPlaceme @@ -45,12 +48,21 @@ export default function LiveActivitiesStrip({ placement }: { placement: TPlaceme
setSlide((s) => Math.min(s, items.length - 1))
}, [items.length])
// `items` can shrink without a new array identity; `slide` may then be out of range until effects run.
const displayIndex = items.length === 0 ? 0 : Math.min(slide, items.length - 1)
const itemAtSlide = items[displayIndex]
const openLiveNote = useCallback(() => {
const ev = itemAtSlide?.event
if (!ev) return
// Same bech32 as {@link getNoteBech32Id} + pass event so {@link navigationEventStore} / cache match the URL.
navigateToNote(toNote(ev), ev)
}, [navigateToNote, itemAtSlide])
if (!showLiveActivitiesBanner || items.length === 0) {
return null
}
// `items` can shrink without a new array identity; `slide` may then be out of range until effects run.
const displayIndex = Math.min(slide, items.length - 1)
const current = items[displayIndex]
if (!current) {
return null
@ -69,41 +81,58 @@ export default function LiveActivitiesStrip({ placement }: { placement: TPlaceme @@ -69,41 +81,58 @@ export default function LiveActivitiesStrip({ placement }: { placement: TPlaceme
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground xl:text-xs">
{t('liveActivities.heading')}
</div>
<a
href={current.joinUrl}
target="_blank"
rel="noopener noreferrer"
<div
className={cn(
'flex min-w-0 gap-2 rounded-md transition-colors hover:bg-muted/80',
placement === 'sidebar' && 'flex-col xl:flex-row xl:items-start',
placement === 'mobile' && 'items-center'
'flex min-w-0 gap-1.5 rounded-md',
placement === 'sidebar' && 'flex-col xl:flex-row xl:items-stretch',
placement === 'mobile' && 'items-stretch'
)}
>
{current.imageUrl ? (
<img
src={current.imageUrl}
alt=""
className={cn(
'shrink-0 rounded object-cover',
placement === 'sidebar' ? 'h-14 w-full xl:h-12 xl:w-12' : 'h-12 w-12'
)}
/>
) : null}
<div className="min-w-0 flex-1">
<div className="flex items-start gap-1">
<span className="line-clamp-2 min-w-0 flex-1 text-xs font-medium leading-snug xl:text-sm">
{current.title}
</span>
<ExternalLink className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" aria-hidden />
</div>
{current.summary ? (
<p className="mt-0.5 line-clamp-2 text-[11px] text-muted-foreground xl:text-xs">{current.summary}</p>
) : null}
{current.fromFollowedHost ? (
<p className="mt-1 text-[10px] text-green-600 dark:text-green-500">{t('liveActivities.fromFollow')}</p>
<button
type="button"
onClick={openLiveNote}
className={cn(
'flex min-w-0 flex-1 gap-2 rounded-md text-left transition-colors hover:bg-muted/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
placement === 'sidebar' && 'flex-col xl:flex-row xl:items-start',
placement === 'mobile' && 'items-center'
)}
title={t('liveActivities.viewNoteTitle')}
>
{current.imageUrl ? (
<img
src={current.imageUrl}
alt=""
className={cn(
'shrink-0 rounded object-cover',
placement === 'sidebar' ? 'h-14 w-full xl:h-12 xl:w-12' : 'h-12 w-12'
)}
/>
) : null}
</div>
</a>
<div className="min-w-0 flex-1">
<div className="line-clamp-2 text-xs font-medium leading-snug xl:text-sm">{current.title}</div>
{current.summary ? (
<p className="mt-0.5 line-clamp-2 text-[11px] text-muted-foreground xl:text-xs">{current.summary}</p>
) : null}
{current.fromFollowedHost ? (
<p className="mt-1 text-[10px] text-green-600 dark:text-green-500">{t('liveActivities.fromFollow')}</p>
) : null}
</div>
</button>
<a
href={current.joinUrl}
target="_blank"
rel="noopener noreferrer"
className={cn(
'flex shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted/80 hover:text-foreground',
placement === 'sidebar' ? 'h-9 w-full xl:h-auto xl:w-9 xl:self-start' : 'h-12 w-10'
)}
title={t('liveActivities.openJoinPageTitle')}
aria-label={t('liveActivities.openJoinPageTitle')}
onClick={(e) => e.stopPropagation()}
>
<ExternalLink className="size-4 shrink-0" aria-hidden />
</a>
</div>
{items.length > 1 ? (
<div className="mt-2 flex justify-center gap-1.5">
{items.map((item, i) => (

4
src/i18n/locales/de.ts

@ -609,6 +609,10 @@ export default { @@ -609,6 +609,10 @@ export default {
'liveActivities.regionLabel': 'Live-Räume und Streams',
'liveActivities.fromFollow': 'Von jemandem, dem du folgst',
'liveActivities.goToSlide': 'Live-Eintrag {{n}} anzeigen',
'liveActivities.viewNoteTitle':
'Diese Live-Aktivität als Beitrag öffnen (Wiedergabe in der App, Links darunter)',
'liveActivities.openJoinPageTitle':
'Join-Seite in neuem Tab öffnen (z. B. zap.stream oder die Raum-Website)',
'liveActivities.settingsToggle': 'Banner für Live-Aktivitäten',
'liveActivities.settingsHint':
'Zeigt NIP-53-Live-Räume (Audio/Video) von deinen Relays. Aktualisierung zur Viertelstunde und nach dem ersten Session-Warm-up.',

2
src/i18n/locales/en.ts

@ -604,6 +604,8 @@ export default { @@ -604,6 +604,8 @@ export default {
'liveActivities.regionLabel': 'Live spaces and streams',
'liveActivities.fromFollow': 'From someone you follow',
'liveActivities.goToSlide': 'Show live item {{n}}',
'liveActivities.viewNoteTitle': 'Open this live activity as a note (play in app, links below)',
'liveActivities.openJoinPageTitle': 'Open the join page in a new tab (e.g. zap.stream or the room site)',
'liveActivities.settingsToggle': 'Live activities banner',
'liveActivities.settingsHint':
'Shows NIP-53 live rooms (audio/video spaces) from your relays. Updates on a quarter-hour schedule and when the app finishes its initial session warm-up.',

5
src/lib/live-activities.ts

@ -51,6 +51,8 @@ export type TLiveActivityItem = { @@ -51,6 +51,8 @@ export type TLiveActivityItem = {
joinUrl: string
updatedAt: number
fromFollowedHost: boolean
/** Full Nostr event (for navigation cache — same payload the strip already loaded). */
event: Event
}
function firstTagValue(ev: Event, name: string): string | undefined {
@ -488,7 +490,8 @@ export function parseLiveActivityEvent( @@ -488,7 +490,8 @@ export function parseLiveActivityEvent(
imageUrl,
joinUrl,
updatedAt: ev.created_at,
fromFollowedHost: followSet.has(ev.pubkey)
fromFollowedHost: followSet.has(ev.pubkey),
event: ev
}
}

30
src/providers/LiveActivitiesProvider.tsx

@ -11,39 +11,13 @@ import logger from '@/lib/logger' @@ -11,39 +11,13 @@ import logger from '@/lib/logger'
import client from '@/services/client.service'
import storage from '@/services/local-storage.service'
import { registerLiveActivitiesPrewarmCallback } from '@/services/live-activities-prewarm-bridge'
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState
} from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { LiveActivitiesContext } from './live-activities-context'
import { useFavoriteRelays } from './FavoriteRelaysProvider'
import { useFollowListOptional } from './follow-list-context'
import { useNostr } from './NostrProvider'
import { useUserPreferencesOptional } from './UserPreferencesProvider'
type TLiveActivitiesContext = {
items: TLiveActivityItem[]
loading: boolean
}
const LiveActivitiesContext = createContext<TLiveActivitiesContext | undefined>(undefined)
export function useLiveActivities(): TLiveActivitiesContext {
const ctx = useContext(LiveActivitiesContext)
if (!ctx) {
throw new Error('useLiveActivities must be used within LiveActivitiesProvider')
}
return ctx
}
export function useLiveActivitiesOptional(): TLiveActivitiesContext | undefined {
return useContext(LiveActivitiesContext)
}
export function LiveActivitiesProvider({ children }: { children: React.ReactNode }) {
const { pubkey, relayList, isInitialized, isAccountSessionHydrating } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()

9
src/providers/live-activities-context.ts

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
import type { TLiveActivityItem } from '@/lib/live-activities'
import { createContext } from 'react'
export type LiveActivitiesContextValue = {
items: TLiveActivityItem[]
loading: boolean
}
export const LiveActivitiesContext = createContext<LiveActivitiesContextValue | undefined>(undefined)

14
src/providers/useLiveActivities.ts

@ -0,0 +1,14 @@ @@ -0,0 +1,14 @@
import { useContext } from 'react'
import { LiveActivitiesContext, type LiveActivitiesContextValue } from './live-activities-context'
export function useLiveActivities(): LiveActivitiesContextValue {
const ctx = useContext(LiveActivitiesContext)
if (!ctx) {
throw new Error('useLiveActivities must be used within LiveActivitiesProvider')
}
return ctx
}
export function useLiveActivitiesOptional(): LiveActivitiesContextValue | undefined {
return useContext(LiveActivitiesContext)
}
Loading…
Cancel
Save