/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import * as ContextMenuActionCreators from '@app/actions/ContextMenuActionCreators'; import {UserProfilePopout} from '@app/components/popouts/UserProfilePopout'; import {AvatarWithPresence} from '@app/components/uikit/avatars/AvatarWithPresence'; import {VoiceParticipantContextMenu} from '@app/components/uikit/context_menu/VoiceParticipantContextMenu'; import {Popout} from '@app/components/uikit/popout/Popout'; import {useTooltipPortalRoot} from '@app/components/uikit/tooltip/Tooltip'; import styles from '@app/components/voice/StreamSpectatorsPopout.module.css'; import type {SpectatorEntry} from '@app/components/voice/useStreamSpectators'; import {useMergeRefs} from '@app/hooks/useMergeRefs'; import type {UserRecord} from '@app/records/UserRecord'; import AccessibilityStore from '@app/stores/AccessibilityStore'; import MediaEngineStore from '@app/stores/voice/MediaEngineFacade'; import * as NicknameUtils from '@app/utils/NicknameUtils'; import { autoUpdate, FloatingPortal, flip, offset, safePolygon, shift, useFloating, useHover, useInteractions, } from '@floating-ui/react'; import {useLingui} from '@lingui/react/macro'; import {DesktopIcon, DeviceMobileIcon} from '@phosphor-icons/react'; import {AnimatePresence, type MotionStyle, motion} from 'framer-motion'; import {observer} from 'mobx-react-lite'; import type {HTMLAttributes, ReactElement, Ref, SyntheticEvent} from 'react'; import {Children, cloneElement, useCallback, useEffect, useMemo, useRef, useState} from 'react'; interface StreamSpectatorsPopoutProps { viewerUsers: ReadonlyArray; spectatorEntries?: ReadonlyArray; guildId?: string; channelId?: string; onOpenChange?: (open: boolean) => void; children: ReactElement & {ref?: Ref}>; } const FLOATING_INITIAL = {opacity: 0, scale: 0.98}; const FLOATING_INITIAL_REDUCED = {opacity: 1, scale: 1}; const FLOATING_ANIMATE = {opacity: 1, scale: 1}; const FLOATING_EXIT = {opacity: 0, scale: 0.98}; const FLOATING_EXIT_REDUCED = {opacity: 1, scale: 1}; const FLOATING_TRANSITION = { opacity: {duration: 0.1}, scale: {type: 'spring' as const, damping: 25, stiffness: 500}, }; const FLOATING_TRANSITION_REDUCED = {duration: 0}; export const StreamSpectatorsPopout = observer(function StreamSpectatorsPopout({ viewerUsers, spectatorEntries, guildId, channelId, onOpenChange, children, }: StreamSpectatorsPopoutProps) { const {t} = useLingui(); const portalRoot = useTooltipPortalRoot(); const [isOpen, setIsOpen] = useState(false); const [profilePopoutOpen, setProfilePopoutOpen] = useState(false); const profilePopoutOpenRef = useRef(false); useEffect(() => { profilePopoutOpenRef.current = profilePopoutOpen; }, [profilePopoutOpen]); const handleOpenChange = useCallback( (open: boolean) => { if (!open && profilePopoutOpenRef.current) return; setIsOpen(open); onOpenChange?.(open); }, [onOpenChange], ); const floatingMiddleware = useMemo(() => [offset(8), flip(), shift({padding: 8})], []); const {x, y, refs, strategy, context} = useFloating({ open: isOpen, onOpenChange: handleOpenChange, placement: 'bottom-start', middleware: floatingMiddleware, whileElementsMounted: autoUpdate, }); const hoverDelay = useMemo(() => ({open: 200, close: 200}), []); const hoverSafePolygon = useMemo(() => safePolygon({buffer: 4, requireIntent: false}), []); const hover = useHover(context, {delay: hoverDelay, handleClose: hoverSafePolygon}); const {getReferenceProps, getFloatingProps} = useInteractions([hover]); const child = Children.only(children); const referenceRefs = useMemo(() => [refs.setReference, child.props.ref], [refs.setReference, child.props.ref]); const mergedRef = useMergeRefs(referenceRefs); const stopPropagation = useCallback((event: SyntheticEvent) => { event.stopPropagation(); }, []); const referenceProps = useMemo(() => getReferenceProps({ref: mergedRef}), [getReferenceProps, mergedRef]); const floatingProps = useMemo( () => getFloatingProps({ ref: refs.setFloating, onMouseDown: stopPropagation, onClick: stopPropagation, }), [getFloatingProps, refs.setFloating, stopPropagation], ); const floatingStyles = useMemo( (): MotionStyle => ({ position: strategy, left: x ?? 0, top: y ?? 0, zIndex: 'var(--z-index-tooltip)', visibility: x === null || y === null ? 'hidden' : 'visible', pointerEvents: 'auto', }), [strategy, x, y], ); const handleProfilePopoutOpen = useCallback(() => setProfilePopoutOpen(true), []); const handleProfilePopoutClose = useCallback(() => setProfilePopoutOpen(false), []); const entries = spectatorEntries ?? []; const count = entries.length > 0 ? entries.length : viewerUsers.length; if (count === 0) return children; return ( <> {cloneElement(child, referenceProps)} {portalRoot && ( {isOpen && ( {t`Spectators`} - {count} {entries.length > 0 ? entries.map((entry) => ( )) : viewerUsers.map((user) => ( ))} )} )} > ); }); interface SpectatorRowProps { user: UserRecord; guildId?: string; channelId?: string; isMobile?: boolean; connectionId?: string; onPopoutOpen: () => void; onPopoutClose: () => void; } const SpectatorRow = observer(function SpectatorRow({ user, guildId, channelId, isMobile, connectionId, onPopoutOpen, onPopoutClose, }: SpectatorRowProps) { const displayName = NicknameUtils.getNickname(user, guildId, channelId) || user.username || user.id; const participantName = displayName || user.username || user.id; const voiceState = connectionId ? MediaEngineStore.getVoiceStateByConnectionId(connectionId) : null; const selfMute = voiceState?.self_mute ?? false; const selfDeaf = voiceState?.self_deaf ?? false; const handleContextMenu = useCallback( (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); ContextMenuActionCreators.openFromEvent(event, ({onClose}) => ( )); }, [channelId, connectionId, guildId, participantName, user], ); return ( ( )} position="left-start" onOpen={onPopoutOpen} onClose={onPopoutClose} > {displayName} {connectionId != null && ( {isMobile ? : } )} ); });