mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
532 lines
19 KiB
TypeScript
532 lines
19 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import {GroupDMAvatar} from '@app/features/app/components/shared/GroupDMAvatar';
|
|
import type {Channel} from '@app/features/channel/models/Channel';
|
|
import * as ChannelUtils from '@app/features/channel/utils/ChannelUtils';
|
|
import type {VoiceState} from '@app/features/gateway/types/GatewayVoiceTypes';
|
|
import {GuildIcon} from '@app/features/guild/components/popouts/GuildIcon';
|
|
import type {Guild} from '@app/features/guild/models/Guild';
|
|
import {WATCH_STREAM_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
|
import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils';
|
|
import * as NavigationCommands from '@app/features/navigation/commands/NavigationCommands';
|
|
import Permission from '@app/features/permissions/state/Permission';
|
|
import {AvatarStack} from '@app/features/ui/avatars/AvatarStack';
|
|
import {Button} from '@app/features/ui/button/Button';
|
|
import {Avatar} from '@app/features/ui/components/Avatar';
|
|
import {LiveBadge} from '@app/features/ui/components/LiveBadge';
|
|
import {Spinner} from '@app/features/ui/components/Spinner';
|
|
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
|
|
import styles from '@app/features/user/components/profile/VoiceActivityCard.module.css';
|
|
import {
|
|
VOICE_ACTIVITY_AVATAR_MAX_VISIBLE,
|
|
VOICE_ACTIVITY_AVATAR_SIZE_PX,
|
|
} from '@app/features/user/components/profile/VoiceActivityCardMetrics';
|
|
import type {User} from '@app/features/user/models/User';
|
|
import Users from '@app/features/user/state/Users';
|
|
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
|
|
import * as VoiceStreamWatchCommands from '@app/features/voice/commands/VoiceStreamWatchCommands';
|
|
import {useStreamPreview} from '@app/features/voice/components/useStreamPreview';
|
|
import {useStreamWatchState} from '@app/features/voice/components/useStreamWatchState';
|
|
import {
|
|
createVoiceParticipantSortSnapshot,
|
|
sortVoiceParticipantItemsWithSnapshot,
|
|
} from '@app/features/voice/components/VoiceParticipantSortUtils';
|
|
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
|
|
import {usePendingVoiceConnection} from '@app/features/voice/hooks/usePendingVoiceConnection';
|
|
import type {UserVoiceActivity} from '@app/features/voice/hooks/useUserVoiceActivities';
|
|
import {useVoiceJoinEligibility} from '@app/features/voice/hooks/useVoiceJoinEligibility';
|
|
import {canViewStreamPreview} from '@app/features/voice/utils/StreamPreviewPermissionUtils';
|
|
import {buildVoiceParticipantIdentity} from '@app/features/voice/utils/VoiceParticipantIdentity';
|
|
import {ME} from '@fluxer/constants/src/AppConstants';
|
|
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
|
import {msg} from '@lingui/core/macro';
|
|
import {Trans, useLingui} from '@lingui/react/macro';
|
|
import {CaretRightIcon, PhoneIcon, SpeakerHighIcon} from '@phosphor-icons/react';
|
|
import {clsx} from 'clsx';
|
|
import {observer} from 'mobx-react-lite';
|
|
import type React from 'react';
|
|
import {useCallback, useMemo, useRef} from 'react';
|
|
|
|
const PARTICIPANTS_DESCRIPTOR = msg({
|
|
message: 'Participants',
|
|
comment: 'Short label in the user settings voice activity card. Keep it concise.',
|
|
});
|
|
const PARTICIPANTS_2_DESCRIPTOR = msg({
|
|
message: 'Participants: {names}',
|
|
comment:
|
|
'Short label in the user settings voice activity card. Keep it concise. Preserve {names}; it is inserted by code.',
|
|
});
|
|
const YOU_DON_T_HAVE_PERMISSION_TO_JOIN_THIS_DESCRIPTOR = msg({
|
|
message: "You can't join this voice channel",
|
|
comment: 'Label in the user settings voice activity card. Keep the tone plain and specific.',
|
|
});
|
|
const OPEN_VOICE_DESCRIPTOR = msg({
|
|
message: 'Open voice',
|
|
comment: 'Button or menu action label in the user settings voice activity card. Keep it concise.',
|
|
});
|
|
const JOIN_VOICE_DESCRIPTOR = msg({
|
|
message: 'Join voice',
|
|
comment: 'Button or menu action label in the user settings voice activity card. Keep it concise.',
|
|
});
|
|
const WATCHING_DESCRIPTOR = msg({
|
|
message: 'Watching',
|
|
comment: 'Short label in the user settings voice activity card. Keep it concise.',
|
|
});
|
|
const OPEN_DESCRIPTOR = msg({
|
|
message: 'Open {contextName}',
|
|
comment:
|
|
'Button or menu action label in the user settings voice activity card. Keep it concise. Preserve {contextName}; it is inserted by code.',
|
|
});
|
|
const OPEN_2_DESCRIPTOR = msg({
|
|
message: 'Open {displayName}',
|
|
comment:
|
|
'Button or menu action label in the user settings voice activity card. Keep it concise. Preserve {displayName}; it is inserted by code.',
|
|
});
|
|
const OPEN_3_DESCRIPTOR = msg({
|
|
message: 'Open @{displayName}',
|
|
comment:
|
|
'Button or menu action label in the user settings voice activity card. Keep it concise. Preserve {displayName}; it is inserted by code.',
|
|
});
|
|
const STREAM_PREVIEW_DESCRIPTOR = msg({
|
|
message: 'Stream preview',
|
|
comment: 'Short label in the user settings voice activity card. Keep it concise.',
|
|
});
|
|
const NO_PREVIEW_YET_DESCRIPTOR = msg({
|
|
message: 'No preview yet',
|
|
comment: 'Empty-state text in the user settings voice activity card.',
|
|
});
|
|
|
|
interface VoiceActivityCardProps {
|
|
activity: UserVoiceActivity;
|
|
onNavigate?: () => void;
|
|
}
|
|
|
|
export const VoiceActivityCard: React.FC<VoiceActivityCardProps> = observer(({activity, onNavigate}) => {
|
|
const {i18n} = useLingui();
|
|
const {voiceState, connectionId, guildId, channelId, channel, guild, isStreaming, streamKey, participantUsers} =
|
|
activity;
|
|
const isConnectedToChannel = useMemo(() => {
|
|
if (!channelId) return false;
|
|
return MediaEngine.channelId === channelId && MediaEngine.guildId === (guildId ?? null);
|
|
}, [channelId, guildId]);
|
|
const {canJoin} = useVoiceJoinEligibility({
|
|
guildId: guildId ?? null,
|
|
channelId,
|
|
});
|
|
const canFetchStreamPreview = canViewStreamPreview({
|
|
guildId,
|
|
channelId,
|
|
hasConnectPermission: () =>
|
|
Permission.can(Permissions.CONNECT, {guildId: guildId ?? undefined, channelId: channelId ?? undefined}),
|
|
});
|
|
const {previewUrl, isPreviewLoading} = useStreamPreview(
|
|
isStreaming && !!streamKey && canFetchStreamPreview,
|
|
streamKey ?? '',
|
|
);
|
|
const streamWatchArgs = useMemo(
|
|
() => ({
|
|
streamKey: streamKey ?? '',
|
|
guildId: guildId ?? null,
|
|
channelId,
|
|
}),
|
|
[streamKey, guildId, channelId],
|
|
);
|
|
const {isWatching, isPendingJoin, canWatch, startWatching} = useStreamWatchState(streamWatchArgs);
|
|
const handleVoiceConnected = useCallback(() => {
|
|
NavigationCommands.selectChannel(guildId ?? ME, channelId);
|
|
onNavigate?.();
|
|
}, [guildId, channelId, onNavigate]);
|
|
const {isPending: isJoining, startConnection: startJoinConnection} = usePendingVoiceConnection({
|
|
guildId,
|
|
channelId,
|
|
onConnected: handleVoiceConnected,
|
|
});
|
|
const handleWatchConnected = useCallback(() => {
|
|
const streamingUser = Users.getUser(voiceState.user_id);
|
|
if (streamingUser) {
|
|
VoiceStreamWatchCommands.applyStreamWatchFocus({
|
|
participantIdentity: buildVoiceParticipantIdentity(streamingUser.id, connectionId),
|
|
guildId: guildId ?? null,
|
|
channelId: channelId ?? null,
|
|
});
|
|
}
|
|
onNavigate?.();
|
|
}, [voiceState.user_id, connectionId, guildId, channelId, onNavigate]);
|
|
const {isPending: isWatchingStarting, markPending: markWatchPending} = usePendingVoiceConnection({
|
|
guildId,
|
|
channelId,
|
|
onConnected: handleWatchConnected,
|
|
});
|
|
const handleWatchStream = useCallback(
|
|
(event: React.SyntheticEvent) => {
|
|
event.stopPropagation();
|
|
const streamingUser = Users.getUser(voiceState.user_id);
|
|
if (!streamingUser) return;
|
|
VoiceStreamWatchCommands.openAndWatchStream(
|
|
{
|
|
streamKey: streamKey ?? '',
|
|
guildId: guildId ?? null,
|
|
channelId: channelId ?? null,
|
|
userId: streamingUser.id,
|
|
connectionId,
|
|
},
|
|
{startWatching, markPending: markWatchPending},
|
|
);
|
|
},
|
|
[startWatching, voiceState.user_id, connectionId, guildId, channelId, streamKey, markWatchPending],
|
|
);
|
|
const handleJoinOrOpenVoice = useCallback(() => {
|
|
if (isConnectedToChannel) {
|
|
NavigationCommands.selectChannel(guildId ?? ME, channelId);
|
|
onNavigate?.();
|
|
} else if (canJoin && channelId) {
|
|
startJoinConnection({skipConfirm: true});
|
|
}
|
|
}, [isConnectedToChannel, canJoin, guildId, channelId, onNavigate, startJoinConnection]);
|
|
const avatarSortSnapshotRef = useRef(createVoiceParticipantSortSnapshot());
|
|
const avatarStackUsers = useMemo(() => {
|
|
const ownerUser = Users.getUser(voiceState.user_id);
|
|
const userMap = new Map<string, User>();
|
|
if (ownerUser) {
|
|
userMap.set(ownerUser.id, ownerUser);
|
|
}
|
|
for (const user of participantUsers) {
|
|
userMap.set(user.id, user);
|
|
}
|
|
return sortVoiceParticipantItemsWithSnapshot(Array.from(userMap.values()), {
|
|
snapshot: avatarSortSnapshotRef.current,
|
|
getParticipantKey: (user) => user.id,
|
|
getUserId: (user) => user.id,
|
|
guildId,
|
|
channelId,
|
|
});
|
|
}, [voiceState.user_id, participantUsers, guildId, channelId]);
|
|
const participantLabel = useMemo(() => {
|
|
if (avatarStackUsers.length === 0) {
|
|
return i18n._(PARTICIPANTS_DESCRIPTOR);
|
|
}
|
|
const names = avatarStackUsers
|
|
.map((user) => NicknameUtils.getNickname(user, guildId ?? undefined, channelId ?? undefined))
|
|
.join(', ');
|
|
return i18n._(PARTICIPANTS_2_DESCRIPTOR, {names});
|
|
}, [avatarStackUsers, guildId, channelId, i18n.locale]);
|
|
const cannotJoinReason = useMemo(() => {
|
|
if (isConnectedToChannel || canJoin) return null;
|
|
return i18n._(YOU_DON_T_HAVE_PERMISSION_TO_JOIN_THIS_DESCRIPTOR);
|
|
}, [isConnectedToChannel, canJoin, i18n.locale]);
|
|
const buttonLabel = useMemo(() => {
|
|
if (isConnectedToChannel) return i18n._(OPEN_VOICE_DESCRIPTOR);
|
|
return i18n._(JOIN_VOICE_DESCRIPTOR);
|
|
}, [isConnectedToChannel, i18n.locale]);
|
|
return (
|
|
<div className={styles.card} data-flx="user.profile.voice-activity-card.card">
|
|
<div className={styles.headerContextGroup} data-flx="user.profile.voice-activity-card.header-context-group">
|
|
<div className={styles.headerRow} data-flx="user.profile.voice-activity-card.header-row">
|
|
<div className={styles.headerLeft} data-flx="user.profile.voice-activity-card.header-left">
|
|
{isStreaming ? (
|
|
<>
|
|
<span
|
|
className={clsx(styles.activityLabel, styles.streamingLabel)}
|
|
data-flx="user.profile.voice-activity-card.activity-label"
|
|
>
|
|
<Trans>Streaming</Trans>
|
|
</span>
|
|
<LiveBadge showTooltip={false} data-flx="user.profile.voice-activity-card.live-badge" />
|
|
</>
|
|
) : (
|
|
<span className={styles.activityLabel} data-flx="user.profile.voice-activity-card.activity-label--2">
|
|
<Trans>In voice</Trans>
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<VoiceActivityContext
|
|
channel={channel}
|
|
guild={guild}
|
|
guildId={guildId}
|
|
voiceState={voiceState}
|
|
data-flx="user.profile.voice-activity-card.voice-activity-context"
|
|
/>
|
|
</div>
|
|
{avatarStackUsers.length > 0 && (
|
|
<div
|
|
className={styles.participantsRow}
|
|
role="group"
|
|
aria-label={participantLabel}
|
|
data-flx="user.profile.voice-activity-card.participants-row"
|
|
>
|
|
<AvatarStack
|
|
size={VOICE_ACTIVITY_AVATAR_SIZE_PX}
|
|
maxVisible={VOICE_ACTIVITY_AVATAR_MAX_VISIBLE}
|
|
users={avatarStackUsers}
|
|
guildId={guildId}
|
|
channelId={channelId}
|
|
data-flx="user.profile.voice-activity-card.avatar-stack"
|
|
/>
|
|
</div>
|
|
)}
|
|
{isStreaming && streamKey && (
|
|
<StreamPreviewSection
|
|
previewUrl={previewUrl}
|
|
isPreviewLoading={isPreviewLoading}
|
|
isWatching={isWatching}
|
|
isPendingJoin={isPendingJoin}
|
|
canWatch={canWatch}
|
|
onWatch={handleWatchStream}
|
|
watchLabel={isWatching ? i18n._(WATCHING_DESCRIPTOR) : i18n._(WATCH_STREAM_DESCRIPTOR)}
|
|
isSubmitting={isWatchingStarting}
|
|
data-flx="user.profile.voice-activity-card.stream-preview-section"
|
|
/>
|
|
)}
|
|
<div className={styles.actionRow} data-flx="user.profile.voice-activity-card.action-row">
|
|
{cannotJoinReason ? (
|
|
<Tooltip text={cannotJoinReason} maxWidth="xl" data-flx="user.profile.voice-activity-card.tooltip">
|
|
<div style={{width: '100%'}} data-flx="user.profile.voice-activity-card.div">
|
|
<Button
|
|
compact
|
|
fitContainer
|
|
disabled={true}
|
|
leftIcon={
|
|
<PhoneIcon
|
|
weight="fill"
|
|
className={styles.actionIcon}
|
|
data-flx="user.profile.voice-activity-card.action-icon"
|
|
/>
|
|
}
|
|
className={styles.actionButton}
|
|
data-flx="user.profile.voice-activity-card.action-button"
|
|
>
|
|
{buttonLabel}
|
|
</Button>
|
|
</div>
|
|
</Tooltip>
|
|
) : (
|
|
<Button
|
|
compact
|
|
fitContainer
|
|
onClick={handleJoinOrOpenVoice}
|
|
leftIcon={
|
|
<PhoneIcon
|
|
weight="fill"
|
|
className={styles.actionIcon}
|
|
data-flx="user.profile.voice-activity-card.action-icon--2"
|
|
/>
|
|
}
|
|
className={styles.actionButton}
|
|
submitting={isJoining}
|
|
data-flx="user.profile.voice-activity-card.action-button.join-or-open-voice"
|
|
>
|
|
{buttonLabel}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
interface VoiceActivityContextProps {
|
|
channel: Channel | undefined;
|
|
guild: Guild | undefined;
|
|
guildId: string | null;
|
|
voiceState: VoiceState;
|
|
}
|
|
|
|
const VoiceActivityContext: React.FC<VoiceActivityContextProps> = observer(({channel, guild, guildId, voiceState}) => {
|
|
const {i18n} = useLingui();
|
|
const channelId = channel?.id;
|
|
const handleGuildNavigate = useCallback(() => {
|
|
if (!guildId || !channelId) return;
|
|
NavigationCommands.selectChannel(guildId, channelId);
|
|
}, [channelId, guildId]);
|
|
const handleDMNavigate = useCallback(() => {
|
|
if (!channelId) return;
|
|
NavigationCommands.selectChannel(ME, channelId);
|
|
}, [channelId]);
|
|
if (!channel || !channelId) return null;
|
|
if (guild && guildId) {
|
|
const guildName = guild.name ?? '';
|
|
const channelName = channel.name ?? '';
|
|
const contextName = guildName && channelName ? `${guildName} · ${channelName}` : (guildName || channelName).trim();
|
|
return (
|
|
<button
|
|
type="button"
|
|
className={styles.contextButton}
|
|
onClick={handleGuildNavigate}
|
|
aria-label={i18n._(OPEN_DESCRIPTOR, {contextName})}
|
|
data-flx="user.profile.voice-activity-card.voice-activity-context.context-button.guild-navigate"
|
|
>
|
|
<Tooltip text={guild.name ?? ''} data-flx="user.profile.voice-activity-card.voice-activity-context.tooltip">
|
|
<div data-flx="user.profile.voice-activity-card.voice-activity-context.div">
|
|
<GuildIcon
|
|
id={guild.id}
|
|
name={guild.name}
|
|
icon={guild.icon}
|
|
className={styles.contextGuildIcon}
|
|
sizePx={16}
|
|
data-flx="user.profile.voice-activity-card.voice-activity-context.context-guild-icon"
|
|
/>
|
|
</div>
|
|
</Tooltip>
|
|
<CaretRightIcon
|
|
weight="bold"
|
|
className={styles.contextChevron}
|
|
data-flx="user.profile.voice-activity-card.voice-activity-context.context-chevron"
|
|
/>
|
|
<SpeakerHighIcon
|
|
weight="fill"
|
|
className={styles.contextIcon}
|
|
data-flx="user.profile.voice-activity-card.voice-activity-context.context-icon"
|
|
/>
|
|
<span
|
|
className={styles.contextChannelName}
|
|
data-flx="user.profile.voice-activity-card.voice-activity-context.context-channel-name"
|
|
>
|
|
{channel.name}
|
|
</span>
|
|
</button>
|
|
);
|
|
}
|
|
if (channel.isGroupDM()) {
|
|
const displayName = ChannelUtils.getDMDisplayName(channel);
|
|
return (
|
|
<button
|
|
type="button"
|
|
className={styles.contextButton}
|
|
onClick={handleDMNavigate}
|
|
aria-label={i18n._(OPEN_2_DESCRIPTOR, {displayName})}
|
|
data-flx="user.profile.voice-activity-card.voice-activity-context.context-button.dm-navigate"
|
|
>
|
|
<Tooltip text={displayName} data-flx="user.profile.voice-activity-card.voice-activity-context.tooltip--2">
|
|
<div
|
|
className={styles.contextDmAvatar}
|
|
data-flx="user.profile.voice-activity-card.voice-activity-context.context-dm-avatar"
|
|
>
|
|
<GroupDMAvatar
|
|
channel={channel}
|
|
size={16}
|
|
data-flx="user.profile.voice-activity-card.voice-activity-context.group-dm-avatar"
|
|
/>
|
|
</div>
|
|
</Tooltip>
|
|
<span
|
|
className={styles.contextChannelName}
|
|
data-flx="user.profile.voice-activity-card.voice-activity-context.context-channel-name--2"
|
|
>
|
|
{displayName}
|
|
</span>
|
|
</button>
|
|
);
|
|
}
|
|
if (channel.isDM()) {
|
|
const recipientId = channel.recipientIds.find((id) => id !== voiceState.user_id);
|
|
const recipientUser = recipientId ? Users.getUser(recipientId) : undefined;
|
|
if (recipientUser) {
|
|
const displayName = NicknameUtils.getNickname(recipientUser);
|
|
return (
|
|
<button
|
|
type="button"
|
|
className={styles.contextButton}
|
|
onClick={handleDMNavigate}
|
|
aria-label={i18n._(OPEN_3_DESCRIPTOR, {displayName})}
|
|
data-flx="user.profile.voice-activity-card.voice-activity-context.context-button.dm-navigate--2"
|
|
>
|
|
<Tooltip text={displayName} data-flx="user.profile.voice-activity-card.voice-activity-context.tooltip--3">
|
|
<div
|
|
className={styles.contextDmAvatar}
|
|
data-flx="user.profile.voice-activity-card.voice-activity-context.context-dm-avatar--2"
|
|
>
|
|
<Avatar
|
|
user={recipientUser}
|
|
size={16}
|
|
data-flx="user.profile.voice-activity-card.voice-activity-context.avatar"
|
|
/>
|
|
</div>
|
|
</Tooltip>
|
|
<span
|
|
className={styles.contextChannelName}
|
|
data-flx="user.profile.voice-activity-card.voice-activity-context.context-channel-name--3"
|
|
>
|
|
@{displayName}
|
|
</span>
|
|
</button>
|
|
);
|
|
}
|
|
}
|
|
return null;
|
|
});
|
|
|
|
interface StreamPreviewSectionProps {
|
|
previewUrl: string | null;
|
|
isPreviewLoading: boolean;
|
|
isWatching: boolean;
|
|
isPendingJoin: boolean;
|
|
canWatch: boolean;
|
|
onWatch: (event: React.SyntheticEvent) => void;
|
|
watchLabel: string;
|
|
isSubmitting?: boolean;
|
|
}
|
|
|
|
const StreamPreviewSection: React.FC<StreamPreviewSectionProps> = observer(
|
|
({previewUrl, isPreviewLoading, isWatching, isPendingJoin, canWatch, onWatch, watchLabel, isSubmitting}) => {
|
|
const {i18n} = useLingui();
|
|
const isDisabled = !canWatch || isPendingJoin || isWatching || isSubmitting;
|
|
const handleClick = useCallback(
|
|
(event: React.MouseEvent) => {
|
|
if (isDisabled) return;
|
|
onWatch(event);
|
|
},
|
|
[isDisabled, onWatch],
|
|
);
|
|
return (
|
|
<div
|
|
className={clsx(styles.previewContainer, isSubmitting && styles.previewSubmitting)}
|
|
onClick={handleClick}
|
|
role="button"
|
|
tabIndex={isDisabled ? -1 : 0}
|
|
aria-label={watchLabel}
|
|
aria-disabled={isDisabled}
|
|
onKeyDown={(e) => {
|
|
if (isKeyboardActivationKey(e.key) && !isDisabled) {
|
|
e.preventDefault();
|
|
onWatch(e);
|
|
}
|
|
}}
|
|
data-flx="user.profile.voice-activity-card.stream-preview-section.preview-container.click"
|
|
>
|
|
{previewUrl ? (
|
|
<img
|
|
src={previewUrl}
|
|
alt={i18n._(STREAM_PREVIEW_DESCRIPTOR)}
|
|
className={styles.previewImage}
|
|
data-flx="user.profile.voice-activity-card.stream-preview-section.preview-image"
|
|
/>
|
|
) : (
|
|
<div
|
|
className={styles.previewFallback}
|
|
data-flx="user.profile.voice-activity-card.stream-preview-section.preview-fallback"
|
|
>
|
|
{isPreviewLoading ? (
|
|
<Spinner size="small" data-flx="user.profile.voice-activity-card.stream-preview-section.spinner" />
|
|
) : (
|
|
i18n._(NO_PREVIEW_YET_DESCRIPTOR)
|
|
)}
|
|
</div>
|
|
)}
|
|
{!isDisabled && (
|
|
<div
|
|
className={styles.previewHoverOverlay}
|
|
data-flx="user.profile.voice-activity-card.stream-preview-section.preview-hover-overlay"
|
|
>
|
|
<span
|
|
className={styles.previewHoverText}
|
|
data-flx="user.profile.voice-activity-card.stream-preview-section.preview-hover-text"
|
|
>
|
|
{watchLabel}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
);
|