feat: Implement voice and video calling features including participant tiles, screen sharing, and audio controls with associated UI components and sound assets.
All checks were successful
Build and Release / build-and-release (push) Successful in 13m4s
All checks were successful
Build and Release / build-and-release (push) Successful in 13m4s
This commit is contained in:
@@ -6,6 +6,7 @@ import Recovery from './pages/Recovery';
|
||||
import Chat from './pages/Chat';
|
||||
import { usePlatform } from './platform';
|
||||
import { useSearch } from './contexts/SearchContext';
|
||||
import { useSystemBars } from './hooks/useSystemBars';
|
||||
|
||||
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -15,6 +16,7 @@ function AuthGuard({ children }) {
|
||||
const navigate = useNavigate();
|
||||
const { session, settings } = usePlatform();
|
||||
const searchCtx = useSearch();
|
||||
useSystemBars(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
BIN
packages/shared/src/assets/sounds/camera_off.mp3
Normal file
BIN
packages/shared/src/assets/sounds/camera_off.mp3
Normal file
Binary file not shown.
BIN
packages/shared/src/assets/sounds/camera_on.mp3
Normal file
BIN
packages/shared/src/assets/sounds/camera_on.mp3
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
packages/shared/src/assets/sounds/outgoing_ring.mp3
Normal file
BIN
packages/shared/src/assets/sounds/outgoing_ring.mp3
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
packages/shared/src/assets/sounds/user_moved.mp3
Normal file
BIN
packages/shared/src/assets/sounds/user_moved.mp3
Normal file
Binary file not shown.
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import { useQuery, usePaginatedQuery, useMutation, useConvex } from 'convex/react';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
@@ -30,6 +31,7 @@ import ColoredIcon from './ColoredIcon';
|
||||
import { usePlatform } from '../platform';
|
||||
import { useVoice } from '../contexts/VoiceContext';
|
||||
import { useSearch } from '../contexts/SearchContext';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { generateUniqueMessage } from '../utils/floodMessages';
|
||||
|
||||
const SCROLL_DEBUG = true;
|
||||
@@ -340,7 +342,7 @@ const Attachment = ({ metadata, onLoad, onImageClick }) => {
|
||||
if (error) return <div style={{ color: '#ed4245', fontSize: '12px' }}>{error}</div>;
|
||||
|
||||
if (metadata.mimeType.startsWith('image/')) {
|
||||
return <img src={url} alt={metadata.filename} draggable="false" style={{ maxHeight: '300px', borderRadius: '4px', cursor: 'zoom-in' }} onLoad={onLoad} onClick={() => onImageClick(url)} />;
|
||||
return <img src={url} alt={metadata.filename} draggable="false" style={{ maxWidth: '100%', maxHeight: '300px', borderRadius: '4px', cursor: 'zoom-in' }} onLoad={onLoad} onClick={() => onImageClick(url)} />;
|
||||
}
|
||||
if (metadata.mimeType.startsWith('video/')) {
|
||||
const handlePlayClick = () => { setShowControls(true); if (videoRef.current) videoRef.current.play(); };
|
||||
@@ -510,6 +512,7 @@ const InputContextMenu = ({ x, y, onClose, onPaste }) => {
|
||||
|
||||
const ChatArea = ({ channelId, channelName, channelType, username, channelKey, userId: currentUserId, showMembers, onToggleMembers, onOpenDM, showPinned, onTogglePinned, jumpToMessageId, onClearJumpToMessage }) => {
|
||||
const { crypto } = usePlatform();
|
||||
const isMobile = useIsMobile();
|
||||
const { isReceivingScreenShareAudio } = useVoice();
|
||||
const searchCtx = useSearch();
|
||||
const [decryptedMessages, setDecryptedMessages] = useState([]);
|
||||
@@ -2234,9 +2237,9 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
isHovered={hoveredMessageId === msg.id}
|
||||
editInput={editInput}
|
||||
username={username}
|
||||
onHover={() => setHoveredMessageId(msg.id)}
|
||||
onLeave={() => setHoveredMessageId(null)}
|
||||
onContextMenu={(e) => { e.preventDefault(); window.dispatchEvent(new Event('close-context-menus')); setContextMenu({ x: e.clientX, y: e.clientY, messageId: msg.id, isOwner, isAttachment: !!parseAttachment(msg.content), canDelete }); }}
|
||||
onHover={isMobile ? undefined : () => setHoveredMessageId(msg.id)}
|
||||
onLeave={isMobile ? undefined : () => setHoveredMessageId(null)}
|
||||
onContextMenu={isMobile ? undefined : (e) => { e.preventDefault(); window.dispatchEvent(new Event('close-context-menus')); setContextMenu({ x: e.clientX, y: e.clientY, messageId: msg.id, isOwner, isAttachment: !!parseAttachment(msg.content), canDelete }); }}
|
||||
onAddReaction={(emoji) => { if (emoji) { addReaction({ messageId: msg.id, userId: currentUserId, emoji }); } else { setReactionPickerMsgId(reactionPickerMsgId === msg.id ? null : msg.id); } }}
|
||||
onEdit={() => { setEditingMessage({ id: msg.id, content: msg.content }); setEditInput(msg.content); }}
|
||||
onReply={() => setReplyingTo({ messageId: msg.id, username: msg.username, content: msg.content?.substring(0, 100) })}
|
||||
@@ -2481,10 +2484,11 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{zoomedImage && (
|
||||
{zoomedImage && ReactDOM.createPortal(
|
||||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0, 0, 0, 0.85)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'zoom-out' }} onClick={() => setZoomedImage(null)}>
|
||||
<img src={zoomedImage} alt="Zoomed" style={{ maxWidth: '90%', maxHeight: '90%', boxShadow: '0 8px 16px rgba(0,0,0,0.5)', borderRadius: '4px', cursor: 'default' }} onClick={(e) => e.stopPropagation()} />
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
{profilePopup && (
|
||||
<UserProfilePopup
|
||||
|
||||
@@ -841,7 +841,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
|
||||
// DnD sensors
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: isMobile ? Infinity : 5 } })
|
||||
);
|
||||
|
||||
// Unread tracking
|
||||
@@ -1393,7 +1393,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="channel-list" style={{ flex: 1, overflowY: 'auto', backgroundColor: 'var(--bg-tertiary)' }} onContextMenu={(e) => {
|
||||
<div className="channel-list" style={{ flex: 1, overflowY: 'auto', backgroundColor: 'var(--bg-tertiary)' }} onContextMenu={isMobile ? undefined : (e) => {
|
||||
if (!e.target.closest('.channel-item') && !e.target.closest('.channel-category-header')) {
|
||||
e.preventDefault();
|
||||
window.dispatchEvent(new Event('close-context-menus'));
|
||||
@@ -1453,7 +1453,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
collapsed={collapsedCategories[group.id]}
|
||||
onToggle={toggleCategory}
|
||||
onAddChannel={handleAddChannelToCategory}
|
||||
onContextMenu={group.id !== '__uncategorized__' ? (e) => {
|
||||
onContextMenu={group.id !== '__uncategorized__' && !isMobile ? (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.dispatchEvent(new Event('close-context-menus'));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Track, RoomEvent, ConnectionQuality } from 'livekit-client';
|
||||
import { useVoice } from '../contexts/VoiceContext';
|
||||
import { usePlatform } from '../platform/PlatformProvider';
|
||||
import ScreenShareModal from './ScreenShareModal';
|
||||
import Avatar from './Avatar';
|
||||
import { VideoRenderer, findTrackPubs, useParticipantTrack } from '../utils/streamUtils.jsx';
|
||||
@@ -621,6 +622,7 @@ const FocusedStreamView = ({
|
||||
const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
|
||||
const [participants, setParticipants] = useState([]);
|
||||
const { isMuted, toggleMute, disconnectVoice, setScreenSharing, connectToVoice, watchingStreamOf, setWatchingStreamOf, isReceivingScreenShareAudio, isReconnecting } = useVoice();
|
||||
const { features } = usePlatform();
|
||||
const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false);
|
||||
const [isScreenShareActive, setIsScreenShareActive] = useState(false);
|
||||
const screenShareAudioTrackRef = useRef(null);
|
||||
@@ -1039,6 +1041,7 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
|
||||
<ColoredIcon src={cameraIcon} color={isCameraOn ? 'black' : 'white'} size="24px" />
|
||||
</button>
|
||||
|
||||
{features.hasScreenCapture && (
|
||||
<button
|
||||
onClick={handleScreenShareClick}
|
||||
title="Share Screen"
|
||||
@@ -1050,6 +1053,7 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
|
||||
>
|
||||
<ColoredIcon src={screenIcon} color={isScreenShareOn ? 'black' : 'white'} size="24px" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={disconnectVoice}
|
||||
|
||||
@@ -50,7 +50,7 @@ function playSoundUrl(url) {
|
||||
}
|
||||
|
||||
export const VoiceProvider = ({ children }) => {
|
||||
const { idle } = usePlatform();
|
||||
const { idle, voiceService } = usePlatform();
|
||||
const [activeChannelId, setActiveChannelId] = useState(null);
|
||||
const [activeChannelName, setActiveChannelName] = useState(null);
|
||||
const [connectionState, setConnectionState] = useState('disconnected');
|
||||
@@ -316,6 +316,8 @@ export const VoiceProvider = ({ children }) => {
|
||||
|
||||
setRoom(newRoom);
|
||||
setConnectionState('connected');
|
||||
// Start native foreground service for background voice on Android
|
||||
voiceService?.startService({ channelName, isMuted, isDeafened });
|
||||
// Play custom join sound if set, otherwise default
|
||||
if (myJoinSoundUrl) {
|
||||
playSoundUrl(myJoinSoundUrl);
|
||||
@@ -336,6 +338,7 @@ export const VoiceProvider = ({ children }) => {
|
||||
setIsMuted(true);
|
||||
await newRoom.localParticipant.setMicrophoneEnabled(false);
|
||||
await convex.mutation(api.voiceState.updateState, { userId, isMuted: true });
|
||||
voiceService?.updateNotification({ isMuted: true });
|
||||
}
|
||||
|
||||
newRoom.on(RoomEvent.Disconnected, async (reason) => {
|
||||
@@ -364,6 +367,7 @@ export const VoiceProvider = ({ children }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
voiceService?.stopService();
|
||||
playSound('leave');
|
||||
setConnectionState('disconnected');
|
||||
setActiveChannelId(null);
|
||||
@@ -427,6 +431,30 @@ export const VoiceProvider = ({ children }) => {
|
||||
return () => clearInterval(interval);
|
||||
}, [activeChannelId, convex]);
|
||||
|
||||
// Handle notification action buttons (Android foreground service)
|
||||
useEffect(() => {
|
||||
if (!voiceService) return;
|
||||
const listener = voiceService.addNotificationActionListener((event) => {
|
||||
switch (event.action) {
|
||||
case 'disconnect':
|
||||
disconnectVoice();
|
||||
break;
|
||||
case 'toggleMute':
|
||||
toggleMute();
|
||||
break;
|
||||
case 'toggleDeafen':
|
||||
toggleDeafen();
|
||||
break;
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
if (listener && listener.remove) listener.remove();
|
||||
else if (listener && typeof listener.then === 'function') {
|
||||
listener.then(l => l?.remove?.());
|
||||
}
|
||||
};
|
||||
}, [voiceService, activeChannelId]);
|
||||
|
||||
// Detect when another user moves us to a different voice channel
|
||||
useEffect(() => {
|
||||
const myUserId = localStorage.getItem('userId');
|
||||
@@ -508,6 +536,7 @@ export const VoiceProvider = ({ children }) => {
|
||||
// After server-side move, locally mute
|
||||
setIsMuted(true);
|
||||
if (room) room.localParticipant.setMicrophoneEnabled(false);
|
||||
voiceService?.updateNotification({ isMuted: true });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('AFK check failed:', e);
|
||||
@@ -691,6 +720,7 @@ export const VoiceProvider = ({ children }) => {
|
||||
const disconnectVoice = () => {
|
||||
console.log('User manually disconnected voice');
|
||||
isDMCallRef.current = false;
|
||||
voiceService?.stopService();
|
||||
if (room) room.disconnect();
|
||||
};
|
||||
|
||||
@@ -702,6 +732,7 @@ export const VoiceProvider = ({ children }) => {
|
||||
const nextState = !isMuted;
|
||||
setIsMuted(nextState);
|
||||
playSound(nextState ? 'mute' : 'unmute');
|
||||
voiceService?.updateNotification({ isMuted: nextState });
|
||||
if (room) {
|
||||
room.localParticipant.setMicrophoneEnabled(!nextState);
|
||||
}
|
||||
@@ -712,6 +743,7 @@ export const VoiceProvider = ({ children }) => {
|
||||
const nextState = !isDeafened;
|
||||
setIsDeafened(nextState);
|
||||
playSound(nextState ? 'deafen' : 'undeafen');
|
||||
voiceService?.updateNotification({ isDeafened: nextState });
|
||||
if (room && !isMuted) {
|
||||
room.localParticipant.setMicrophoneEnabled(!nextState);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
|
||||
const EDGE_THRESHOLD = 30;
|
||||
const EDGE_THRESHOLD = 20;
|
||||
const SNAP_THRESHOLD = 0.4;
|
||||
const VELOCITY_THRESHOLD = 0.5;
|
||||
|
||||
@@ -128,10 +128,10 @@ export function useSwipeNavigation({ enabled, canSwipeToChat }) {
|
||||
const absDx = Math.abs(deltaX);
|
||||
const absDy = Math.abs(deltaY);
|
||||
// Need some movement to decide
|
||||
if (absDx < 5 && absDy < 5) return;
|
||||
if (absDx < 12 && absDy < 12) return;
|
||||
|
||||
decided = true;
|
||||
if (absDx > absDy * 1.5) {
|
||||
if (absDx > absDy * 2) {
|
||||
tracking = true;
|
||||
setIsSwiping(true);
|
||||
} else {
|
||||
|
||||
46
packages/shared/src/hooks/useSystemBars.js
Normal file
46
packages/shared/src/hooks/useSystemBars.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { usePlatform } from '../platform';
|
||||
|
||||
const COLOR_TABLE = {
|
||||
'theme-dark': { auth: '#1e1f22', sidebar: '#141318', channelPanel: '#1C1D22', chat: '#1C1D22', isDark: false },
|
||||
'theme-light': { auth: '#e3e5e8', sidebar: '#e3e5e8', channelPanel: '#e3e5e8', chat: '#ffffff', isDark: true },
|
||||
'theme-darker': { auth: '#121214', sidebar: '#121214', channelPanel: '#121214', chat: '#202225', isDark: false },
|
||||
'theme-midnight':{ auth: '#000000', sidebar: '#000000', channelPanel: '#000000', chat: '#0c0c14', isDark: false },
|
||||
};
|
||||
|
||||
function getBarColors(theme, pathname, mobileView) {
|
||||
const entry = COLOR_TABLE[theme] || COLOR_TABLE['theme-dark'];
|
||||
|
||||
// Auth pages
|
||||
if (pathname === '/' || pathname === '/register' || pathname === '/recovery') {
|
||||
return { statusBarColor: entry.auth, navigationBarColor: entry.auth, isDarkContent: entry.isDark };
|
||||
}
|
||||
|
||||
// Chat page — pick based on which mobile panel is active
|
||||
if (mobileView === 'sidebar') {
|
||||
return { statusBarColor: entry.sidebar, navigationBarColor: entry.channelPanel, isDarkContent: entry.isDark };
|
||||
}
|
||||
|
||||
return { statusBarColor: entry.chat, navigationBarColor: entry.chat, isDarkContent: entry.isDark };
|
||||
}
|
||||
|
||||
export function useSystemBars(mobileView) {
|
||||
const { theme } = useTheme();
|
||||
const location = useLocation();
|
||||
const platform = usePlatform();
|
||||
const lastRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!platform.features.hasSystemBars) return;
|
||||
|
||||
const colors = getBarColors(theme, location.pathname, mobileView);
|
||||
const key = `${colors.statusBarColor}|${colors.navigationBarColor}|${colors.isDarkContent}`;
|
||||
|
||||
if (lastRef.current === key) return;
|
||||
lastRef.current = key;
|
||||
|
||||
platform.systemBars.setColors(colors);
|
||||
}, [theme, location.pathname, mobileView, platform]);
|
||||
}
|
||||
@@ -367,6 +367,11 @@ body {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.message-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Markdown Styles Tweaks */
|
||||
.message-content strong {
|
||||
font-weight: 700;
|
||||
@@ -3371,6 +3376,21 @@ body {
|
||||
width: calc(100vw - 32px);
|
||||
}
|
||||
|
||||
/* Constrain images/videos/embeds in messages to fit mobile width */
|
||||
.message-content img,
|
||||
.message-content video {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.link-preview {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.youtube-video-wrapper {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Chat container takes full width */
|
||||
.is-mobile .chat-container {
|
||||
width: 100%;
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useSwipeNavigation } from '../hooks/useSwipeNavigation';
|
||||
import IncomingCallUI from '../components/IncomingCallUI';
|
||||
import Avatar from '../components/Avatar';
|
||||
import callRingSound from '../assets/sounds/default_call_sound.mp3';
|
||||
import { useSystemBars } from '../hooks/useSystemBars';
|
||||
|
||||
const MAX_SEARCH_HISTORY = 10;
|
||||
|
||||
@@ -44,6 +45,8 @@ const Chat = () => {
|
||||
canSwipeToChat: activeChannel !== null || activeDMChannel !== null || view === 'me'
|
||||
});
|
||||
|
||||
useSystemBars(mobileView);
|
||||
|
||||
// Jump-to-message state (for search result clicks)
|
||||
const [jumpToMessageId, setJumpToMessageId] = useState(null);
|
||||
const clearJumpToMessage = useCallback(() => setJumpToMessageId(null), []);
|
||||
|
||||
@@ -71,12 +71,27 @@
|
||||
* @property {() => object} getStats
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PlatformVoiceService
|
||||
* @property {(opts: {channelName: string, isMuted: boolean, isDeafened: boolean}) => Promise<void>} startService
|
||||
* @property {() => Promise<void>} stopService
|
||||
* @property {(opts: {channelName?: string, isMuted?: boolean, isDeafened?: boolean}) => Promise<void>} updateNotification
|
||||
* @property {(callback: function) => Promise<{remove: function}>} addNotificationActionListener
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PlatformSystemBars
|
||||
* @property {(opts: {statusBarColor: string, navigationBarColor: string, isDarkContent: boolean}) => Promise<void>} setColors
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PlatformFeatures
|
||||
* @property {boolean} hasWindowControls
|
||||
* @property {boolean} hasScreenCapture
|
||||
* @property {boolean} hasNativeUpdates
|
||||
* @property {boolean} hasSearch
|
||||
* @property {boolean} hasVoiceService
|
||||
* @property {boolean} hasSystemBars
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -90,6 +105,8 @@
|
||||
* @property {PlatformWindowControls|null} windowControls
|
||||
* @property {PlatformUpdates|null} updates
|
||||
* @property {PlatformSearchDB|null} searchDB
|
||||
* @property {PlatformVoiceService|null} voiceService
|
||||
* @property {PlatformSystemBars|null} systemBars
|
||||
* @property {PlatformFeatures} features
|
||||
*/
|
||||
|
||||
|
||||
Reference in New Issue
Block a user