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

This commit is contained in:
Bryan1029384756
2026-02-20 10:43:52 -06:00
parent e7f10aa5f0
commit 3acd5ca697
27 changed files with 180 additions and 17 deletions

View File

@@ -35,12 +35,16 @@ const webPlatform = {
},
windowControls: null,
updates: null,
voiceService: null,
systemBars: null,
searchDB,
features: {
hasWindowControls: false,
hasScreenCapture: true,
hasNativeUpdates: false,
hasSearch: true,
hasVoiceService: false,
hasSystemBars: false,
},
};
@@ -104,6 +108,34 @@ if (window.Capacitor?.isNativePlatform?.()) {
},
};
webPlatform.features.hasNativeUpdates = true;
webPlatform.features.hasScreenCapture = false;
// Native voice foreground service
const VoiceService = window.Capacitor.Plugins.VoiceService;
if (VoiceService) {
webPlatform.voiceService = {
async startService({ channelName, isMuted, isDeafened }) {
await VoiceService.startService({ channelName, isMuted, isDeafened });
},
async stopService() {
await VoiceService.stopService();
},
async updateNotification(opts) {
await VoiceService.updateNotification(opts);
},
addNotificationActionListener(callback) {
return VoiceService.addListener('voiceNotificationAction', callback);
},
};
webPlatform.features.hasVoiceService = true;
}
// Native system bar coloring
const SystemBars = window.Capacitor.Plugins.SystemBars;
if (SystemBars) {
webPlatform.systemBars = { setColors: (opts) => SystemBars.setColors(opts) };
webPlatform.features.hasSystemBars = true;
}
}
export default webPlatform;

View File

@@ -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;

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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

View File

@@ -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'));

View File

@@ -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}

View File

@@ -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);
}

View File

@@ -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 {

View 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]);
}

View File

@@ -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%;

View File

@@ -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), []);

View File

@@ -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
*/