diff --git a/app.json b/app.json
index a4a4974..f04fe52 100644
--- a/app.json
+++ b/app.json
@@ -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",
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 190d219..0fb9437 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -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() {
+
+
+
diff --git a/package-lock.json b/package-lock.json
index 3620731..cbc15c9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 519c398..62dacb5 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/components/call/CallScreen.tsx b/src/components/call/CallScreen.tsx
new file mode 100644
index 0000000..6984a71
--- /dev/null
+++ b/src/components/call/CallScreen.tsx
@@ -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 (
+
+
+
+ {/* Background - single consistent color */}
+
+
+ {/* Remote video preview */}
+ {peerStream && (
+
+
+
+ )}
+
+ {/* Top area: minimize button */}
+
+
+
+
+
+
+ {/* Center: Peer info */}
+
+
+ {currentCall.peerAvatar ? (
+
+ ) : (
+
+
+ {currentCall.peerName?.charAt(0).toUpperCase() || '?'}
+
+
+ )}
+
+
+ {currentCall.peerName || '未知用户'}
+
+ {getStatusText()}
+
+
+ {/* Bottom controls */}
+
+
+ {/* Mute */}
+
+
+
+
+
+ {currentCall.isMuted ? '取消静音' : '静音'}
+
+
+
+ {/* Speaker */}
+
+
+
+
+
+ {currentCall.isSpeakerOn ? '免提' : '听筒'}
+
+
+
+
+ {/* End call - prominent red button */}
+
+
+
+
+ 挂断
+
+
+
+ );
+};
+
+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;
diff --git a/src/components/call/FloatingCallWindow.tsx b/src/components/call/FloatingCallWindow.tsx
new file mode 100644
index 0000000..cf7f795
--- /dev/null
+++ b/src/components/call/FloatingCallWindow.tsx
@@ -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 (
+
+
+ {/* Avatar */}
+
+ {currentCall.peerAvatar ? (
+
+ ) : (
+
+
+ {currentCall.peerName?.charAt(0).toUpperCase() || '?'}
+
+
+ )}
+
+
+ {/* Info */}
+
+
+ {currentCall.peerName || '未知用户'}
+
+ {getStatusText()}
+
+
+ {/* End call button */}
+ {
+ e.stopPropagation();
+ endCall('hangup');
+ }}
+ activeOpacity={0.7}
+ >
+
+
+
+
+ );
+};
+
+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;
diff --git a/src/components/call/IncomingCallModal.tsx b/src/components/call/IncomingCallModal.tsx
new file mode 100644
index 0000000..e8feace
--- /dev/null
+++ b/src/components/call/IncomingCallModal.tsx
@@ -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 (
+
+
+
+
+ {/* Avatar with ripple effect - positioned in upper area */}
+
+ {/* Ripple rings */}
+ {[0, 1, 2].map((i) => (
+
+ ))}
+
+
+ {incomingCall.callerAvatar ? (
+
+ ) : (
+
+
+ {incomingCall.callerName?.charAt(0).toUpperCase() || '?'}
+
+
+ )}
+
+
+
+ {/* Caller info */}
+
+
+ {incomingCall.callerName || '未知用户'}
+
+ 来电通话
+
+
+ {/* Bottom controls */}
+
+ {/* Decline */}
+
+
+
+
+ 拒绝
+
+
+ {/* Accept */}
+
+
+
+
+ 接听
+
+
+
+
+ );
+};
+
+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;
diff --git a/src/components/call/index.ts b/src/components/call/index.ts
new file mode 100644
index 0000000..bcfc64a
--- /dev/null
+++ b/src/components/call/index.ts
@@ -0,0 +1,3 @@
+export { default as CallScreen } from './CallScreen';
+export { default as IncomingCallModal } from './IncomingCallModal';
+export { default as FloatingCallWindow } from './FloatingCallWindow';
diff --git a/src/screens/message/ChatScreen.tsx b/src/screens/message/ChatScreen.tsx
index b4f56fd..25c3177 100644
--- a/src/screens/message/ChatScreen.tsx
+++ b/src/screens/message/ChatScreen.tsx
@@ -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}
/>
{/* 消息列表 */}
diff --git a/src/screens/message/components/ChatScreen/ChatHeader.tsx b/src/screens/message/components/ChatScreen/ChatHeader.tsx
index 6945dac..bc6d1ad 100644
--- a/src/screens/message/components/ChatScreen/ChatHeader.tsx
+++ b/src/screens/message/components/ChatScreen/ChatHeader.tsx
@@ -23,6 +23,7 @@ export const ChatHeader: React.FC = ({
onMorePress,
onGroupInfoPress,
isWideScreen: propIsWideScreen,
+ onCallPress,
}) => {
const colors = useAppColors();
const baseStyles = useChatScreenStyles();
@@ -124,13 +125,30 @@ export const ChatHeader: React.FC = ({
>
- ) : (
+ ) : isGroupChat ? (
+ ) : (
+
+ {onCallPress && (
+
+
+
+ )}
+
+
+
+
)}
diff --git a/src/screens/message/components/ChatScreen/styles.ts b/src/screens/message/components/ChatScreen/styles.ts
index 8b8766a..ab60367 100644
--- a/src/screens/message/components/ChatScreen/styles.ts
+++ b/src/screens/message/components/ChatScreen/styles.ts
@@ -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,
diff --git a/src/screens/message/components/ChatScreen/types.ts b/src/screens/message/components/ChatScreen/types.ts
index 866f6bd..6efebe3 100644
--- a/src/screens/message/components/ChatScreen/types.ts
+++ b/src/screens/message/components/ChatScreen/types.ts
@@ -179,6 +179,8 @@ export interface ChatHeaderProps {
onGroupInfoPress?: () => void;
/** 是否为大屏幕模式 */
isWideScreen?: boolean;
+ /** 拨打电话回调(仅私聊有效) */
+ onCallPress?: () => void;
}
// 回复预览 Props
diff --git a/src/services/webrtc/WebRTCManager.ts b/src/services/webrtc/WebRTCManager.ts
new file mode 100644
index 0000000..02dee76
--- /dev/null
+++ b/src/services/webrtc/WebRTCManager.ts
@@ -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 = new Set();
+ private disposed = false;
+ private isInitiator = false;
+
+ async initialize(iceServers: ICEServer[] = []): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ await this.setRemoteDescription(offer);
+ return this.createAnswer();
+ }
+
+ async setRemoteDescription(description: RTCSessionDescriptionInit): Promise {
+ 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 {
+ 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 {
+ 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();
diff --git a/src/services/webrtc/index.ts b/src/services/webrtc/index.ts
new file mode 100644
index 0000000..1c1d65d
--- /dev/null
+++ b/src/services/webrtc/index.ts
@@ -0,0 +1,2 @@
+export { webrtcManager } from './WebRTCManager';
+export type { ICEServer, ConnectionState, WebRTCManagerEvent } from './WebRTCManager';
diff --git a/src/services/wsService.ts b/src/services/wsService.ts
index edca371..28641f6 100644
--- a/src/services/wsService.ts
+++ b/src/services/wsService.ts
@@ -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 = (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 {
return new Promise((resolve, reject) => {
diff --git a/src/stores/authStore.ts b/src/stores/authStore.ts
index d4ad174..580022d 100644
--- a/src/stores/authStore.ts
+++ b/src/stores/authStore.ts
@@ -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 {
try {
await wsService.start();
+ callStore.getState().initCall();
} catch (error) {
console.error('[AuthStore] 启动 SSE 服务失败:', error);
}
diff --git a/src/stores/callStore.ts b/src/stores/callStore.ts
new file mode 100644
index 0000000..24db71c
--- /dev/null
+++ b/src/stores/callStore.ts
@@ -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;
+ acceptCall: () => Promise;
+ rejectCall: () => void;
+ endCall: (reason?: string) => Promise;
+ toggleMute: () => void;
+ toggleSpeaker: () => void;
+ toggleMinimize: () => void;
+}
+
+let durationTimer: ReturnType | null = null;
+let callTimeoutTimer: ReturnType | 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(); // 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((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 }));
+ },
+}));
diff --git a/src/stores/index.ts b/src/stores/index.ts
index b7d21a6..d71b1b6 100644
--- a/src/stores/index.ts
+++ b/src/stores/index.ts
@@ -47,6 +47,7 @@ export {
export {
useHomeTabPressStore,
} from './homeTabPressStore';
+export { callStore } from './callStore';
export {
useAppColors,
useThemePreference,