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

@@ -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:*)"
] ]
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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