diff --git a/js/src/components/ui/avatar.tsx b/js/src/components/ui/avatar.tsx index 75c377d..b9582b2 100644 --- a/js/src/components/ui/avatar.tsx +++ b/js/src/components/ui/avatar.tsx @@ -8,14 +8,14 @@ function Avatar({ size = "default", ...props }: React.ComponentProps & { - size?: "default" | "sm" | "lg" + size?: "default" | "xs" | "sm" | "lg" }) { return ( ) { data-slot="avatar-badge" className={cn( "bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none", + "group-data-[size=xs]/avatar:size-1.5 group-data-[size=xs]/avatar:[&>svg]:hidden", "group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden", "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", @@ -91,7 +92,7 @@ function AvatarGroupCount({ return (
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 ring-background relative flex shrink-0 items-center justify-center ring-2", className)} + className={cn("bg-muted text-muted-foreground size-8 rounded-full text-sm group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 group-has-data-[size=xs]/avatar-group:size-4 group-has-data-[size=xs]/avatar-group:text-[8px] [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 ring-background relative flex shrink-0 items-center justify-center ring-2", className)} {...props} /> ) diff --git a/js/src/features/particles/media-particle-view.tsx b/js/src/features/particles/media-particle-view.tsx index a37fee1..af3d7ed 100644 --- a/js/src/features/particles/media-particle-view.tsx +++ b/js/src/features/particles/media-particle-view.tsx @@ -98,7 +98,7 @@ export function MediaParticleView({ /> {audioSource && ( -
+
)} diff --git a/js/src/features/particles/playback-page-indicator.tsx b/js/src/features/particles/playback-page-indicator.tsx index 2a63dec..ac5bb80 100644 --- a/js/src/features/particles/playback-page-indicator.tsx +++ b/js/src/features/particles/playback-page-indicator.tsx @@ -1,10 +1,20 @@ import { cn } from "@/lib/utils"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import type { HumanPresence } from "@/hooks/use-presence-positions"; + +const MAX_VISIBLE_AVATARS = 3; interface PlaybackPageIndicatorProps { total: number; current: number; progress: number; onGoTo: (index: number) => void; + presenceBySegment?: Map; } export function PlaybackPageIndicator({ @@ -12,45 +22,79 @@ export function PlaybackPageIndicator({ current, progress, onGoTo, + presenceBySegment, }: PlaybackPageIndicatorProps) { if (total === 0) return null; return ( -
- {Array.from({ length: total }, (_, i) => ( - - ))} + +
+ ); + })} +
+ ); +} + +function SegmentPresenceAvatars({ + presence, +}: { + presence: HumanPresence[]; +}) { + const visible = presence.slice(0, MAX_VISIBLE_AVATARS); + const overflow = presence.length - MAX_VISIBLE_AVATARS; + + return ( +
+ {visible.map((human) => ( + + + + + {human.emailPrefix.slice(0, 2).toUpperCase()} + + + + + {human.email} + + + ))} + {overflow > 0 && ( + + +{overflow} + + )}
); } diff --git a/js/src/features/particles/stream-view.tsx b/js/src/features/particles/stream-view.tsx index 0450868..7f4751a 100644 --- a/js/src/features/particles/stream-view.tsx +++ b/js/src/features/particles/stream-view.tsx @@ -20,6 +20,7 @@ import { WindowControls } from "@/components/window-controls"; import { RelativeTimestamp } from "@/components/relative-timestamp"; import { useStreamPlayback } from "@/hooks/use-stream-playback"; import { usePrefetchAdjacentMedia } from "@/hooks/use-prefetch-adjacent-media"; +import { usePresencePositions } from "@/hooks/use-presence-positions"; import { getInitials } from "@/lib/utils"; function getParticleDisplayName(particle: Particle): string { @@ -119,6 +120,15 @@ export function StreamView({ path, streamParticle }: StreamViewProps) { usePrefetchAdjacentMedia(children, currentIndex); + const authedUser = useAuthStore((s) => s.user); + const network = useNetwork(networkId); + const presenceBySegment = usePresencePositions( + streamParticle.playback_markers, + children, + network?.humans, + authedUser?.id, + ); + const [composeActive, setComposeActive] = useState(false); const [progress, setProgress] = useState(0); @@ -268,16 +278,6 @@ export function StreamView({ path, streamParticle }: StreamViewProps) { {/* Bottom gradient safe zone — keeps captions & controls readable */}
- {/* Progress indicator */} -
- -
-
@@ -303,9 +303,6 @@ export function StreamView({ path, streamParticle }: StreamViewProps) { {/* Bottom-left: metadata */}
- {currentParticle && ( - - )} {exitRemainingMs !== null && ( Closing in {Math.ceil(exitRemainingMs / 1000)}s @@ -324,6 +321,17 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
+ + {/* Progress indicator — bottom */} +
+ +
); } @@ -388,7 +396,7 @@ function TopBar({ networkId, particle, streamParticle }: { networkId: string; pa - {human?.email_prefix ?? humanId} + {human?.email ?? humanId} ); })} @@ -446,41 +454,3 @@ function ParticleBreadcrumbContent({ particle, networkId }: { particle: Particle ) } -// Shows a list of avatars of users who have seen the current particle, based on playback markers in the stream particle. -const SeenIndicator = ({ stream, currentParticle, networkId }: { stream: Particle & { type: "stream" }, currentParticle: Particle, networkId: string }) => { - const authedUser = useAuthStore((s) => s.user); - const network = useNetwork(networkId); - const playbackMarkers = stream.playback_markers ?? {}; - - const seenUserIds = Object.entries(playbackMarkers) - .filter(([userId, timestamp]) => timestamp.getTime() >= currentParticle.created_at.getTime() && userId !== authedUser?.id) - .map(([userId, _]) => userId); - - const seenHumans = seenUserIds - .map((userId) => network?.humans?.find((h) => h.id === userId)) - .filter((h): h is NonNullable => !!h && h.id !== currentParticle.created_by_human_id); - - if (seenHumans.length === 0) return null; - - return ( - <> - {"Seen by"} - - {seenHumans.map((human) => ( - - - - - {human.email_prefix.slice(0, 2)} - - - - -

Seen by {human.email_prefix}

-
-
- ))} -
- - ); -} diff --git a/js/src/features/particles/transcript-overlay.tsx b/js/src/features/particles/transcript-overlay.tsx index 8746b9a..ee3a944 100644 --- a/js/src/features/particles/transcript-overlay.tsx +++ b/js/src/features/particles/transcript-overlay.tsx @@ -75,7 +75,7 @@ export function TranscriptOverlay({ return (

{activeChunk.map((word, i) => { diff --git a/js/src/hooks/use-presence-positions.ts b/js/src/hooks/use-presence-positions.ts new file mode 100644 index 0000000..02cf716 --- /dev/null +++ b/js/src/hooks/use-presence-positions.ts @@ -0,0 +1,50 @@ +import { useMemo } from "react"; +import type { Human, Particle } from "@/api/types"; + +export interface HumanPresence { + humanId: string; + email: string; + emailPrefix: string; +} + +/** + * Maps playback markers to segment indices, returning which users are present at each particle. + */ +export function usePresencePositions( + playbackMarkers: Record | undefined, + children: Particle[], + networkHumans: Human[] | undefined, + currentUserId: string | undefined, +): Map { + return useMemo(() => { + const result = new Map(); + if (!playbackMarkers || !networkHumans || children.length === 0) return result; + + for (const [userId, markerTimestamp] of Object.entries(playbackMarkers)) { + if (userId === currentUserId) continue; + + const human = networkHumans.find((h) => h.id === userId); + if (!human) continue; + + // Find the last particle whose created_at <= marker timestamp + let segmentIndex = -1; + for (let i = children.length - 1; i >= 0; i--) { + if (children[i].created_at.getTime() <= markerTimestamp.getTime()) { + segmentIndex = i; + break; + } + } + if (segmentIndex === -1) continue; + + const existing = result.get(segmentIndex); + const presence: HumanPresence = { humanId: human.id, email: human.email, emailPrefix: human.email_prefix }; + if (existing) { + existing.push(presence); + } else { + result.set(segmentIndex, [presence]); + } + } + + return result; + }, [playbackMarkers, children, networkHumans, currentUserId]); +}