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:
@@ -43,7 +43,8 @@
|
|||||||
"Bash(keytool:*)",
|
"Bash(keytool:*)",
|
||||||
"Bash(echo:*)",
|
"Bash(echo:*)",
|
||||||
"Bash(python -c \"import base64; print\\(base64.b64encode\\(open\\(r''C:\\\\Users\\\\bryan\\\\Desktop\\\\Discord Clone\\\\discord-clone-release.keystore'',''rb''\\).read\\(\\)\\).decode\\(\\)\\)\")",
|
"Bash(python -c \"import base64; print\\(base64.b64encode\\(open\\(r''C:\\\\Users\\\\bryan\\\\Desktop\\\\Discord Clone\\\\discord-clone-release.keystore'',''rb''\\).read\\(\\)\\).decode\\(\\)\\)\")",
|
||||||
"WebFetch(domain:gitea.moyettes.com)"
|
"WebFetch(domain:gitea.moyettes.com)",
|
||||||
|
"Bash(grep:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@discord-clone/android",
|
"name": "@discord-clone/android",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.28",
|
"version": "1.0.29",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"cap:sync": "npx cap sync",
|
"cap:sync": "npx cap sync",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@discord-clone/electron",
|
"name": "@discord-clone/electron",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.28",
|
"version": "1.0.29",
|
||||||
"description": "Discord Clone - Electron app",
|
"description": "Discord Clone - Electron app",
|
||||||
"author": "Moyettes",
|
"author": "Moyettes",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -56,12 +56,14 @@ const electronPlatform = {
|
|||||||
updates: {
|
updates: {
|
||||||
checkUpdate: () => window.updateAPI.checkFlatpakUpdate(),
|
checkUpdate: () => window.updateAPI.checkFlatpakUpdate(),
|
||||||
},
|
},
|
||||||
|
systemBars: null,
|
||||||
searchDB,
|
searchDB,
|
||||||
features: {
|
features: {
|
||||||
hasWindowControls: true,
|
hasWindowControls: true,
|
||||||
hasScreenCapture: true,
|
hasScreenCapture: true,
|
||||||
hasNativeUpdates: true,
|
hasNativeUpdates: true,
|
||||||
hasSearch: true,
|
hasSearch: true,
|
||||||
|
hasSystemBars: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@discord-clone/web",
|
"name": "@discord-clone/web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.28",
|
"version": "1.0.29",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -35,12 +35,16 @@ const webPlatform = {
|
|||||||
},
|
},
|
||||||
windowControls: null,
|
windowControls: null,
|
||||||
updates: null,
|
updates: null,
|
||||||
|
voiceService: null,
|
||||||
|
systemBars: null,
|
||||||
searchDB,
|
searchDB,
|
||||||
features: {
|
features: {
|
||||||
hasWindowControls: false,
|
hasWindowControls: false,
|
||||||
hasScreenCapture: true,
|
hasScreenCapture: true,
|
||||||
hasNativeUpdates: false,
|
hasNativeUpdates: false,
|
||||||
hasSearch: true,
|
hasSearch: true,
|
||||||
|
hasVoiceService: false,
|
||||||
|
hasSystemBars: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -104,6 +108,34 @@ if (window.Capacitor?.isNativePlatform?.()) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
webPlatform.features.hasNativeUpdates = true;
|
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;
|
export default webPlatform;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Recovery from './pages/Recovery';
|
|||||||
import Chat from './pages/Chat';
|
import Chat from './pages/Chat';
|
||||||
import { usePlatform } from './platform';
|
import { usePlatform } from './platform';
|
||||||
import { useSearch } from './contexts/SearchContext';
|
import { useSearch } from './contexts/SearchContext';
|
||||||
|
import { useSystemBars } from './hooks/useSystemBars';
|
||||||
|
|
||||||
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ function AuthGuard({ children }) {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { session, settings } = usePlatform();
|
const { session, settings } = usePlatform();
|
||||||
const searchCtx = useSearch();
|
const searchCtx = useSearch();
|
||||||
|
useSystemBars(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
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 React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
|
import ReactDOM from 'react-dom';
|
||||||
import { Virtuoso } from 'react-virtuoso';
|
import { Virtuoso } from 'react-virtuoso';
|
||||||
import { useQuery, usePaginatedQuery, useMutation, useConvex } from 'convex/react';
|
import { useQuery, usePaginatedQuery, useMutation, useConvex } from 'convex/react';
|
||||||
import { api } from '../../../../convex/_generated/api';
|
import { api } from '../../../../convex/_generated/api';
|
||||||
@@ -30,6 +31,7 @@ import ColoredIcon from './ColoredIcon';
|
|||||||
import { usePlatform } from '../platform';
|
import { usePlatform } from '../platform';
|
||||||
import { useVoice } from '../contexts/VoiceContext';
|
import { useVoice } from '../contexts/VoiceContext';
|
||||||
import { useSearch } from '../contexts/SearchContext';
|
import { useSearch } from '../contexts/SearchContext';
|
||||||
|
import { useIsMobile } from '../hooks/useIsMobile';
|
||||||
import { generateUniqueMessage } from '../utils/floodMessages';
|
import { generateUniqueMessage } from '../utils/floodMessages';
|
||||||
|
|
||||||
const SCROLL_DEBUG = true;
|
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 (error) return <div style={{ color: '#ed4245', fontSize: '12px' }}>{error}</div>;
|
||||||
|
|
||||||
if (metadata.mimeType.startsWith('image/')) {
|
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/')) {
|
if (metadata.mimeType.startsWith('video/')) {
|
||||||
const handlePlayClick = () => { setShowControls(true); if (videoRef.current) videoRef.current.play(); };
|
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 ChatArea = ({ channelId, channelName, channelType, username, channelKey, userId: currentUserId, showMembers, onToggleMembers, onOpenDM, showPinned, onTogglePinned, jumpToMessageId, onClearJumpToMessage }) => {
|
||||||
const { crypto } = usePlatform();
|
const { crypto } = usePlatform();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const { isReceivingScreenShareAudio } = useVoice();
|
const { isReceivingScreenShareAudio } = useVoice();
|
||||||
const searchCtx = useSearch();
|
const searchCtx = useSearch();
|
||||||
const [decryptedMessages, setDecryptedMessages] = useState([]);
|
const [decryptedMessages, setDecryptedMessages] = useState([]);
|
||||||
@@ -2234,9 +2237,9 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
isHovered={hoveredMessageId === msg.id}
|
isHovered={hoveredMessageId === msg.id}
|
||||||
editInput={editInput}
|
editInput={editInput}
|
||||||
username={username}
|
username={username}
|
||||||
onHover={() => setHoveredMessageId(msg.id)}
|
onHover={isMobile ? undefined : () => setHoveredMessageId(msg.id)}
|
||||||
onLeave={() => setHoveredMessageId(null)}
|
onLeave={isMobile ? undefined : () => 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 }); }}
|
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); } }}
|
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); }}
|
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) })}
|
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>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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)}>
|
<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()} />
|
<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 && (
|
{profilePopup && (
|
||||||
<UserProfilePopup
|
<UserProfilePopup
|
||||||
|
|||||||
@@ -841,7 +841,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
|||||||
|
|
||||||
// DnD sensors
|
// DnD sensors
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
|
useSensor(PointerSensor, { activationConstraint: { distance: isMobile ? Infinity : 5 } })
|
||||||
);
|
);
|
||||||
|
|
||||||
// Unread tracking
|
// Unread tracking
|
||||||
@@ -1393,7 +1393,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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')) {
|
if (!e.target.closest('.channel-item') && !e.target.closest('.channel-category-header')) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
window.dispatchEvent(new Event('close-context-menus'));
|
window.dispatchEvent(new Event('close-context-menus'));
|
||||||
@@ -1453,7 +1453,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
|||||||
collapsed={collapsedCategories[group.id]}
|
collapsed={collapsedCategories[group.id]}
|
||||||
onToggle={toggleCategory}
|
onToggle={toggleCategory}
|
||||||
onAddChannel={handleAddChannelToCategory}
|
onAddChannel={handleAddChannelToCategory}
|
||||||
onContextMenu={group.id !== '__uncategorized__' ? (e) => {
|
onContextMenu={group.id !== '__uncategorized__' && !isMobile ? (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
window.dispatchEvent(new Event('close-context-menus'));
|
window.dispatchEvent(new Event('close-context-menus'));
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { Track, RoomEvent, ConnectionQuality } from 'livekit-client';
|
import { Track, RoomEvent, ConnectionQuality } from 'livekit-client';
|
||||||
import { useVoice } from '../contexts/VoiceContext';
|
import { useVoice } from '../contexts/VoiceContext';
|
||||||
|
import { usePlatform } from '../platform/PlatformProvider';
|
||||||
import ScreenShareModal from './ScreenShareModal';
|
import ScreenShareModal from './ScreenShareModal';
|
||||||
import Avatar from './Avatar';
|
import Avatar from './Avatar';
|
||||||
import { VideoRenderer, findTrackPubs, useParticipantTrack } from '../utils/streamUtils.jsx';
|
import { VideoRenderer, findTrackPubs, useParticipantTrack } from '../utils/streamUtils.jsx';
|
||||||
@@ -621,6 +622,7 @@ const FocusedStreamView = ({
|
|||||||
const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
|
const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
|
||||||
const [participants, setParticipants] = useState([]);
|
const [participants, setParticipants] = useState([]);
|
||||||
const { isMuted, toggleMute, disconnectVoice, setScreenSharing, connectToVoice, watchingStreamOf, setWatchingStreamOf, isReceivingScreenShareAudio, isReconnecting } = useVoice();
|
const { isMuted, toggleMute, disconnectVoice, setScreenSharing, connectToVoice, watchingStreamOf, setWatchingStreamOf, isReceivingScreenShareAudio, isReconnecting } = useVoice();
|
||||||
|
const { features } = usePlatform();
|
||||||
const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false);
|
const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false);
|
||||||
const [isScreenShareActive, setIsScreenShareActive] = useState(false);
|
const [isScreenShareActive, setIsScreenShareActive] = useState(false);
|
||||||
const screenShareAudioTrackRef = useRef(null);
|
const screenShareAudioTrackRef = useRef(null);
|
||||||
@@ -1039,6 +1041,7 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
|
|||||||
<ColoredIcon src={cameraIcon} color={isCameraOn ? 'black' : 'white'} size="24px" />
|
<ColoredIcon src={cameraIcon} color={isCameraOn ? 'black' : 'white'} size="24px" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{features.hasScreenCapture && (
|
||||||
<button
|
<button
|
||||||
onClick={handleScreenShareClick}
|
onClick={handleScreenShareClick}
|
||||||
title="Share Screen"
|
title="Share Screen"
|
||||||
@@ -1050,6 +1053,7 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
|
|||||||
>
|
>
|
||||||
<ColoredIcon src={screenIcon} color={isScreenShareOn ? 'black' : 'white'} size="24px" />
|
<ColoredIcon src={screenIcon} color={isScreenShareOn ? 'black' : 'white'} size="24px" />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={disconnectVoice}
|
onClick={disconnectVoice}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ function playSoundUrl(url) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const VoiceProvider = ({ children }) => {
|
export const VoiceProvider = ({ children }) => {
|
||||||
const { idle } = usePlatform();
|
const { idle, voiceService } = usePlatform();
|
||||||
const [activeChannelId, setActiveChannelId] = useState(null);
|
const [activeChannelId, setActiveChannelId] = useState(null);
|
||||||
const [activeChannelName, setActiveChannelName] = useState(null);
|
const [activeChannelName, setActiveChannelName] = useState(null);
|
||||||
const [connectionState, setConnectionState] = useState('disconnected');
|
const [connectionState, setConnectionState] = useState('disconnected');
|
||||||
@@ -316,6 +316,8 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
|
|
||||||
setRoom(newRoom);
|
setRoom(newRoom);
|
||||||
setConnectionState('connected');
|
setConnectionState('connected');
|
||||||
|
// Start native foreground service for background voice on Android
|
||||||
|
voiceService?.startService({ channelName, isMuted, isDeafened });
|
||||||
// Play custom join sound if set, otherwise default
|
// Play custom join sound if set, otherwise default
|
||||||
if (myJoinSoundUrl) {
|
if (myJoinSoundUrl) {
|
||||||
playSoundUrl(myJoinSoundUrl);
|
playSoundUrl(myJoinSoundUrl);
|
||||||
@@ -336,6 +338,7 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
setIsMuted(true);
|
setIsMuted(true);
|
||||||
await newRoom.localParticipant.setMicrophoneEnabled(false);
|
await newRoom.localParticipant.setMicrophoneEnabled(false);
|
||||||
await convex.mutation(api.voiceState.updateState, { userId, isMuted: true });
|
await convex.mutation(api.voiceState.updateState, { userId, isMuted: true });
|
||||||
|
voiceService?.updateNotification({ isMuted: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
newRoom.on(RoomEvent.Disconnected, async (reason) => {
|
newRoom.on(RoomEvent.Disconnected, async (reason) => {
|
||||||
@@ -364,6 +367,7 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
voiceService?.stopService();
|
||||||
playSound('leave');
|
playSound('leave');
|
||||||
setConnectionState('disconnected');
|
setConnectionState('disconnected');
|
||||||
setActiveChannelId(null);
|
setActiveChannelId(null);
|
||||||
@@ -427,6 +431,30 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [activeChannelId, convex]);
|
}, [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
|
// Detect when another user moves us to a different voice channel
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const myUserId = localStorage.getItem('userId');
|
const myUserId = localStorage.getItem('userId');
|
||||||
@@ -508,6 +536,7 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
// After server-side move, locally mute
|
// After server-side move, locally mute
|
||||||
setIsMuted(true);
|
setIsMuted(true);
|
||||||
if (room) room.localParticipant.setMicrophoneEnabled(false);
|
if (room) room.localParticipant.setMicrophoneEnabled(false);
|
||||||
|
voiceService?.updateNotification({ isMuted: true });
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('AFK check failed:', e);
|
console.error('AFK check failed:', e);
|
||||||
@@ -691,6 +720,7 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
const disconnectVoice = () => {
|
const disconnectVoice = () => {
|
||||||
console.log('User manually disconnected voice');
|
console.log('User manually disconnected voice');
|
||||||
isDMCallRef.current = false;
|
isDMCallRef.current = false;
|
||||||
|
voiceService?.stopService();
|
||||||
if (room) room.disconnect();
|
if (room) room.disconnect();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -702,6 +732,7 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
const nextState = !isMuted;
|
const nextState = !isMuted;
|
||||||
setIsMuted(nextState);
|
setIsMuted(nextState);
|
||||||
playSound(nextState ? 'mute' : 'unmute');
|
playSound(nextState ? 'mute' : 'unmute');
|
||||||
|
voiceService?.updateNotification({ isMuted: nextState });
|
||||||
if (room) {
|
if (room) {
|
||||||
room.localParticipant.setMicrophoneEnabled(!nextState);
|
room.localParticipant.setMicrophoneEnabled(!nextState);
|
||||||
}
|
}
|
||||||
@@ -712,6 +743,7 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
const nextState = !isDeafened;
|
const nextState = !isDeafened;
|
||||||
setIsDeafened(nextState);
|
setIsDeafened(nextState);
|
||||||
playSound(nextState ? 'deafen' : 'undeafen');
|
playSound(nextState ? 'deafen' : 'undeafen');
|
||||||
|
voiceService?.updateNotification({ isDeafened: nextState });
|
||||||
if (room && !isMuted) {
|
if (room && !isMuted) {
|
||||||
room.localParticipant.setMicrophoneEnabled(!nextState);
|
room.localParticipant.setMicrophoneEnabled(!nextState);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
const EDGE_THRESHOLD = 30;
|
const EDGE_THRESHOLD = 20;
|
||||||
const SNAP_THRESHOLD = 0.4;
|
const SNAP_THRESHOLD = 0.4;
|
||||||
const VELOCITY_THRESHOLD = 0.5;
|
const VELOCITY_THRESHOLD = 0.5;
|
||||||
|
|
||||||
@@ -128,10 +128,10 @@ export function useSwipeNavigation({ enabled, canSwipeToChat }) {
|
|||||||
const absDx = Math.abs(deltaX);
|
const absDx = Math.abs(deltaX);
|
||||||
const absDy = Math.abs(deltaY);
|
const absDy = Math.abs(deltaY);
|
||||||
// Need some movement to decide
|
// Need some movement to decide
|
||||||
if (absDx < 5 && absDy < 5) return;
|
if (absDx < 12 && absDy < 12) return;
|
||||||
|
|
||||||
decided = true;
|
decided = true;
|
||||||
if (absDx > absDy * 1.5) {
|
if (absDx > absDy * 2) {
|
||||||
tracking = true;
|
tracking = true;
|
||||||
setIsSwiping(true);
|
setIsSwiping(true);
|
||||||
} else {
|
} 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;
|
word-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-content img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
/* Markdown Styles Tweaks */
|
/* Markdown Styles Tweaks */
|
||||||
.message-content strong {
|
.message-content strong {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -3371,6 +3376,21 @@ body {
|
|||||||
width: calc(100vw - 32px);
|
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 */
|
/* Chat container takes full width */
|
||||||
.is-mobile .chat-container {
|
.is-mobile .chat-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { useSwipeNavigation } from '../hooks/useSwipeNavigation';
|
|||||||
import IncomingCallUI from '../components/IncomingCallUI';
|
import IncomingCallUI from '../components/IncomingCallUI';
|
||||||
import Avatar from '../components/Avatar';
|
import Avatar from '../components/Avatar';
|
||||||
import callRingSound from '../assets/sounds/default_call_sound.mp3';
|
import callRingSound from '../assets/sounds/default_call_sound.mp3';
|
||||||
|
import { useSystemBars } from '../hooks/useSystemBars';
|
||||||
|
|
||||||
const MAX_SEARCH_HISTORY = 10;
|
const MAX_SEARCH_HISTORY = 10;
|
||||||
|
|
||||||
@@ -44,6 +45,8 @@ const Chat = () => {
|
|||||||
canSwipeToChat: activeChannel !== null || activeDMChannel !== null || view === 'me'
|
canSwipeToChat: activeChannel !== null || activeDMChannel !== null || view === 'me'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useSystemBars(mobileView);
|
||||||
|
|
||||||
// Jump-to-message state (for search result clicks)
|
// Jump-to-message state (for search result clicks)
|
||||||
const [jumpToMessageId, setJumpToMessageId] = useState(null);
|
const [jumpToMessageId, setJumpToMessageId] = useState(null);
|
||||||
const clearJumpToMessage = useCallback(() => setJumpToMessageId(null), []);
|
const clearJumpToMessage = useCallback(() => setJumpToMessageId(null), []);
|
||||||
|
|||||||
@@ -71,12 +71,27 @@
|
|||||||
* @property {() => object} getStats
|
* @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
|
* @typedef {Object} PlatformFeatures
|
||||||
* @property {boolean} hasWindowControls
|
* @property {boolean} hasWindowControls
|
||||||
* @property {boolean} hasScreenCapture
|
* @property {boolean} hasScreenCapture
|
||||||
* @property {boolean} hasNativeUpdates
|
* @property {boolean} hasNativeUpdates
|
||||||
* @property {boolean} hasSearch
|
* @property {boolean} hasSearch
|
||||||
|
* @property {boolean} hasVoiceService
|
||||||
|
* @property {boolean} hasSystemBars
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -90,6 +105,8 @@
|
|||||||
* @property {PlatformWindowControls|null} windowControls
|
* @property {PlatformWindowControls|null} windowControls
|
||||||
* @property {PlatformUpdates|null} updates
|
* @property {PlatformUpdates|null} updates
|
||||||
* @property {PlatformSearchDB|null} searchDB
|
* @property {PlatformSearchDB|null} searchDB
|
||||||
|
* @property {PlatformVoiceService|null} voiceService
|
||||||
|
* @property {PlatformSystemBars|null} systemBars
|
||||||
* @property {PlatformFeatures} features
|
* @property {PlatformFeatures} features
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user