275 lines
9.0 KiB
TypeScript
275 lines
9.0 KiB
TypeScript
/*
|
|
* 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 <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
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<UserRecord>;
|
|
spectatorEntries?: ReadonlyArray<SpectatorEntry>;
|
|
guildId?: string;
|
|
channelId?: string;
|
|
onOpenChange?: (open: boolean) => void;
|
|
children: ReactElement<HTMLAttributes<HTMLElement> & {ref?: Ref<HTMLElement>}>;
|
|
}
|
|
|
|
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 && (
|
|
<FloatingPortal root={portalRoot}>
|
|
<AnimatePresence>
|
|
{isOpen && (
|
|
<motion.div
|
|
{...floatingProps}
|
|
style={floatingStyles}
|
|
initial={AccessibilityStore.useReducedMotion ? FLOATING_INITIAL_REDUCED : FLOATING_INITIAL}
|
|
animate={FLOATING_ANIMATE}
|
|
exit={AccessibilityStore.useReducedMotion ? FLOATING_EXIT_REDUCED : FLOATING_EXIT}
|
|
transition={AccessibilityStore.useReducedMotion ? FLOATING_TRANSITION_REDUCED : FLOATING_TRANSITION}
|
|
>
|
|
<div className={styles.card}>
|
|
<div className={styles.header}>
|
|
{t`Spectators`} - {count}
|
|
</div>
|
|
<div className={styles.list} role="listbox">
|
|
{entries.length > 0
|
|
? entries.map((entry) => (
|
|
<SpectatorRow
|
|
key={`${entry.userId}_${entry.connectionId}`}
|
|
user={entry.user}
|
|
guildId={guildId}
|
|
channelId={channelId}
|
|
isMobile={entry.isMobile}
|
|
connectionId={entry.connectionId}
|
|
onPopoutOpen={handleProfilePopoutOpen}
|
|
onPopoutClose={handleProfilePopoutClose}
|
|
/>
|
|
))
|
|
: viewerUsers.map((user) => (
|
|
<SpectatorRow
|
|
key={user.id}
|
|
user={user}
|
|
guildId={guildId}
|
|
channelId={channelId}
|
|
onPopoutOpen={handleProfilePopoutOpen}
|
|
onPopoutClose={handleProfilePopoutClose}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</FloatingPortal>
|
|
)}
|
|
</>
|
|
);
|
|
});
|
|
|
|
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<HTMLElement>) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
ContextMenuActionCreators.openFromEvent(event, ({onClose}) => (
|
|
<VoiceParticipantContextMenu
|
|
user={user}
|
|
participantName={participantName}
|
|
onClose={onClose}
|
|
guildId={guildId}
|
|
connectionId={connectionId}
|
|
/>
|
|
));
|
|
},
|
|
[channelId, connectionId, guildId, participantName, user],
|
|
);
|
|
|
|
return (
|
|
<Popout
|
|
render={({popoutKey}) => (
|
|
<UserProfilePopout
|
|
key={`${user.id}:${guildId ?? 'global'}:user`}
|
|
popoutKey={popoutKey}
|
|
user={user}
|
|
isWebhook={false}
|
|
guildId={guildId}
|
|
/>
|
|
)}
|
|
position="left-start"
|
|
onOpen={onPopoutOpen}
|
|
onClose={onPopoutClose}
|
|
>
|
|
<div className={styles.spectatorRow} role="option" tabIndex={0} onContextMenu={handleContextMenu}>
|
|
<AvatarWithPresence user={user} size={24} muted={selfMute} deafened={selfDeaf} guildId={guildId} />
|
|
<span className={styles.spectatorName}>{displayName}</span>
|
|
{connectionId != null && (
|
|
<span className={styles.spectatorDevice}>
|
|
{isMobile ? <DeviceMobileIcon size={14} weight="regular" /> : <DesktopIcon size={14} weight="regular" />}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</Popout>
|
|
);
|
|
});
|