feat: add MobileVoiceJoinSheet bottom sheet for voice preview
This commit is contained in:
@@ -16,7 +16,9 @@ import { TransferOwnershipModal } from '../modals/TransferOwnershipModal';
|
|||||||
import { MobileFolderSheet } from './MobileFolderSheet';
|
import { MobileFolderSheet } from './MobileFolderSheet';
|
||||||
import { useInstanceStore } from '../../stores/instanceStore';
|
import { useInstanceStore } from '../../stores/instanceStore';
|
||||||
import { VoiceUserRow } from '../voice/VoiceUserRow';
|
import { VoiceUserRow } from '../voice/VoiceUserRow';
|
||||||
|
import { MobileVoiceJoinSheet } from '../voice/MobileVoiceJoinSheet';
|
||||||
import { buildVoiceModMenuItems, VolumeSliderItem } from '../voice/voiceMenuItems';
|
import { buildVoiceModMenuItems, VolumeSliderItem } from '../voice/voiceMenuItems';
|
||||||
|
import { joinVoiceChannel } from '../../utils/voice';
|
||||||
|
|
||||||
type ResolvedItem =
|
type ResolvedItem =
|
||||||
| { type: 'space'; space: TaggedSpace }
|
| { type: 'space'; space: TaggedSpace }
|
||||||
@@ -192,6 +194,12 @@ export function MobileSpacesScreen() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleVoiceJoin = useCallback((chId: string, _preMuted: boolean) => {
|
||||||
|
joinVoiceChannel(chId);
|
||||||
|
setVoiceJoinChannelId(null);
|
||||||
|
pushMobileScreen('voice');
|
||||||
|
}, [pushMobileScreen]);
|
||||||
|
|
||||||
const toggleCategory = (categoryId: string) => {
|
const toggleCategory = (categoryId: string) => {
|
||||||
setCollapsedCategories(prev => {
|
setCollapsedCategories(prev => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -908,6 +916,16 @@ export function MobileSpacesScreen() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
|
{voiceJoinChannelId && selectedSpaceId && (
|
||||||
|
<MobileVoiceJoinSheet
|
||||||
|
channelId={voiceJoinChannelId}
|
||||||
|
channelName={channels.find(c => c.id === voiceJoinChannelId)?.name || ''}
|
||||||
|
spaceId={selectedSpaceId}
|
||||||
|
onClose={() => setVoiceJoinChannelId(null)}
|
||||||
|
onJoin={handleVoiceJoin}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
|
import { useSpaceStore } from '../../stores/spaceStore';
|
||||||
|
import { VoiceUserRow } from './VoiceUserRow';
|
||||||
|
|
||||||
|
interface MobileVoiceJoinSheetProps {
|
||||||
|
channelId: string;
|
||||||
|
channelName: string;
|
||||||
|
spaceId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onJoin: (channelId: string, preMuted: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileVoiceJoinSheet({
|
||||||
|
channelId,
|
||||||
|
channelName,
|
||||||
|
spaceId,
|
||||||
|
onClose,
|
||||||
|
onJoin,
|
||||||
|
}: MobileVoiceJoinSheetProps) {
|
||||||
|
const [preMuted, setPreMuted] = useState(false);
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
const voiceUsers = useVoiceStore((s) => s.voiceUsers);
|
||||||
|
const voiceUserStates = useVoiceStore((s) => s.voiceUserStates);
|
||||||
|
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
|
||||||
|
const spaceMutedUserIds = useVoiceStore((s) => s.spaceMutedUserIds);
|
||||||
|
const spaceDeafenedUserIds = useVoiceStore((s) => s.spaceDeafenedUserIds);
|
||||||
|
const permissionMutedUserIds = useVoiceStore((s) => s.permissionMutedUserIds);
|
||||||
|
const speakingUserIds = useVoiceStore((s) => s.speakingUserIds);
|
||||||
|
|
||||||
|
const members = useSpaceStore((s) => s.members);
|
||||||
|
const channels = useSpaceStore((s) => s.channels);
|
||||||
|
|
||||||
|
// Trigger slide-up animation on mount
|
||||||
|
useEffect(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setVisible(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const userIds = useMemo(() => voiceUsers.get(channelId) || [], [voiceUsers, channelId]);
|
||||||
|
|
||||||
|
const userCount = userIds.length;
|
||||||
|
const userCountLabel = userCount === 1 ? '1 Person in Voice' : `${userCount} People in Voice`;
|
||||||
|
|
||||||
|
// Determine if switching channels
|
||||||
|
const isSwitching = currentVoiceChannelId !== null && currentVoiceChannelId !== channelId;
|
||||||
|
const currentChannelName = useMemo(() => {
|
||||||
|
if (!currentVoiceChannelId) return '';
|
||||||
|
const ch = channels.find((c) => c.id === currentVoiceChannelId);
|
||||||
|
return ch?.name || '';
|
||||||
|
}, [currentVoiceChannelId, channels]);
|
||||||
|
|
||||||
|
const handleJoin = useCallback(() => {
|
||||||
|
onJoin(channelId, preMuted);
|
||||||
|
}, [channelId, preMuted, onJoin]);
|
||||||
|
|
||||||
|
const handleBackdropClick = useCallback(() => {
|
||||||
|
onClose();
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 z-50"
|
||||||
|
onClick={handleBackdropClick}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Sheet container */}
|
||||||
|
<div
|
||||||
|
className={`glass-bubble fixed bottom-0 left-0 right-0 z-50 rounded-t-2xl transition-transform duration-300 ease-out ${
|
||||||
|
visible ? 'translate-y-0' : 'translate-y-full'
|
||||||
|
}`}
|
||||||
|
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
|
||||||
|
>
|
||||||
|
{/* Drag handle */}
|
||||||
|
<div className="w-10 h-1 rounded-full bg-white/20 mx-auto mt-3 mb-4" />
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-5 mb-2">
|
||||||
|
<h2 className="text-base font-bold text-txt-primary truncate">{channelName}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User count */}
|
||||||
|
{userCount > 0 && (
|
||||||
|
<p className="text-sm text-txt-tertiary px-5 mb-3">{userCountLabel}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Channel switch warning */}
|
||||||
|
{isSwitching && (
|
||||||
|
<div className="mx-5 mb-3 px-3 py-2 rounded-lg bg-accent-amber/10 text-accent-amber text-xs">
|
||||||
|
You'll leave <span className="font-semibold">{currentChannelName}</span> and join{' '}
|
||||||
|
<span className="font-semibold">{channelName}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User list */}
|
||||||
|
{userCount > 0 && (
|
||||||
|
<div className="max-h-60 overflow-y-auto px-5 mb-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{userIds.map((userId) => {
|
||||||
|
const member = members.find((m) => m.userId === userId);
|
||||||
|
const displayName =
|
||||||
|
member?.user.displayName ?? member?.user.username ?? userId;
|
||||||
|
const avatar = member?.user.avatar ?? null;
|
||||||
|
const avatarColor = member?.user.avatarColor;
|
||||||
|
const wsStatus = voiceUserStates.get(userId);
|
||||||
|
const isMuted = wsStatus?.isMuted ?? false;
|
||||||
|
const isDeafened = wsStatus?.isDeafened ?? false;
|
||||||
|
const isCameraOn = wsStatus?.isCameraOn ?? false;
|
||||||
|
const isScreenSharing = wsStatus?.isScreenSharing ?? false;
|
||||||
|
const isSpaceMuted = spaceMutedUserIds.has(`${spaceId}:${userId}`);
|
||||||
|
const isSpaceDeafened = spaceDeafenedUserIds.has(`${spaceId}:${userId}`);
|
||||||
|
const isPermMuted = permissionMutedUserIds.has(`${spaceId}:${userId}`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={userId} className="py-1.5 rounded-lg">
|
||||||
|
<VoiceUserRow
|
||||||
|
userId={member?.user.homeUserId ?? userId}
|
||||||
|
displayName={displayName}
|
||||||
|
avatar={avatar}
|
||||||
|
avatarColor={avatarColor ?? undefined}
|
||||||
|
isMuted={isMuted}
|
||||||
|
isDeafened={isDeafened}
|
||||||
|
isCameraOn={isCameraOn}
|
||||||
|
isScreenSharing={isScreenSharing}
|
||||||
|
isServerMuted={isSpaceMuted}
|
||||||
|
isServerDeafened={isSpaceDeafened}
|
||||||
|
isPermissionMuted={isPermMuted}
|
||||||
|
isSpeaking={speakingUserIds.has(userId)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{userCount === 0 && (
|
||||||
|
<div className="px-5 mb-4 py-6 text-center">
|
||||||
|
<p className="text-sm text-txt-tertiary">No one is in this channel yet.</p>
|
||||||
|
<p className="text-xs text-txt-tertiary/60 mt-1">Be the first to join!</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bottom action bar */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
|
{/* Mic toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => setPreMuted(!preMuted)}
|
||||||
|
className="w-12 h-12 bg-surface-elevated rounded-full flex items-center justify-center text-txt-secondary active:scale-95 transition-transform"
|
||||||
|
aria-label={preMuted ? 'Unmute microphone' : 'Mute microphone'}
|
||||||
|
>
|
||||||
|
{preMuted ? (
|
||||||
|
/* Mic off icon */
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor" className="text-txt-danger">
|
||||||
|
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
|
||||||
|
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
|
||||||
|
<line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
/* Mic on icon */
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor" className="text-txt-primary">
|
||||||
|
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
|
||||||
|
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Join Voice button */}
|
||||||
|
<button
|
||||||
|
onClick={handleJoin}
|
||||||
|
className="bg-accent-mint text-black font-semibold rounded-full px-8 py-3 active:scale-95 transition-transform"
|
||||||
|
>
|
||||||
|
{isSwitching ? 'Switch Channel' : 'Join Voice'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Chat button — navigates to associated text channel (placeholder for future) */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-12 h-12 bg-surface-elevated rounded-full flex items-center justify-center text-txt-secondary active:scale-95 transition-transform"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user