feat(CallFeature): implement call functionality and integrate WebSocket signaling
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 5m2s
Frontend CI / ota-android (push) Successful in 11m28s
Frontend CI / build-android-apk (push) Successful in 1h0m58s

- Updated app.json to include microphone permissions and background audio support.
- Added call-related dependencies in package.json and package-lock.json.
- Enhanced ChatScreen to initiate calls and handle call actions.
- Introduced call components in the layout for incoming call handling.
- Expanded WebSocket service to manage call signaling messages and events.
- Refactored authStore to initialize call state on user authentication.
This commit is contained in:
lafay
2026-03-27 17:12:19 +08:00
parent db7885086f
commit b19a2ced6f
18 changed files with 2086 additions and 18 deletions

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "萝卜社区",
"slug": "qojo",
"version": "1.0.11",
"version": "1.0.12",
"orientation": "default",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
@@ -15,11 +15,13 @@
"ios": {
"supportsTablet": true,
"infoPlist": {
"NSMicrophoneUsageDescription": "允许萝卜社区访问您的麦克风以进行语音通话",
"UIBackgroundModes": [
"fetch",
"remote-notification",
"fetch",
"remote-notification"
"remote-notification",
"audio"
],
"ITSAppUsesNonExemptEncryption": false
},
@@ -37,6 +39,7 @@
"versionCode": 6,
"permissions": [
"VIBRATE",
"RECORD_AUDIO",
"RECEIVE_BOOT_COMPLETED",
"WAKE_LOCK",
"READ_EXTERNAL_STORAGE",

View File

@@ -22,6 +22,7 @@ import AppDialogHost from '../src/components/common/AppDialogHost';
import { installAlertOverride } from '../src/services/alertOverride';
import { useAuthStore } from '../src/stores';
import { checkForAPKUpdate } from '../src/services/apkUpdateService';
import { CallScreen, IncomingCallModal, FloatingCallWindow } from '../src/components/call';
registerNotificationPresentationHandler();
@@ -219,6 +220,9 @@ export default function RootLayout() {
<ThemedStack />
<AppPromptBar />
<AppDialogHost />
<IncomingCallModal />
<CallScreen />
<FloatingCallWindow />
</QueryClientProvider>
</ThemedProviders>
</SafeAreaProvider>

50
package-lock.json generated
View File

@@ -54,6 +54,7 @@
"react-native-screens": "~4.23.0",
"react-native-sse": "^1.2.1",
"react-native-web": "^0.21.0",
"react-native-webrtc": "^124.0.7",
"react-native-webview": "13.16.0",
"react-native-worklets": "0.7.2",
"zod": "^4.3.6",
@@ -9799,6 +9800,55 @@
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
"license": "MIT"
},
"node_modules/react-native-webrtc": {
"version": "124.0.7",
"resolved": "https://registry.npmmirror.com/react-native-webrtc/-/react-native-webrtc-124.0.7.tgz",
"integrity": "sha512-gnXPdbUS8IkKHq9WNaWptW/yy5s6nMyI6cNn90LXdobPVCgYSk6NA2uUGdT4c4J14BRgaFA95F+cR28tUPkMVA==",
"license": "MIT",
"dependencies": {
"base64-js": "1.5.1",
"debug": "4.3.4",
"event-target-shim": "6.0.2"
},
"peerDependencies": {
"react-native": ">=0.60.0"
}
},
"node_modules/react-native-webrtc/node_modules/debug": {
"version": "4.3.4",
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.3.4.tgz",
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
"license": "MIT",
"dependencies": {
"ms": "2.1.2"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/react-native-webrtc/node_modules/event-target-shim": {
"version": "6.0.2",
"resolved": "https://registry.npmmirror.com/event-target-shim/-/event-target-shim-6.0.2.tgz",
"integrity": "sha512-8q3LsZjRezbFZ2PN+uP+Q7pnHUMmAOziU2vA2OwoFaKIXxlxl38IylhSSgUorWu/rf4er67w0ikBqjBFk/pomA==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
},
"funding": {
"url": "https://github.com/sponsors/mysticatea"
}
},
"node_modules/react-native-webrtc/node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
"license": "MIT"
},
"node_modules/react-native-webview": {
"version": "13.16.0",
"resolved": "https://registry.npmmirror.com/react-native-webview/-/react-native-webview-13.16.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "carrot_bbs",
"version": "1.0.1",
"version": "1.0.2",
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
@@ -60,6 +60,7 @@
"react-native-screens": "~4.23.0",
"react-native-sse": "^1.2.1",
"react-native-web": "^0.21.0",
"react-native-webrtc": "^124.0.7",
"react-native-webview": "13.16.0",
"react-native-worklets": "0.7.2",
"zod": "^4.3.6",

View File

@@ -0,0 +1,309 @@
import React from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Image,
StatusBar,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { callStore } from '../../stores/callStore';
import { RTCView } from 'react-native-webrtc';
const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
const CallScreen: React.FC = () => {
const currentCall = callStore((s) => s.currentCall);
const callDuration = callStore((s) => s.callDuration);
const peerStream = callStore((s) => s.peerStream);
const endCall = callStore((s) => s.endCall);
const toggleMute = callStore((s) => s.toggleMute);
const toggleSpeaker = callStore((s) => s.toggleSpeaker);
const isMinimized = callStore((s) => s.isMinimized);
const toggleMinimize = callStore((s) => s.toggleMinimize);
// Don't render full screen when minimized or no call
if (!currentCall || isMinimized) return null;
const getStatusText = (): string => {
switch (currentCall.status) {
case 'ringing':
return '正在等待对方接听...';
case 'connecting':
return '连接中...';
case 'connected':
return formatDuration(callDuration);
case 'ending':
return '通话已结束';
default:
return '';
}
};
const handleEndCall = () => {
endCall('hangup');
};
const handleToggleMute = () => {
toggleMute();
};
const handleToggleSpeaker = () => {
toggleSpeaker();
};
const handleMinimize = () => {
toggleMinimize();
};
return (
<View style={styles.container}>
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
{/* Background - single consistent color */}
<View style={styles.background} />
{/* Remote video preview */}
{peerStream && (
<View style={styles.remoteVideoContainer}>
<RTCView
streamURL={peerStream?.toURL()}
style={styles.remoteVideo}
objectFit="cover"
mirror
/>
</View>
)}
{/* Top area: minimize button */}
<View style={styles.topBar}>
<TouchableOpacity
style={styles.topButton}
onPress={handleMinimize}
activeOpacity={0.7}
>
<MaterialCommunityIcons name="chevron-down" size={28} color="#fff" />
</TouchableOpacity>
</View>
{/* Center: Peer info */}
<View style={styles.centerArea}>
<View style={styles.avatarOuter}>
{currentCall.peerAvatar ? (
<Image source={{ uri: currentCall.peerAvatar }} style={styles.avatar} />
) : (
<View style={[styles.avatar, styles.avatarPlaceholder]}>
<Text style={styles.avatarText}>
{currentCall.peerName?.charAt(0).toUpperCase() || '?'}
</Text>
</View>
)}
</View>
<Text style={styles.peerName} numberOfLines={1}>
{currentCall.peerName || '未知用户'}
</Text>
<Text style={styles.status}>{getStatusText()}</Text>
</View>
{/* Bottom controls */}
<View style={styles.controls}>
<View style={styles.controlRow}>
{/* Mute */}
<TouchableOpacity
style={[styles.controlButton, currentCall.isMuted && styles.controlButtonActive]}
onPress={handleToggleMute}
activeOpacity={0.7}
>
<View style={[styles.controlCircle, currentCall.isMuted && styles.controlCircleActive]}>
<MaterialCommunityIcons
name={currentCall.isMuted ? 'microphone-off' : 'microphone'}
size={26}
color={currentCall.isMuted ? '#333' : '#fff'}
/>
</View>
<Text style={[styles.controlLabel, currentCall.isMuted && styles.controlLabelActive]}>
{currentCall.isMuted ? '取消静音' : '静音'}
</Text>
</TouchableOpacity>
{/* Speaker */}
<TouchableOpacity
style={[styles.controlButton, currentCall.isSpeakerOn && styles.controlButtonActive]}
onPress={handleToggleSpeaker}
activeOpacity={0.7}
>
<View style={[styles.controlCircle, currentCall.isSpeakerOn && styles.controlCircleActive]}>
<MaterialCommunityIcons
name={currentCall.isSpeakerOn ? 'volume-high' : 'cellphone'}
size={26}
color={currentCall.isSpeakerOn ? '#333' : '#fff'}
/>
</View>
<Text style={[styles.controlLabel, currentCall.isSpeakerOn && styles.controlLabelActive]}>
{currentCall.isSpeakerOn ? '免提' : '听筒'}
</Text>
</TouchableOpacity>
</View>
{/* End call - prominent red button */}
<TouchableOpacity
style={styles.endCallButton}
onPress={handleEndCall}
activeOpacity={0.8}
>
<View style={styles.endCallCircle}>
<MaterialCommunityIcons name="phone-hangup" size={32} color="#fff" />
</View>
<Text style={styles.endCallLabel}></Text>
</TouchableOpacity>
</View>
</View>
);
};
const CONTROL_CIRCLE_SIZE = 56;
const END_CALL_SIZE = 64;
const styles = StyleSheet.create({
container: {
...StyleSheet.absoluteFillObject,
backgroundColor: '#1A1A2E',
zIndex: 9999,
},
background: {
...StyleSheet.absoluteFillObject,
backgroundColor: '#1A1A2E',
},
topBar: {
position: 'absolute',
top: 50,
left: 0,
right: 0,
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
height: 44,
},
topButton: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
justifyContent: 'center',
alignItems: 'center',
},
centerArea: {
position: 'absolute',
top: '28%',
left: 0,
right: 0,
alignItems: 'center',
paddingHorizontal: 30,
},
avatarOuter: {
marginBottom: 16,
},
avatar: {
width: 100,
height: 100,
borderRadius: 50,
backgroundColor: '#3A3A5C',
},
avatarPlaceholder: {
justifyContent: 'center',
alignItems: 'center',
},
avatarText: {
fontSize: 40,
color: '#fff',
fontWeight: '600',
},
peerName: {
fontSize: 22,
fontWeight: '600',
color: '#FFFFFF',
marginBottom: 6,
textAlign: 'center',
maxWidth: 280,
},
status: {
fontSize: 14,
color: 'rgba(255, 255, 255, 0.5)',
letterSpacing: 0.3,
},
remoteVideoContainer: {
position: 'absolute',
top: 70,
right: 20,
width: 100,
height: 140,
borderRadius: 14,
overflow: 'hidden',
backgroundColor: '#1A1A2E',
},
remoteVideo: {
width: '100%',
height: '100%',
},
controls: {
position: 'absolute',
bottom: 60,
left: 0,
right: 0,
alignItems: 'center',
},
controlRow: {
flexDirection: 'row',
justifyContent: 'center',
gap: 36,
marginBottom: 24,
},
controlButton: {
alignItems: 'center',
width: 70,
},
controlCircle: {
width: CONTROL_CIRCLE_SIZE,
height: CONTROL_CIRCLE_SIZE,
borderRadius: CONTROL_CIRCLE_SIZE / 2,
backgroundColor: 'rgba(255, 255, 255, 0.12)',
justifyContent: 'center',
alignItems: 'center',
},
controlCircleActive: {
backgroundColor: '#FFFFFF',
},
controlButtonActive: {},
controlLabel: {
fontSize: 11,
color: 'rgba(255, 255, 255, 0.55)',
marginTop: 8,
},
controlLabelActive: {
color: '#FFFFFF',
fontWeight: '500',
},
endCallButton: {
alignItems: 'center',
},
endCallCircle: {
width: END_CALL_SIZE,
height: END_CALL_SIZE,
borderRadius: END_CALL_SIZE / 2,
backgroundColor: '#E54D42',
justifyContent: 'center',
alignItems: 'center',
},
endCallLabel: {
fontSize: 11,
color: 'rgba(255, 255, 255, 0.55)',
marginTop: 8,
},
});
export default CallScreen;

View File

@@ -0,0 +1,153 @@
import React from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Image,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { callStore } from '../../stores/callStore';
const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
const FloatingCallWindow: React.FC = () => {
const currentCall = callStore((s) => s.currentCall);
const callDuration = callStore((s) => s.callDuration);
const isMinimized = callStore((s) => s.isMinimized);
const toggleMinimize = callStore((s) => s.toggleMinimize);
const endCall = callStore((s) => s.endCall);
// Only show when there's an active call and it's minimized
if (!currentCall || !isMinimized) return null;
const getStatusText = (): string => {
switch (currentCall.status) {
case 'ringing':
return '等待接听...';
case 'connecting':
return '连接中...';
case 'connected':
return formatDuration(callDuration);
case 'ending':
return '已结束';
default:
return '';
}
};
return (
<View style={styles.container}>
<TouchableOpacity
style={styles.window}
onPress={toggleMinimize}
activeOpacity={0.9}
>
{/* Avatar */}
<View style={styles.avatarContainer}>
{currentCall.peerAvatar ? (
<Image
source={{ uri: currentCall.peerAvatar }}
style={styles.avatar}
/>
) : (
<View style={[styles.avatar, styles.avatarPlaceholder]}>
<Text style={styles.avatarText}>
{currentCall.peerName?.charAt(0).toUpperCase() || '?'}
</Text>
</View>
)}
</View>
{/* Info */}
<View style={styles.info}>
<Text style={styles.name} numberOfLines={1}>
{currentCall.peerName || '未知用户'}
</Text>
<Text style={styles.status}>{getStatusText()}</Text>
</View>
{/* End call button */}
<TouchableOpacity
style={styles.endButton}
onPress={(e) => {
e.stopPropagation();
endCall('hangup');
}}
activeOpacity={0.7}
>
<MaterialCommunityIcons name="phone-hangup" size={20} color="#fff" />
</TouchableOpacity>
</TouchableOpacity>
</View>
);
};
const styles = StyleSheet.create({
container: {
position: 'absolute',
top: 100,
left: 16,
right: 16,
zIndex: 10000,
},
window: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#2A2A4E',
borderRadius: 16,
padding: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
},
avatarContainer: {
marginRight: 12,
},
avatar: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: '#3A3A5C',
},
avatarPlaceholder: {
justifyContent: 'center',
alignItems: 'center',
},
avatarText: {
fontSize: 18,
color: '#fff',
fontWeight: '600',
},
info: {
flex: 1,
justifyContent: 'center',
},
name: {
fontSize: 15,
fontWeight: '600',
color: '#FFFFFF',
marginBottom: 2,
},
status: {
fontSize: 12,
color: 'rgba(255, 255, 255, 0.6)',
},
endButton: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: '#E54D42',
justifyContent: 'center',
alignItems: 'center',
marginLeft: 8,
},
});
export default FloatingCallWindow;

View File

@@ -0,0 +1,274 @@
import React, { useEffect, useRef } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Image,
Animated,
Modal,
StatusBar,
Dimensions,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { callStore } from '../../stores/callStore';
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
const IncomingCallModal: React.FC = () => {
const incomingCall = callStore((s) => s.incomingCall);
const acceptCall = callStore((s) => s.acceptCall);
const rejectCall = callStore((s) => s.rejectCall);
const pulseAnim = useRef(new Animated.Value(1)).current;
const rippleAnim = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (incomingCall) {
const pulse = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.06,
duration: 1000,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 1000,
useNativeDriver: true,
}),
])
);
const ripple = Animated.loop(
Animated.sequence([
Animated.timing(rippleAnim, {
toValue: 1,
duration: 2000,
useNativeDriver: true,
}),
Animated.timing(rippleAnim, {
toValue: 0,
duration: 0,
useNativeDriver: true,
}),
])
);
Animated.parallel([pulse, ripple]).start();
return () => {
pulse.stop();
ripple.stop();
};
}
}, [incomingCall, pulseAnim, rippleAnim]);
const handleAccept = () => {
acceptCall();
};
const handleReject = () => {
rejectCall();
};
if (!incomingCall) return null;
return (
<Modal
visible={!!incomingCall}
transparent
animationType="fade"
statusBarTranslucent
>
<View style={styles.overlay}>
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
{/* Avatar with ripple effect - positioned in upper area */}
<View style={styles.avatarSection}>
{/* Ripple rings */}
{[0, 1, 2].map((i) => (
<Animated.View
key={i}
style={[
styles.rippleRing,
{
transform: [
{
scale: rippleAnim.interpolate({
inputRange: [0, 1],
outputRange: [1, 1.5 + i * 0.2],
}),
},
],
opacity: rippleAnim.interpolate({
inputRange: [0, 0.4, 1],
outputRange: [0.25, 0.12, 0],
}),
},
]}
/>
))}
<Animated.View
style={[styles.avatarContainer, { transform: [{ scale: pulseAnim }] }]}
>
{incomingCall.callerAvatar ? (
<Image
source={{ uri: incomingCall.callerAvatar }}
style={styles.avatar}
/>
) : (
<View style={[styles.avatar, styles.avatarPlaceholder]}>
<Text style={styles.avatarText}>
{incomingCall.callerName?.charAt(0).toUpperCase() || '?'}
</Text>
</View>
)}
</Animated.View>
</View>
{/* Caller info */}
<View style={styles.infoSection}>
<Text style={styles.callerName} numberOfLines={1}>
{incomingCall.callerName || '未知用户'}
</Text>
<Text style={styles.callType}></Text>
</View>
{/* Bottom controls */}
<View style={styles.controls}>
{/* Decline */}
<TouchableOpacity
style={styles.actionButton}
onPress={handleReject}
activeOpacity={0.8}
>
<View style={styles.declineCircle}>
<MaterialCommunityIcons name="phone-hangup" size={28} color="#fff" />
</View>
<Text style={styles.actionLabel}></Text>
</TouchableOpacity>
{/* Accept */}
<TouchableOpacity
style={styles.actionButton}
onPress={handleAccept}
activeOpacity={0.8}
>
<View style={styles.acceptCircle}>
<MaterialCommunityIcons name="phone" size={28} color="#fff" />
</View>
<Text style={styles.actionLabel}></Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
};
const AVATAR_SIZE = 100;
const ACTION_CIRCLE_SIZE = 60;
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: '#0D0D0D',
},
avatarSection: {
position: 'absolute',
top: '18%',
left: 0,
right: 0,
height: AVATAR_SIZE + 60,
justifyContent: 'center',
alignItems: 'center',
},
rippleRing: {
position: 'absolute',
width: AVATAR_SIZE + 16,
height: AVATAR_SIZE + 16,
borderRadius: (AVATAR_SIZE + 16) / 2,
borderWidth: 2,
borderColor: '#4CD964',
},
avatarContainer: {
width: AVATAR_SIZE + 8,
height: AVATAR_SIZE + 8,
borderRadius: (AVATAR_SIZE + 8) / 2,
backgroundColor: '#4CD964',
justifyContent: 'center',
alignItems: 'center',
},
avatar: {
width: AVATAR_SIZE,
height: AVATAR_SIZE,
borderRadius: AVATAR_SIZE / 2,
backgroundColor: '#3A3A5C',
},
avatarPlaceholder: {
justifyContent: 'center',
alignItems: 'center',
},
avatarText: {
fontSize: 36,
color: '#fff',
fontWeight: '600',
},
infoSection: {
position: 'absolute',
top: '42%',
left: 0,
right: 0,
alignItems: 'center',
paddingHorizontal: 40,
},
callerName: {
fontSize: 24,
fontWeight: '600',
color: '#FFFFFF',
marginBottom: 6,
textAlign: 'center',
maxWidth: 280,
},
callType: {
fontSize: 14,
color: 'rgba(255, 255, 255, 0.5)',
letterSpacing: 0.3,
},
controls: {
position: 'absolute',
bottom: '12%',
left: 0,
right: 0,
flexDirection: 'row',
justifyContent: 'center',
gap: 60,
},
actionButton: {
alignItems: 'center',
width: 80,
},
declineCircle: {
width: ACTION_CIRCLE_SIZE,
height: ACTION_CIRCLE_SIZE,
borderRadius: ACTION_CIRCLE_SIZE / 2,
backgroundColor: '#E54D42',
justifyContent: 'center',
alignItems: 'center',
},
acceptCircle: {
width: ACTION_CIRCLE_SIZE,
height: ACTION_CIRCLE_SIZE,
borderRadius: ACTION_CIRCLE_SIZE / 2,
backgroundColor: '#4CD964',
justifyContent: 'center',
alignItems: 'center',
},
actionLabel: {
fontSize: 12,
color: 'rgba(255, 255, 255, 0.6)',
marginTop: 10,
},
});
export default IncomingCallModal;

View File

@@ -0,0 +1,3 @@
export { default as CallScreen } from './CallScreen';
export { default as IncomingCallModal } from './IncomingCallModal';
export { default as FloatingCallWindow } from './FloatingCallWindow';

View File

@@ -35,7 +35,7 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { Text, ImageGallery, ImageGridItem } from '../../components/common';
import { useAppColors } from '../../theme';
import * as hrefs from '../../navigation/hrefs';
import { messageManager } from '../../stores';
import { messageManager, callStore } from '../../stores';
import { useBreakpointGTE } from '../../hooks/useResponsive';
import {
useChatScreen,
@@ -423,6 +423,15 @@ export const ChatScreen: React.FC = () => {
onMorePress={navigateToChatSettings}
onGroupInfoPress={handleGroupInfoPress}
isWideScreen={isWideScreen}
onCallPress={!isGroupChat ? () => {
const otherUserId = otherUser?.id;
if (otherUserId && conversationId) {
callStore.getState().startCall(conversationId, otherUserId, {
nickname: otherUser?.nickname,
avatar: otherUser?.avatar,
});
}
} : undefined}
/>
{/* 消息列表 */}

View File

@@ -23,6 +23,7 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
onMorePress,
onGroupInfoPress,
isWideScreen: propIsWideScreen,
onCallPress,
}) => {
const colors = useAppColors();
const baseStyles = useChatScreenStyles();
@@ -124,13 +125,30 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
>
<MaterialCommunityIcons name="dots-horizontal" size={22} color={colors.text.primary} />
</TouchableOpacity>
) : (
) : isGroupChat ? (
<TouchableOpacity
style={styles.moreButton}
onPress={onMorePress}
>
<MaterialCommunityIcons name="dots-horizontal" size={22} color={colors.text.primary} />
</TouchableOpacity>
) : (
<View style={styles.headerActions}>
{onCallPress && (
<TouchableOpacity
style={styles.callButton}
onPress={onCallPress}
>
<MaterialCommunityIcons name="phone" size={22} color={colors.primary.main} />
</TouchableOpacity>
)}
<TouchableOpacity
style={styles.moreButton}
onPress={onMorePress}
>
<MaterialCommunityIcons name="dots-horizontal" size={22} color={colors.text.primary} />
</TouchableOpacity>
</View>
)}
</View>
</View>

View File

@@ -114,11 +114,22 @@ export function createChatScreenStyles(colors: AppColors) {
alignItems: 'center',
justifyContent: 'center',
borderRadius: 19,
backgroundColor: colors.chat.surfaceRaised,
borderWidth: 1,
borderColor: colors.chat.borderLight,
backgroundColor: 'transparent',
},
headerActions: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
callButton: {
width: 38,
height: 38,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 19,
backgroundColor: `${colors.primary.main}12`,
},
// 消息列表
messageListContainer: {
flex: 1,

View File

@@ -179,6 +179,8 @@ export interface ChatHeaderProps {
onGroupInfoPress?: () => void;
/** 是否为大屏幕模式 */
isWideScreen?: boolean;
/** 拨打电话回调(仅私聊有效) */
onCallPress?: () => void;
}
// 回复预览 Props

View File

@@ -0,0 +1,281 @@
import {
RTCPeerConnection,
RTCSessionDescription,
RTCIceCandidate,
mediaDevices,
MediaStream,
MediaStreamTrack,
} from 'react-native-webrtc';
export interface ICEServer {
urls: string[];
username?: string;
credential?: string;
}
export type ConnectionState = 'new' | 'connecting' | 'connected' | 'disconnected' | 'failed' | 'closed';
export type WebRTCManagerEvent =
| { type: 'icecandidate'; candidate: RTCIceCandidate | null }
| { type: 'connectionstatechange'; state: ConnectionState }
| { type: 'remotestream'; stream: MediaStream }
| { type: 'error'; error: Error };
type EventHandler = (event: WebRTCManagerEvent) => void;
class WebRTCManager {
private peerConnection: RTCPeerConnection | null = null;
private localStream: MediaStream | null = null;
private remoteStream: MediaStream | null = null;
private pendingCandidates: RTCIceCandidateInit[] = [];
private iceServers: ICEServer[] = [];
private eventHandlers: Set<EventHandler> = new Set();
private disposed = false;
private isInitiator = false;
async initialize(iceServers: ICEServer[] = []): Promise<void> {
if (this.peerConnection) {
this.dispose();
}
this.disposed = false;
this.iceServers = iceServers.length > 0
? iceServers
: [{ urls: ['stun:stun.l.google.com:19302'] }];
}
private buildPeerConnectionConfig(): RTCConfiguration {
return {
iceServers: this.iceServers.map((server) => ({
urls: server.urls,
...(server.username ? { username: server.username } : {}),
...(server.credential ? { credential: server.credential } : {}),
})),
iceCandidatePoolSize: 10,
};
}
private createPeerConnection(): RTCPeerConnection {
const config = this.buildPeerConnectionConfig();
const pc = new RTCPeerConnection(config);
// @ts-ignore - react-native-webrtc uses on* handlers instead of addEventListener
pc.onicecandidate = (event) => {
if (event.candidate) {
this.emit({ type: 'icecandidate', candidate: event.candidate });
} else {
this.emit({ type: 'icecandidate', candidate: null });
}
};
// @ts-ignore
pc.oniceconnectionstatechange = () => {
const state = pc.iceConnectionState as ConnectionState;
this.emit({ type: 'connectionstatechange', state });
};
// @ts-ignore
pc.onconnectionstatechange = () => {
const state = pc.connectionState as ConnectionState;
this.emit({ type: 'connectionstatechange', state });
};
// @ts-ignore
pc.ontrack = (event) => {
if (event.streams && event.streams[0]) {
this.remoteStream = event.streams[0];
this.emit({ type: 'remotestream', stream: event.streams[0] });
}
};
// @ts-ignore
pc.onsignalingstatechange = () => {
// Could emit state change here if needed
};
return pc;
}
async createLocalStream(voiceOnly = true): Promise<MediaStream> {
const constraints = voiceOnly
? { audio: true, video: false }
: { audio: true, video: { facingMode: 'user', frameRate: 30 } };
try {
// @ts-ignore - react-native-webrtc has different constraint types
this.localStream = await mediaDevices.getUserMedia(constraints);
return this.localStream;
} catch (error) {
console.error('[WebRTC] Failed to get local media stream:', error);
throw error;
}
}
async startCall(isInitiator: boolean): Promise<RTCSessionDescriptionInit | null> {
if (this.disposed) throw new Error('WebRTCManager has been disposed');
if (!this.localStream) throw new Error('Local stream not initialized');
this.isInitiator = isInitiator;
this.peerConnection = this.createPeerConnection();
// Add local tracks to peer connection
for (const track of this.localStream.getTracks()) {
this.peerConnection.addTrack(track, this.localStream);
}
if (isInitiator) {
const offer = await this.createOffer();
return offer;
}
return null;
}
async createOffer(): Promise<RTCSessionDescriptionInit> {
if (!this.peerConnection) throw new Error('PeerConnection not initialized');
const offerOptions = {
offerToReceiveAudio: true,
offerToReceiveVideo: false,
};
const offer = await this.peerConnection.createOffer(offerOptions);
await this.peerConnection.setLocalDescription(offer);
return offer;
}
async createAnswer(): Promise<RTCSessionDescriptionInit> {
if (!this.peerConnection) throw new Error('PeerConnection not initialized');
const answer = await this.peerConnection.createAnswer();
await this.peerConnection.setLocalDescription(answer);
// Process pending candidates after local description is set
await this.processPendingCandidates();
return answer;
}
async createAnswerFromOffer(offer: RTCSessionDescriptionInit): Promise<RTCSessionDescriptionInit> {
await this.setRemoteDescription(offer);
return this.createAnswer();
}
async setRemoteDescription(description: RTCSessionDescriptionInit): Promise<void> {
if (!this.peerConnection) throw new Error('PeerConnection not initialized');
if (!description.sdp) {
throw new Error('setRemoteDescription: sdp is required');
}
const desc = new RTCSessionDescription({
type: description.type,
sdp: description.sdp,
});
await this.peerConnection.setRemoteDescription(desc);
// Process pending candidates after remote description is set
await this.processPendingCandidates();
}
async addIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
if (!this.peerConnection) {
this.pendingCandidates.push(candidate);
return;
}
if (!this.peerConnection.remoteDescription) {
this.pendingCandidates.push(candidate);
return;
}
try {
const iceCandidate = new RTCIceCandidate(candidate);
await this.peerConnection.addIceCandidate(iceCandidate);
} catch (error) {
console.error('[WebRTC] Failed to add ICE candidate:', error);
// Still push to pending in case order matters
this.pendingCandidates.push(candidate);
}
}
private async processPendingCandidates(): Promise<void> {
if (this.pendingCandidates.length === 0) return;
if (!this.peerConnection) return;
const candidates = [...this.pendingCandidates];
this.pendingCandidates = [];
for (const candidate of candidates) {
try {
const iceCandidate = new RTCIceCandidate(candidate);
await this.peerConnection.addIceCandidate(iceCandidate);
} catch (error) {
console.error('[WebRTC] Failed to add pending ICE candidate:', error);
}
}
}
setMuted(muted: boolean): void {
if (!this.localStream) return;
const audioTracks = this.localStream.getAudioTracks();
audioTracks.forEach((track) => {
track.enabled = !muted;
});
}
isMuted(): boolean {
if (!this.localStream) return false;
const audioTracks = this.localStream.getAudioTracks();
return audioTracks.some((track) => !track.enabled);
}
getRemoteStream(): MediaStream | null {
return this.remoteStream;
}
getLocalStream(): MediaStream | null {
return this.localStream;
}
getPeerConnection(): RTCPeerConnection | null {
return this.peerConnection;
}
onEvent(handler: EventHandler): () => void {
this.eventHandlers.add(handler);
return () => {
this.eventHandlers.delete(handler);
};
}
private emit(event: WebRTCManagerEvent): void {
this.eventHandlers.forEach((handler) => {
try {
handler(event);
} catch (error) {
console.error('[WebRTC] Event handler error:', error);
}
});
}
dispose(): void {
this.disposed = true;
this.eventHandlers.clear();
this.pendingCandidates = [];
if (this.localStream) {
this.localStream.getTracks().forEach((track) => track.stop());
this.localStream.release();
this.localStream = null;
}
if (this.remoteStream) {
this.remoteStream.getTracks().forEach((track) => track.stop());
this.remoteStream.release();
this.remoteStream = null;
}
if (this.peerConnection) {
this.peerConnection.close();
this.peerConnection = null;
}
}
}
export const webrtcManager = new WebRTCManager();

View File

@@ -0,0 +1,2 @@
export { webrtcManager } from './WebRTCManager';
export type { ICEServer, ConnectionState, WebRTCManagerEvent } from './WebRTCManager';

View File

@@ -30,7 +30,106 @@ export type WSMessageType =
| 'pong'
| 'message_sent'
| 'message_recalled'
| 'error';
| 'error'
| 'call_incoming'
| 'call_accepted'
| 'call_rejected'
| 'call_busy'
| 'call_sdp'
| 'call_ice'
| 'call_ended'
| 'call_peer_muted'
| 'call_invited'
| 'call_answered_elsewhere';
export interface WSCallIncomingMessage {
type: 'call_incoming';
call_id: string;
conversation_id: string;
caller_id: string;
call_type: string;
created_at: number;
lifetime?: number;
ice_servers?: ICEServerConfig[];
}
export interface ICEServerConfig {
urls: string[];
username?: string;
credential?: string;
}
export interface WSCallAcceptedMessage {
type: 'call_accepted';
call_id: string;
started_at: number;
ice_servers?: ICEServerConfig[];
}
export interface WSCallRejectedMessage {
type: 'call_rejected';
call_id: string;
reason?: string;
}
export interface WSCallBusyMessage {
type: 'call_busy';
call_id: string;
}
export interface WSCallEndedMessage {
type: 'call_ended';
call_id: string;
ended_by?: string;
reason?: string;
duration?: number;
ended_at?: number;
}
export interface WSCallSDPMessage {
type: 'call_sdp';
call_id: string;
from_id: string;
payload: {
sdp_type: string;
sdp: string;
};
}
export interface WSCallICEMessage {
type: 'call_ice';
call_id: string;
from_id: string;
payload: {
candidate: string;
};
}
export interface WSCallPeerMutedMessage {
type: 'call_peer_muted';
call_id: string;
user_id: string;
muted: boolean;
}
export interface WSCallInvitedMessage {
type: 'call_invited';
call_id: string;
conversation_id: string;
callee_id: string;
}
export interface WSCallAnsweredElsewhereMessage {
type: 'call_answered_elsewhere';
call_id: string;
reason?: string;
}
export interface WSErrorMessage {
type: 'error';
code: string;
message: string;
}
export interface WSChatMessage {
type: 'chat';
@@ -166,7 +265,18 @@ export type WSMessage =
| WSGroupNoticeMessage
| WSGroupMentionMessage
| WSGroupReadMessage
| WSGroupRecallMessage;
| WSGroupRecallMessage
| WSCallIncomingMessage
| WSCallAcceptedMessage
| WSCallRejectedMessage
| WSCallBusyMessage
| WSCallEndedMessage
| WSCallSDPMessage
| WSCallICEMessage
| WSCallPeerMutedMessage
| WSCallInvitedMessage
| WSCallAnsweredElsewhereMessage
| WSErrorMessage;
type MessageHandler<T extends WSMessage = WSMessage> = (message: T) => void;
type ConnectionHandler = () => void;
@@ -273,17 +383,35 @@ class WebSocketService {
}
private handleIncoming(data: string): void {
try {
const msg: WSServerMessage = JSON.parse(data);
// 后端 writePump 会将多条消息以 \n 分隔合并发送,需要逐条解析
const lines = data.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const msg: WSServerMessage = JSON.parse(trimmed);
this.processMessage(msg);
} catch (error) {
console.error('Failed to parse WebSocket message:', error, 'raw:', trimmed);
}
}
}
private processMessage(msg: WSServerMessage): void {
// 处理pong响应
if (msg.type === 'pong') {
return;
}
// 处理错误消息
// 处理错误消息 - emit 到上层处理
if (msg.type === 'error') {
console.error('WebSocket error:', msg.payload);
const err: WSErrorMessage = {
type: 'error',
code: msg.payload?.code || 'unknown',
message: msg.payload?.message || 'Unknown error',
};
this.emit('error', err);
return;
}
@@ -306,9 +434,6 @@ class WebSocketService {
// 分发事件
this.dispatchServerMessage(msg);
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
}
}
private dispatchServerMessage(msg: WSServerMessage): void {
@@ -333,6 +458,37 @@ class WebSocketService {
case 'system_notification':
this.handleSystemNotification(payload);
break;
// Call signaling messages
case 'call_incoming':
this.handleCallIncoming(payload);
break;
case 'call_accepted':
this.handleCallAccepted(payload);
break;
case 'call_rejected':
this.handleCallRejected(payload);
break;
case 'call_busy':
this.handleCallBusy(payload);
break;
case 'call_sdp':
this.handleCallSDP(payload);
break;
case 'call_ice':
this.handleCallICE(payload);
break;
case 'call_ended':
this.handleCallEnded(payload);
break;
case 'call_peer_muted':
this.handleCallPeerMuted(payload);
break;
case 'call_invited':
this.handleCallInvited(payload);
break;
case 'call_answered_elsewhere':
this.handleCallAnsweredElsewhere(payload);
break;
default:
console.log('Unknown message type:', type);
}
@@ -458,6 +614,152 @@ class WebSocketService {
systemNotificationService.handleWSMessage(m as any).catch(() => {});
}
// Call signaling handlers
private handleCallIncoming(payload: any): void {
const m: WSCallIncomingMessage = {
type: 'call_incoming',
call_id: payload.call_id,
conversation_id: payload.conversation_id,
caller_id: payload.caller_id,
call_type: payload.call_type,
created_at: payload.created_at,
lifetime: payload.lifetime,
ice_servers: payload.ice_servers,
};
this.emit('call_incoming', m);
}
private handleCallAccepted(payload: any): void {
const m: WSCallAcceptedMessage = {
type: 'call_accepted',
call_id: payload.call_id,
started_at: payload.started_at,
ice_servers: payload.ice_servers,
};
this.emit('call_accepted', m);
}
private handleCallRejected(payload: any): void {
const m: WSCallRejectedMessage = {
type: 'call_rejected',
call_id: payload.call_id,
reason: payload.reason,
};
this.emit('call_rejected', m);
}
private handleCallBusy(payload: any): void {
const m: WSCallBusyMessage = {
type: 'call_busy',
call_id: payload.call_id,
};
this.emit('call_busy', m);
}
private handleCallSDP(payload: any): void {
const m: WSCallSDPMessage = {
type: 'call_sdp',
call_id: payload.call_id,
from_id: payload.from_id,
payload: payload.payload,
};
this.emit('call_sdp', m);
}
private handleCallICE(payload: any): void {
const m: WSCallICEMessage = {
type: 'call_ice',
call_id: payload.call_id,
from_id: payload.from_id,
payload: payload.payload,
};
this.emit('call_ice', m);
}
private handleCallEnded(payload: any): void {
const m: WSCallEndedMessage = {
type: 'call_ended',
call_id: payload.call_id,
ended_by: payload.ended_by,
reason: payload.reason,
duration: payload.duration,
ended_at: payload.ended_at,
};
this.emit('call_ended', m);
}
private handleCallPeerMuted(payload: any): void {
const m: WSCallPeerMutedMessage = {
type: 'call_peer_muted',
call_id: payload.call_id,
user_id: payload.user_id,
muted: payload.muted,
};
this.emit('call_peer_muted', m);
}
private handleCallInvited(payload: any): void {
const m: WSCallInvitedMessage = {
type: 'call_invited',
call_id: payload.call_id,
conversation_id: payload.conversation_id,
callee_id: payload.callee_id,
};
this.emit('call_invited', m);
}
private handleCallAnsweredElsewhere(payload: any): void {
const m: WSCallAnsweredElsewhereMessage = {
type: 'call_answered_elsewhere',
call_id: payload.call_id,
reason: payload.reason,
};
this.emit('call_answered_elsewhere', m);
}
// Call signaling send methods
sendCallInvite(conversationId: string, calleeId: string): void {
this.sendFireAndForget('call_invite', {
conversation_id: conversationId,
callee_id: calleeId,
});
}
sendCallAnswer(callId: string): void {
this.sendFireAndForget('call_answer', { call_id: callId });
}
sendCallReject(callId: string): void {
this.sendFireAndForget('call_reject', { call_id: callId });
}
sendCallBusy(callId: string): void {
this.sendFireAndForget('call_busy', { call_id: callId });
}
sendCallSDP(callId: string, sdpType: string, sdp: string): void {
this.sendFireAndForget('call_sdp', {
call_id: callId,
sdp_type: sdpType,
sdp,
});
}
sendCallICE(callId: string, candidate: string): void {
this.sendFireAndForget('call_ice', {
call_id: callId,
candidate,
});
}
sendCallEnd(callId: string, reason?: string): void {
this.sendFireAndForget('call_end', { call_id: callId, reason });
}
sendCallMute(callId: string, muted: boolean): void {
this.sendFireAndForget('call_mute', { call_id: callId, muted });
}
private handleMessageSent(payload: any): void {
// 找到对应的发送请求并resolve
const pendingMsg = this.pendingMessages.find(p =>
@@ -540,6 +842,15 @@ class WebSocketService {
return api.post('/messages/delete_msg', { message_id: messageId });
}
// 通过WebSocket发送fire-and-forget不等待确认
private sendFireAndForget(type: string, payload: any): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
return;
}
const message = { type, payload };
this.ws.send(JSON.stringify(message));
}
// 通过WebSocket发送
private sendViaWebSocket(type: string, payload: any): Promise<any> {
return new Promise((resolve, reject) => {

View File

@@ -15,6 +15,7 @@ import { create } from 'zustand';
import { User } from '../types';
import { authService, resolveAuthApiError, LoginRequest, RegisterRequest } from '../services';
import { wsService } from '../services/wsService';
import { callStore } from './callStore';
import {
initDatabase,
closeDatabase,
@@ -96,6 +97,7 @@ function resolveLoginError(error: any): string {
async function startRealtime(): Promise<void> {
try {
await wsService.start();
callStore.getState().initCall();
} catch (error) {
console.error('[AuthStore] 启动 SSE 服务失败:', error);
}

634
src/stores/callStore.ts Normal file
View File

@@ -0,0 +1,634 @@
import { create } from 'zustand';
import { MediaStream } from 'react-native-webrtc';
import {
wsService,
WSCallIncomingMessage,
WSCallSDPMessage,
WSCallICEMessage,
WSErrorMessage,
} from '../services/wsService';
import { webrtcManager, ICEServer } from '../services/webrtc';
import { useAuthStore } from './authStore';
import { useUserStore } from './userStore';
import { userManager } from './userManager';
export type CallStatus = 'idle' | 'ringing' | 'connecting' | 'connected' | 'ending';
export interface CallSession {
id: string;
conversationId: string;
peerId: string;
peerName?: string;
peerAvatar?: string | null;
status: CallStatus;
startedAt?: number;
duration: number;
isMuted: boolean;
isSpeakerOn: boolean;
isInitiator: boolean;
}
export interface IncomingCallInfo {
callId: string;
conversationId: string;
callerId: string;
callerName?: string;
callerAvatar?: string | null;
callType: string;
iceServers: ICEServer[];
receivedAt: number;
lifetime?: number;
}
interface CallState {
currentCall: CallSession | null;
incomingCall: IncomingCallInfo | null;
callDuration: number;
peerStream: MediaStream | null;
isMinimized: boolean;
initCall: () => () => void;
startCall: (
conversationId: string,
calleeId: string,
calleeInfo?: { nickname?: string; username?: string; avatar?: string | null }
) => Promise<void>;
acceptCall: () => Promise<void>;
rejectCall: () => void;
endCall: (reason?: string) => Promise<void>;
toggleMute: () => void;
toggleSpeaker: () => void;
toggleMinimize: () => void;
}
let durationTimer: ReturnType<typeof setInterval> | null = null;
let callTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
let initCallUnsub: (() => void) | null = null;
let unsubInvited: (() => void) | null = null;
let pendingOffer: { callId: string; sdp: string } | null = null;
// === Element + Telegram 结合: 常量 ===
const CALL_LIFETIME_MS = 55000; // 55秒 (略小于后端60秒)
const CALL_TIMEOUT_MS = 115000; // 115秒 (拨打方等待超时略小于后端120秒)
const IGNORE_CALL_ID_TTL = 30000; // 已处理callId保留30秒
// === Element: 已处理的 callId 集合 (防重复) ===
const processedCallIds = new Map<string, number>(); // callId -> timestamp
// 清理过期的 callId
function cleanupProcessedCallIds() {
const now = Date.now();
for (const [callId, timestamp] of processedCallIds) {
if (now - timestamp > IGNORE_CALL_ID_TTL) {
processedCallIds.delete(callId);
}
}
}
export const callStore = create<CallState>((set, get) => ({
currentCall: null,
incomingCall: null,
callDuration: 0,
peerStream: null,
isMinimized: false,
initCall: () => {
// Prevent duplicate handler registration
if (initCallUnsub) {
initCallUnsub();
initCallUnsub = null;
}
const unsubs: Array<() => void> = [];
unsubs.push(
wsService.on('call_incoming', async (msg) => {
// === Element: 清理过期的 callId ===
cleanupProcessedCallIds();
// === Element: 检查是否已处理过该 callId ===
if (processedCallIds.has(msg.call_id)) {
console.log('[CallStore] Ignoring already processed call:', msg.call_id);
return;
}
const { currentCall, incomingCall } = get();
if (incomingCall) {
wsService.sendCallBusy(msg.call_id);
processedCallIds.set(msg.call_id, Date.now());
return;
}
if (currentCall && currentCall.status !== 'idle') {
wsService.sendCallBusy(msg.call_id);
processedCallIds.set(msg.call_id, Date.now());
return;
}
// === Element: 检查 lifetime 过期 ===
const callAge = Date.now() - msg.created_at;
const lifetime = msg.lifetime || 60000; // 默认60秒
if (callAge > lifetime - 5000) { // 留5秒余量
console.log('[CallStore] Ignoring stale incoming call, age:', callAge, 'ms, lifetime:', lifetime);
wsService.sendCallReject(msg.call_id);
processedCallIds.set(msg.call_id, Date.now());
return;
}
// Try to get caller info from cache first, then fetch from API
let caller: { nickname?: string; username?: string; avatar?: string | null } | null =
useUserStore.getState().userCache[msg.caller_id];
if (!caller) {
try {
const fetchedCaller = await userManager.getUserById(msg.caller_id);
if (fetchedCaller) {
caller = {
nickname: fetchedCaller.nickname,
username: fetchedCaller.username,
avatar: fetchedCaller.avatar,
};
}
} catch (err) {
console.log('[CallStore] Failed to fetch caller info:', err);
}
}
const callerName = caller?.nickname || caller?.username || msg.caller_id;
set({
incomingCall: {
callId: msg.call_id,
conversationId: msg.conversation_id,
callerId: msg.caller_id,
callerName,
callerAvatar: caller?.avatar,
callType: msg.call_type,
iceServers: msg.ice_servers || [],
receivedAt: Date.now(),
lifetime: msg.lifetime,
},
});
// 标记为已处理
processedCallIds.set(msg.call_id, Date.now());
// 设置超时 (使用 lifetime)
if (callTimeoutTimer) clearTimeout(callTimeoutTimer);
const remainingTime = Math.max(lifetime - callAge - 1000, 5000); // 剩余时间最少5秒
callTimeoutTimer = setTimeout(() => {
const { incomingCall: ic } = get();
if (ic?.callId === msg.call_id) {
console.log('[CallStore] Incoming call timeout');
wsService.sendCallReject(msg.call_id);
processedCallIds.set(msg.call_id, Date.now());
set({ incomingCall: null });
}
}, remainingTime);
})
);
unsubs.push(
wsService.on('call_accepted', async (msg) => {
const { currentCall } = get();
if (!currentCall || currentCall.id !== msg.call_id) return;
try {
await webrtcManager.initialize(msg.ice_servers || []);
await webrtcManager.createLocalStream(true);
// Listen for WebRTC events
const unsubRTC = webrtcManager.onEvent((event) => {
if (event.type === 'icecandidate' && event.candidate) {
wsService.sendCallICE(currentCall.id, JSON.stringify(event.candidate.toJSON()));
}
if (event.type === 'remotestream') {
set({ peerStream: event.stream });
}
if (event.type === 'connectionstatechange') {
if (event.state === 'connected') {
const now = Date.now();
set((s) => ({
currentCall: s.currentCall
? { ...s.currentCall, status: 'connected', startedAt: now }
: null,
}));
if (durationTimer) clearInterval(durationTimer);
durationTimer = setInterval(() => {
set((s) => ({ callDuration: s.callDuration + 1 }));
}, 1000);
}
if (event.state === 'disconnected' || event.state === 'failed') {
get().endCall('connection_lost');
}
}
});
void unsubRTC;
// startCall creates PeerConnection, adds tracks, and creates offer for initiator
const offer = await webrtcManager.startCall(true);
if (offer) {
wsService.sendCallSDP(currentCall.id, 'offer', offer.sdp || '');
}
} catch (err) {
console.error('[CallStore] call_accepted error:', err);
get().endCall('connection_failed');
}
})
);
unsubs.push(
wsService.on('call_rejected', (msg) => {
const { currentCall } = get();
if (currentCall?.id !== msg.call_id) return;
console.log('[CallStore] Call rejected');
get().endCall('rejected');
})
);
unsubs.push(
wsService.on('call_busy', (msg) => {
const { currentCall } = get();
if (currentCall?.id !== msg.call_id) return;
console.log('[CallStore] Call busy');
get().endCall('busy');
})
);
unsubs.push(
wsService.on('call_ended', (msg) => {
const { currentCall, incomingCall } = get();
// 标记为已处理
processedCallIds.set(msg.call_id, Date.now());
// If this is an active call, end it
if (currentCall?.id === msg.call_id) {
console.log('[CallStore] Call ended, duration:', msg.duration);
get().endCall('ended');
return;
}
// If this is an incoming call that was cancelled by caller, clear it
if (incomingCall?.callId === msg.call_id) {
console.log('[CallStore] Incoming call cancelled by caller');
if (callTimeoutTimer) {
clearTimeout(callTimeoutTimer);
callTimeoutTimer = null;
}
set({ incomingCall: null });
}
})
);
// === Telegram: 其他设备已接听 ===
unsubs.push(
wsService.on('call_answered_elsewhere', (msg) => {
const { incomingCall } = get();
if (incomingCall?.callId === msg.call_id) {
console.log('[CallStore] Call answered on another device');
processedCallIds.set(msg.call_id, Date.now());
if (callTimeoutTimer) {
clearTimeout(callTimeoutTimer);
callTimeoutTimer = null;
}
set({ incomingCall: null });
}
})
);
// === Telegram: 处理服务端错误 ===
unsubs.push(
wsService.on('error', (msg: WSErrorMessage) => {
const { currentCall, incomingCall } = get();
console.log('[CallStore] Server error:', msg.code, msg.message);
// 处理 callee_offline 错误
if (msg.code === 'callee_offline') {
if (currentCall && currentCall.status === 'ringing') {
console.log('[CallStore] Callee is offline');
get().endCall('callee_offline');
}
}
// 处理 call_already_answered 错误
if (msg.code === 'call_already_answered') {
if (incomingCall) {
console.log('[CallStore] Call already answered on another device');
processedCallIds.set(incomingCall.callId, Date.now());
if (callTimeoutTimer) {
clearTimeout(callTimeoutTimer);
callTimeoutTimer = null;
}
set({ incomingCall: null });
}
}
})
);
unsubs.push(
wsService.on('call_sdp', async (msg: WSCallSDPMessage) => {
const { currentCall } = get();
if (!currentCall || currentCall.id !== msg.call_id) return;
// Check that we are not the sender of this SDP message
const myUserId = useAuthStore.getState().currentUser?.id;
if (myUserId && msg.from_id === myUserId) return;
try {
const pc = webrtcManager.getPeerConnection();
if (msg.payload.sdp_type === 'offer') {
if (!pc) {
// Cache offer for later processing when PeerConnection is ready
pendingOffer = { callId: msg.call_id, sdp: msg.payload.sdp };
console.log('[CallStore] Caching pending offer, PeerConnection not ready yet');
return;
}
// We are the callee - only process if in 'stable' state
const signalingState = pc.signalingState;
if (signalingState !== 'stable') {
console.warn('[CallStore] Ignoring offer, signaling state is', signalingState);
return;
}
pendingOffer = null;
await webrtcManager.setRemoteDescription({
type: msg.payload.sdp_type,
sdp: msg.payload.sdp,
});
const answer = await webrtcManager.createAnswer();
wsService.sendCallSDP(msg.call_id, 'answer', answer.sdp || '');
} else if (msg.payload.sdp_type === 'answer') {
if (!pc) return;
// We are the initiator - only process if in 'have-local-offer' state
const signalingState = pc.signalingState;
if (signalingState !== 'have-local-offer') {
console.warn('[CallStore] Ignoring answer, signaling state is', signalingState);
return;
}
await webrtcManager.setRemoteDescription({
type: msg.payload.sdp_type,
sdp: msg.payload.sdp,
});
}
} catch (err) {
console.error('[CallStore] call_sdp error:', err);
}
})
);
unsubs.push(
wsService.on('call_ice', (msg: WSCallICEMessage) => {
const { currentCall } = get();
if (!currentCall || currentCall.id !== msg.call_id) return;
// Ignore ICE candidates from ourselves
const myUserId = useAuthStore.getState().currentUser?.id;
if (myUserId && msg.from_id === myUserId) return;
try {
const candidate = typeof msg.payload.candidate === 'string'
? JSON.parse(msg.payload.candidate)
: msg.payload.candidate;
webrtcManager.addIceCandidate(candidate);
} catch (err) {
console.error('[CallStore] call_ice error:', err);
}
})
);
unsubs.push(
wsService.on('call_peer_muted', (msg) => {
console.log('[CallStore] Peer muted:', msg.user_id, msg.muted);
})
);
const cleanup = () => {
if (callTimeoutTimer) clearTimeout(callTimeoutTimer);
if (durationTimer) clearInterval(durationTimer);
unsubs.forEach((unsub) => unsub());
initCallUnsub = null;
};
initCallUnsub = cleanup;
return cleanup;
},
startCall: async (
conversationId: string,
calleeId: string,
calleeInfo?: { nickname?: string; username?: string; avatar?: string | null }
) => {
const { currentCall } = get();
if (currentCall && currentCall.status !== 'idle') {
console.warn('[CallStore] Already in a call');
return;
}
const myUserId = useAuthStore.getState().currentUser?.id;
if (!myUserId) {
console.error('[CallStore] Not logged in');
return;
}
// Use provided callee info first, then fall back to userCache
const cachedCallee = useUserStore.getState().userCache[calleeId];
const callee = calleeInfo || cachedCallee;
const calleeName = callee?.nickname || callee?.username || calleeId;
set({
currentCall: {
id: '', // Will be filled when call_invited response comes
conversationId,
peerId: calleeId,
peerName: calleeName,
peerAvatar: callee?.avatar,
status: 'ringing',
duration: 0,
isMuted: false,
isSpeakerOn: false,
isInitiator: true,
},
});
wsService.sendCallInvite(conversationId, calleeId);
// Listen for call_invited to get the call_id
if (unsubInvited) {
unsubInvited();
}
unsubInvited = wsService.on('call_invited', (msg) => {
set((s) => ({
currentCall: s.currentCall
? { ...s.currentCall, id: msg.call_id }
: null,
}));
});
// Timeout - 使用 CALL_TIMEOUT_MS (115秒略小于后端 120秒)
if (callTimeoutTimer) clearTimeout(callTimeoutTimer);
callTimeoutTimer = setTimeout(() => {
const { currentCall: cc } = get();
if (cc && cc.status === 'ringing') {
console.warn('[CallStore] Call timeout');
get().endCall('timeout');
}
}, CALL_TIMEOUT_MS);
},
acceptCall: async () => {
const { incomingCall } = get();
if (!incomingCall) return;
if (callTimeoutTimer) {
clearTimeout(callTimeoutTimer);
callTimeoutTimer = null;
}
set({
currentCall: {
id: incomingCall.callId,
conversationId: incomingCall.conversationId,
peerId: incomingCall.callerId,
peerName: incomingCall.callerName,
peerAvatar: incomingCall.callerAvatar,
status: 'connecting',
duration: 0,
isMuted: false,
isSpeakerOn: false,
isInitiator: false,
},
incomingCall: null,
});
wsService.sendCallAnswer(incomingCall.callId);
try {
await webrtcManager.initialize(incomingCall.iceServers);
await webrtcManager.createLocalStream(true);
// Listen for WebRTC events
const unsubRTC = webrtcManager.onEvent((event) => {
if (event.type === 'icecandidate' && event.candidate) {
wsService.sendCallICE(incomingCall.callId, JSON.stringify(event.candidate.toJSON()));
}
if (event.type === 'remotestream') {
set({ peerStream: event.stream });
}
if (event.type === 'connectionstatechange') {
if (event.state === 'connected') {
const now = Date.now();
set((s) => ({
currentCall: s.currentCall
? { ...s.currentCall, status: 'connected', startedAt: now }
: null,
}));
if (durationTimer) clearInterval(durationTimer);
durationTimer = setInterval(() => {
set((s) => ({ callDuration: s.callDuration + 1 }));
}, 1000);
}
if (event.state === 'disconnected' || event.state === 'failed') {
get().endCall('connection_lost');
}
}
});
void unsubRTC;
// startCall creates PeerConnection and adds tracks (non-initiator, no offer)
await webrtcManager.startCall(false);
// Process any pending offer that arrived before PeerConnection was ready
if (pendingOffer && pendingOffer.callId === incomingCall.callId) {
const offerMsg = pendingOffer;
pendingOffer = null;
console.log('[CallStore] Processing pending offer after PeerConnection ready');
try {
await webrtcManager.setRemoteDescription({
type: 'offer',
sdp: offerMsg.sdp,
});
const answer = await webrtcManager.createAnswer();
wsService.sendCallSDP(offerMsg.callId, 'answer', answer.sdp || '');
} catch (err) {
console.error('[CallStore] Failed to process pending offer:', err);
}
}
} catch (err) {
console.error('[CallStore] Failed to accept call:', err);
get().endCall('connection_failed');
}
},
rejectCall: () => {
const { incomingCall } = get();
if (!incomingCall) return;
if (callTimeoutTimer) {
clearTimeout(callTimeoutTimer);
callTimeoutTimer = null;
}
wsService.sendCallReject(incomingCall.callId);
set({ incomingCall: null });
},
endCall: async (reason = 'ended') => {
const { currentCall } = get();
if (!currentCall) return;
if (callTimeoutTimer) {
clearTimeout(callTimeoutTimer);
callTimeoutTimer = null;
}
if (durationTimer) {
clearInterval(durationTimer);
durationTimer = null;
}
if (unsubInvited) {
unsubInvited();
unsubInvited = null;
}
pendingOffer = null;
const callId = currentCall.id;
set({
currentCall: null,
callDuration: 0,
peerStream: null,
});
webrtcManager.dispose();
if (callId && reason !== 'ended') {
wsService.sendCallEnd(callId, reason);
}
},
toggleMute: () => {
const { currentCall } = get();
if (!currentCall) return;
const newMuted = !currentCall.isMuted;
webrtcManager.setMuted(newMuted);
if (currentCall.id) {
wsService.sendCallMute(currentCall.id, newMuted);
}
set((s) => ({
currentCall: s.currentCall ? { ...s.currentCall, isMuted: newMuted } : null,
}));
},
toggleSpeaker: () => {
const { currentCall } = get();
if (!currentCall) return;
set((s) => ({
currentCall: s.currentCall
? { ...s.currentCall, isSpeakerOn: !s.currentCall.isSpeakerOn }
: null,
}));
},
toggleMinimize: () => {
set((s) => ({ isMinimized: !s.isMinimized }));
},
}));

View File

@@ -47,6 +47,7 @@ export {
export {
useHomeTabPressStore,
} from './homeTabPressStore';
export { callStore } from './callStore';
export {
useAppColors,
useThemePreference,