feat(call): implement CallKeep integration and enhance group call support
Integrate `expo-callkit-telecom` to support native system call handling (answering, ending, and muting via system UI). This includes a new `callKeepService` and a `CallKeepBootstrap` component to manage the lifecycle of system call events. Additionally, improves the calling experience by: - Adding support for group calls with participant tracking and UI indicators. - Enhancing LiveKit integration by replacing polling with event-driven video track synchronization. - Updating `WebSocketService` to prevent disconnection when the app enters the background during an active call. - Adding new WebSocket message types for participant join/leave events and group invites. - Refining call UI components (`CallScreen`, `FloatingCallWindow`, `IncomingCallModal`) for better visual feedback and safe area handling. Refactor LiveKit service to use event-driven updates for local and remote tracks, improving performance and reliability.
This commit is contained in:
1
app.json
1
app.json
@@ -153,6 +153,7 @@
|
||||
}
|
||||
],
|
||||
"./plugins/withHuaweiPush",
|
||||
"expo-callkit-telecom",
|
||||
"expo-status-bar"
|
||||
],
|
||||
"extra": {
|
||||
|
||||
@@ -28,6 +28,8 @@ import { useAuthStore } from '../src/stores';
|
||||
import { checkForAPKUpdate } from '@/services/platform';
|
||||
import { CallScreen, IncomingCallModal, FloatingCallWindow } from '../src/components/call';
|
||||
import { jpushService } from '@/services/notification/jpushService';
|
||||
import { callKeepService } from '@/services/callkeep';
|
||||
import { callStore } from '@/stores/call';
|
||||
|
||||
registerNotificationPresentationHandler();
|
||||
|
||||
@@ -237,6 +239,37 @@ function APKUpdateBootstrap() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function CallKeepBootstrap() {
|
||||
useEffect(() => {
|
||||
if (Platform.OS === 'web') return;
|
||||
|
||||
callKeepService.initialize({
|
||||
onSystemAnswered: () => {
|
||||
callStore.getState().handleSystemAnswer();
|
||||
},
|
||||
onSystemEnded: () => {
|
||||
callStore.getState().endCall('ended');
|
||||
},
|
||||
onSystemMuted: (muted: boolean) => {
|
||||
callStore.getState().handleSystemMuted(muted);
|
||||
},
|
||||
onIncomingCallFromPush: (session) => {
|
||||
callStore.getState().handleIncomingFromPush(session);
|
||||
},
|
||||
onOutgoingStarted: () => {
|
||||
// Outgoing call accepted by system — no action needed,
|
||||
// the WS call_invited / call_accepted flow handles everything
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
callKeepService.dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function ThemedStack() {
|
||||
const router = useRouter();
|
||||
const resolved = useResolvedColorScheme();
|
||||
@@ -249,6 +282,7 @@ function ThemedStack() {
|
||||
<SessionGate>
|
||||
<NotificationBootstrap />
|
||||
<APKUpdateBootstrap />
|
||||
<CallKeepBootstrap />
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
|
||||
|
||||
563
package-lock.json
generated
563
package-lock.json
generated
@@ -11,13 +11,14 @@
|
||||
"@expo/vector-icons": "^15.1.1",
|
||||
"@livekit/react-native": "^2.11.0",
|
||||
"@livekit/react-native-webrtc": "^144.1.0",
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
"@react-native-async-storage/async-storage": "^3.1.1",
|
||||
"@shopify/flash-list": "^2.3.1",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"axios": "^1.13.6",
|
||||
"date-fns": "^4.1.0",
|
||||
"@tanstack/react-query": "^5.100.14",
|
||||
"axios": "^1.16.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"expo": "^56.0.8",
|
||||
"expo-background-task": "~56.0.16",
|
||||
"expo-callkit-telecom": "^0.3.8",
|
||||
"expo-camera": "~56.0.7",
|
||||
"expo-constants": "~56.0.16",
|
||||
"expo-dev-client": "~56.0.18",
|
||||
@@ -38,34 +39,34 @@
|
||||
"expo-task-manager": "~56.0.16",
|
||||
"expo-updates": "~56.0.17",
|
||||
"expo-video": "~56.1.2",
|
||||
"jcore-react-native": "^2.3.5",
|
||||
"jpush-react-native": "^3.2.6",
|
||||
"katex": "^0.16.42",
|
||||
"jcore-react-native": "^2.3.6",
|
||||
"jpush-react-native": "^3.2.7",
|
||||
"katex": "^0.17.0",
|
||||
"livekit-client": "^2.19.0",
|
||||
"markdown-it": "^14.1.1",
|
||||
"markdown-it": "^14.2.0",
|
||||
"pako": "^2.1.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-native": "0.85.3",
|
||||
"react-native-gesture-handler": "~2.31.1",
|
||||
"react-native-pager-view": "8.0.1",
|
||||
"react-native-paper": "^5.15.0",
|
||||
"react-native-reanimated": "4.3.1",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-gesture-handler": "^3.0.0",
|
||||
"react-native-pager-view": "^8.0.2",
|
||||
"react-native-paper": "^5.15.3",
|
||||
"react-native-reanimated": "^4.4.0",
|
||||
"react-native-safe-area-context": "~5.8.0",
|
||||
"react-native-screens": "4.25.2",
|
||||
"react-native-share": "^12.2.6",
|
||||
"react-native-share": "^12.3.1",
|
||||
"react-native-sse": "^1.2.1",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-webview": "13.16.1",
|
||||
"react-native-worklets": "0.8.3",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.11"
|
||||
"react-native-worklets": "^0.9.1",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-native-community/cli": "^20.1.2",
|
||||
"@react-native-community/cli": "^20.1.3",
|
||||
"@types/pako": "^2.0.4",
|
||||
"@types/react": "~19.2.2",
|
||||
"@types/react": "~19.2.16",
|
||||
"@types/react-native-vector-icons": "^6.4.18",
|
||||
"typescript": "~6.0.3"
|
||||
}
|
||||
@@ -1197,18 +1198,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@egjs/hammerjs": {
|
||||
"version": "2.0.17",
|
||||
"resolved": "https://registry.npmmirror.com/@egjs/hammerjs/-/hammerjs-2.0.17.tgz",
|
||||
"integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hammerjs": "^2.0.36"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@expo-google-fonts/material-symbols": {
|
||||
"version": "0.4.38",
|
||||
"resolved": "https://registry.npmmirror.com/@expo-google-fonts/material-symbols/-/material-symbols-0.4.38.tgz",
|
||||
@@ -2267,6 +2256,19 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@nodable/entities": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/@nodable/entities/-/entities-2.1.1.tgz",
|
||||
"integrity": "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==",
|
||||
"devOptional": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodable"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -2793,30 +2795,31 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native-async-storage/async-storage": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
|
||||
"integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==",
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/@react-native-async-storage/async-storage/-/async-storage-3.1.1.tgz",
|
||||
"integrity": "sha512-z+PnLz1n6ECKhgoHZHkfc+dijXZEyZnNFSajbtE0NEbsJhmX8x9GlOeiMQIKX2E4DUqPSgfIh4FYBv1M49KgPQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"merge-options": "^3.0.4"
|
||||
"idb": "8.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react-native": "^0.0.0-0 || >=0.65 <1.0"
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native-community/cli": {
|
||||
"version": "20.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/@react-native-community/cli/-/cli-20.1.2.tgz",
|
||||
"integrity": "sha512-48GRnGfm1+4ueV8ESNJmKAKW01QdbB8H6qrVxCqpHYvuRkeFBaPpwRY2bEjSwqruw3Pn9ppzGfpAxYQDiUWQxQ==",
|
||||
"version": "20.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/@react-native-community/cli/-/cli-20.1.3.tgz",
|
||||
"integrity": "sha512-sLo8cu9JyFNfuuF1C+8NJ4DHE/PEFaXGd4enkcxi/OJjGG8+sOQrdjNQ4i+cVh/2c+ah1mEMwsYjc3z0+/MqSg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-native-community/cli-clean": "20.1.2",
|
||||
"@react-native-community/cli-config": "20.1.2",
|
||||
"@react-native-community/cli-doctor": "20.1.2",
|
||||
"@react-native-community/cli-server-api": "20.1.2",
|
||||
"@react-native-community/cli-tools": "20.1.2",
|
||||
"@react-native-community/cli-types": "20.1.2",
|
||||
"@react-native-community/cli-clean": "20.1.3",
|
||||
"@react-native-community/cli-config": "20.1.3",
|
||||
"@react-native-community/cli-doctor": "20.1.3",
|
||||
"@react-native-community/cli-server-api": "20.1.3",
|
||||
"@react-native-community/cli-tools": "20.1.3",
|
||||
"@react-native-community/cli-types": "20.1.3",
|
||||
"commander": "^9.4.1",
|
||||
"deepmerge": "^4.3.0",
|
||||
"execa": "^5.0.0",
|
||||
@@ -2835,26 +2838,26 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native-community/cli-clean": {
|
||||
"version": "20.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/@react-native-community/cli-clean/-/cli-clean-20.1.2.tgz",
|
||||
"integrity": "sha512-XcNlmFnYOVDjvHQQn0qreI4FPLEUx8p43EdOmKbtzqwqc9T/EdBdzUnUc5wWQPO1CN7BdMfxW8fyvosz8NIlrg==",
|
||||
"version": "20.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/@react-native-community/cli-clean/-/cli-clean-20.1.3.tgz",
|
||||
"integrity": "sha512-sFLdLzapfC0scjgzBJJWYDY2RhHPjuuPkA5r6q0gc/UQH/izXpMpLrhh1DW84cMDraNACK0U62tU7ebNaQ1LMQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-native-community/cli-tools": "20.1.2",
|
||||
"@react-native-community/cli-tools": "20.1.3",
|
||||
"execa": "^5.0.0",
|
||||
"fast-glob": "^3.3.2",
|
||||
"picocolors": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native-community/cli-config": {
|
||||
"version": "20.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/@react-native-community/cli-config/-/cli-config-20.1.2.tgz",
|
||||
"integrity": "sha512-7aPE14QA8aXpfuQ1jmfiBfjC/N6gdbg6PpBTwb3cl8c/KaeVm+OQYoC2kn2b3XS0NPgw5Ix/VxVaX6AAUQRFuA==",
|
||||
"version": "20.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/@react-native-community/cli-config/-/cli-config-20.1.3.tgz",
|
||||
"integrity": "sha512-n73nW0cG92oNF0r994pPqm0DjAShOm3F8LSffDYhJqNAno+h/csmv/37iL4NtSpmKIO8xqsG3uVTXz9X/hzNaQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-native-community/cli-tools": "20.1.2",
|
||||
"@react-native-community/cli-tools": "20.1.3",
|
||||
"cosmiconfig": "^9.0.0",
|
||||
"deepmerge": "^4.3.0",
|
||||
"fast-glob": "^3.3.2",
|
||||
@@ -2863,43 +2866,43 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native-community/cli-config-android": {
|
||||
"version": "20.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/@react-native-community/cli-config-android/-/cli-config-android-20.1.2.tgz",
|
||||
"integrity": "sha512-W0Qx+lW8pbQMz8x3Rlf/H7D2j2u8O+u9HnrZnKzDl1DaXgaOQqL484lTZlMEQofjq7eLXdmzWxuZdqS6K1QfmQ==",
|
||||
"version": "20.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/@react-native-community/cli-config-android/-/cli-config-android-20.1.3.tgz",
|
||||
"integrity": "sha512-DNHDP+OWLyhKShGciBqPcxhxfp1Z/7GQcb4F+TGyCeKQAr+JdnUjRXN3X+YCU/v+g2kbYYyRJKlGabzkVvdrAw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-native-community/cli-tools": "20.1.2",
|
||||
"@react-native-community/cli-tools": "20.1.3",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fast-xml-parser": "^5.3.6",
|
||||
"picocolors": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native-community/cli-config-apple": {
|
||||
"version": "20.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/@react-native-community/cli-config-apple/-/cli-config-apple-20.1.2.tgz",
|
||||
"integrity": "sha512-Dhi1N1EoMMmJ4dnDlmNWCrJggfv7X/kl3l8uax72uaxepQI/CfohJP2rBdG2mWis+vzrCIk14z2keY0ixxsN8g==",
|
||||
"version": "20.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/@react-native-community/cli-config-apple/-/cli-config-apple-20.1.3.tgz",
|
||||
"integrity": "sha512-QX9B83nAfCPs0KiaYz61kAEHWr9sttooxzRzNdQwvZTwnsIpvWOT9GvMMj/19OeXiQzMJBzZX0Pgt6+spiUsDQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-native-community/cli-tools": "20.1.2",
|
||||
"@react-native-community/cli-tools": "20.1.3",
|
||||
"execa": "^5.0.0",
|
||||
"fast-glob": "^3.3.2",
|
||||
"picocolors": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native-community/cli-doctor": {
|
||||
"version": "20.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/@react-native-community/cli-doctor/-/cli-doctor-20.1.2.tgz",
|
||||
"integrity": "sha512-bbT1EhomvHz5ZtzxY2czA4/JMXhP4aIAxRDsqiW6wfZA9A9/HXqA4uv6CxP0wZUUmovmPNRl3kW/LWXrRmdv3A==",
|
||||
"version": "20.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/@react-native-community/cli-doctor/-/cli-doctor-20.1.3.tgz",
|
||||
"integrity": "sha512-EI+mAPWn255/WZ4CQohy1I049yiaxVr41C3BeQ2BCyhxODIDR8XRsLzYb1t9MfqK/C3ZncUN2mPSRXFeKPPI1w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-native-community/cli-config": "20.1.2",
|
||||
"@react-native-community/cli-platform-android": "20.1.2",
|
||||
"@react-native-community/cli-platform-apple": "20.1.2",
|
||||
"@react-native-community/cli-platform-ios": "20.1.2",
|
||||
"@react-native-community/cli-tools": "20.1.2",
|
||||
"@react-native-community/cli-config": "20.1.3",
|
||||
"@react-native-community/cli-platform-android": "20.1.3",
|
||||
"@react-native-community/cli-platform-apple": "20.1.3",
|
||||
"@react-native-community/cli-platform-ios": "20.1.3",
|
||||
"@react-native-community/cli-tools": "20.1.3",
|
||||
"command-exists": "^1.2.8",
|
||||
"deepmerge": "^4.3.0",
|
||||
"envinfo": "^7.13.0",
|
||||
@@ -2913,9 +2916,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native-community/cli-doctor/node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"version": "7.8.1",
|
||||
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.1.tgz",
|
||||
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
|
||||
"devOptional": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
@@ -2926,51 +2929,51 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native-community/cli-platform-android": {
|
||||
"version": "20.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/@react-native-community/cli-platform-android/-/cli-platform-android-20.1.2.tgz",
|
||||
"integrity": "sha512-1iHB8cTTJpMyEKhxWTRYsxhBBsiOq3tVguGX/HtBdHRzhlCeDpanE6U+aKxWfMFetMcFL+HLe5nQPcJXf9EtKg==",
|
||||
"version": "20.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/@react-native-community/cli-platform-android/-/cli-platform-android-20.1.3.tgz",
|
||||
"integrity": "sha512-bzB9ELPOISuqgtDZXFPQlkuxx1YFkNx3cNgslc5ElCrk+5LeCLQLIBh/dmIuK8rwUrPcrramjeBj++Noc+TaAA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-native-community/cli-config-android": "20.1.2",
|
||||
"@react-native-community/cli-tools": "20.1.2",
|
||||
"@react-native-community/cli-config-android": "20.1.3",
|
||||
"@react-native-community/cli-tools": "20.1.3",
|
||||
"execa": "^5.0.0",
|
||||
"logkitty": "^0.7.1",
|
||||
"picocolors": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native-community/cli-platform-apple": {
|
||||
"version": "20.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/@react-native-community/cli-platform-apple/-/cli-platform-apple-20.1.2.tgz",
|
||||
"integrity": "sha512-UvzjcRGotO3E2xaty8YWE2XMGkkDDaXRtQtNRjzmtcoNY40C+y4iMHxd0o3xbD0bzYM/PO79tXye9MxTWdyVkg==",
|
||||
"version": "20.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/@react-native-community/cli-platform-apple/-/cli-platform-apple-20.1.3.tgz",
|
||||
"integrity": "sha512-XJ+DqAD4hkplWVXK5AMgN7pP9+4yRSe5KfZ/b42+ofkDBI55ALlUmX+9HWE3fMuRjcotTCoNZqX2ov97cFDXpQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-native-community/cli-config-apple": "20.1.2",
|
||||
"@react-native-community/cli-tools": "20.1.2",
|
||||
"@react-native-community/cli-config-apple": "20.1.3",
|
||||
"@react-native-community/cli-tools": "20.1.3",
|
||||
"execa": "^5.0.0",
|
||||
"fast-xml-parser": "^5.3.6",
|
||||
"picocolors": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native-community/cli-platform-ios": {
|
||||
"version": "20.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/@react-native-community/cli-platform-ios/-/cli-platform-ios-20.1.2.tgz",
|
||||
"integrity": "sha512-ZzdLwJMt7ehjO0iy/rQGPgH6uZqMYXeS5uXzSi1DeLYwurV1wOqFc0SLm4TAz5FKYQmHpwBXlMiI12rUmkZxcg==",
|
||||
"version": "20.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/@react-native-community/cli-platform-ios/-/cli-platform-ios-20.1.3.tgz",
|
||||
"integrity": "sha512-2qL48SINotuHbZO73cgqSwqd/OWNx0xTbFSdujhpogV4p8BNwYYypfjh4vJY5qJEB5PxuoVkMXT+aCADpg9nBg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-native-community/cli-platform-apple": "20.1.2"
|
||||
"@react-native-community/cli-platform-apple": "20.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native-community/cli-server-api": {
|
||||
"version": "20.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/@react-native-community/cli-server-api/-/cli-server-api-20.1.2.tgz",
|
||||
"integrity": "sha512-ZlINtIYoDAwSemwTU9OavI1IixCCmAPPw1s3Mp0cOvrddFSZ0hx1N1IR+imLyo4lhFfM8OO3rUe9oVJj1SHUCA==",
|
||||
"version": "20.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/@react-native-community/cli-server-api/-/cli-server-api-20.1.3.tgz",
|
||||
"integrity": "sha512-hsNsdUKZDd2T99OuNuiXz4VuvLa1UN0zcxefmPjXQgI0byrBLzzDr+o7p03sKuODSzKi2h+BMnUxiS07HACQLA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-native-community/cli-tools": "20.1.2",
|
||||
"@react-native-community/cli-tools": "20.1.3",
|
||||
"body-parser": "^2.2.2",
|
||||
"compression": "^1.7.1",
|
||||
"connect": "^3.6.5",
|
||||
@@ -2984,9 +2987,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native-community/cli-tools": {
|
||||
"version": "20.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/@react-native-community/cli-tools/-/cli-tools-20.1.2.tgz",
|
||||
"integrity": "sha512-on2VUBZb68RlMxvIrEdK6+NiOEYu/z+t/cz746yGtxn49fwW6Wafzmh1QNZj8HPAuZ8+Ds61LiXbwoDDkzNSSA==",
|
||||
"version": "20.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/@react-native-community/cli-tools/-/cli-tools-20.1.3.tgz",
|
||||
"integrity": "sha512-EAn0vPCMxtHhfWk2UwLmSUfPfLUnFgC7NjiVJVTKJyVk5qGnkPfoT8te/1IUXFTysUB0F0RIi+NgDB4usFOLeA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3003,9 +3006,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native-community/cli-tools/node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"version": "7.8.1",
|
||||
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.1.tgz",
|
||||
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
|
||||
"devOptional": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
@@ -3016,9 +3019,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native-community/cli-types": {
|
||||
"version": "20.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/@react-native-community/cli-types/-/cli-types-20.1.2.tgz",
|
||||
"integrity": "sha512-WYK98VdcJE+lRuyRzigE/GQAbaJZOKkjpaLwhmMMItXVTqMmIccfGu9b4pRoQOVfs1aLq87DuwUOi9sxz6OG1g==",
|
||||
"version": "20.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/@react-native-community/cli-types/-/cli-types-20.1.3.tgz",
|
||||
"integrity": "sha512-IdAcegf0pH1hVraxWTG1ACLkYC0LDQfqtaEf42ESyLIF3Xap70JzL/9tAlxw7lSCPZPFWhrcgU0TBc4SkC/ecw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3400,9 +3403,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.91.2",
|
||||
"resolved": "https://registry.npmmirror.com/@tanstack/query-core/-/query-core-5.91.2.tgz",
|
||||
"integrity": "sha512-Uz2pTgPC1mhqrrSGg18RKCWT/pkduAYtxbcyIyKBhw7dTWjXZIzqmpzO2lBkyWr4hlImQgpu1m1pei3UnkFRWw==",
|
||||
"version": "5.100.14",
|
||||
"resolved": "https://registry.npmmirror.com/@tanstack/query-core/-/query-core-5.100.14.tgz",
|
||||
"integrity": "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -3410,12 +3413,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.91.2",
|
||||
"resolved": "https://registry.npmmirror.com/@tanstack/react-query/-/react-query-5.91.2.tgz",
|
||||
"integrity": "sha512-GClLPzbM57iFXv+FlvOUL56XVe00PxuTaVEyj1zAObhRiKF008J5vedmaq7O6ehs+VmPHe8+PUQhMuEyv8d9wQ==",
|
||||
"version": "5.100.14",
|
||||
"resolved": "https://registry.npmmirror.com/@tanstack/react-query/-/react-query-5.100.14.tgz",
|
||||
"integrity": "sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.91.2"
|
||||
"@tanstack/query-core": "5.100.14"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -3538,12 +3541,6 @@
|
||||
"integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/hammerjs": {
|
||||
"version": "2.0.46",
|
||||
"resolved": "https://registry.npmmirror.com/@types/hammerjs/-/hammerjs-2.0.46.tgz",
|
||||
"integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/istanbul-lib-coverage": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmmirror.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
||||
@@ -3585,9 +3582,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"version": "19.2.16",
|
||||
"resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.16.tgz",
|
||||
"integrity": "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
@@ -3961,14 +3958,40 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.6",
|
||||
"resolved": "https://registry.npmmirror.com/axios/-/axios-1.13.6.tgz",
|
||||
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmmirror.com/axios/-/axios-1.16.1.tgz",
|
||||
"integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"follow-redirects": "^1.16.0",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
"https-proxy-agent": "^5.0.1",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axios/node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axios/node_modules/https-proxy-agent": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-plugin-polyfill-corejs2": {
|
||||
@@ -4883,9 +4906,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/date-fns/-/date-fns-4.4.0.tgz",
|
||||
"integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -4893,9 +4916,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.20",
|
||||
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.20.tgz",
|
||||
"integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==",
|
||||
"version": "1.11.21",
|
||||
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz",
|
||||
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -5512,6 +5535,19 @@
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-callkit-telecom": {
|
||||
"version": "0.3.8",
|
||||
"resolved": "https://registry.npmmirror.com/expo-callkit-telecom/-/expo-callkit-telecom-0.3.8.tgz",
|
||||
"integrity": "sha512-Jh3tOMBD5JHCMF8EumOSqT/bQgWUhmCn9EwMZm9LT1mhWWmNQ7wKwAOYGdF7P4i4dtPDj0HG7u7fuljzt4bmQw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@livekit/react-native-webrtc": ">=144.0.0",
|
||||
"expo": "*",
|
||||
"expo-notifications": "*",
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-camera": {
|
||||
"version": "56.0.7",
|
||||
"resolved": "https://registry.npmmirror.com/expo-camera/-/expo-camera-56.0.7.tgz",
|
||||
@@ -6420,9 +6456,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/fast-xml-builder": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmmirror.com/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
|
||||
"integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==",
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz",
|
||||
"integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==",
|
||||
"devOptional": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -6432,13 +6468,14 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-expression-matcher": "^1.1.3"
|
||||
"path-expression-matcher": "^1.5.0",
|
||||
"xml-naming": "^0.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.5.7",
|
||||
"resolved": "https://registry.npmmirror.com/fast-xml-parser/-/fast-xml-parser-5.5.7.tgz",
|
||||
"integrity": "sha512-LteOsISQ2GEiDHZch6L9hB0+MLoYVLToR7xotrzU0opCICBkxOPgHAy1HxAvtxfJNXDJpgAsQN30mkrfpO2Prg==",
|
||||
"version": "5.8.0",
|
||||
"resolved": "https://registry.npmmirror.com/fast-xml-parser/-/fast-xml-parser-5.8.0.tgz",
|
||||
"integrity": "sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==",
|
||||
"devOptional": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -6448,9 +6485,11 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-xml-builder": "^1.1.4",
|
||||
"path-expression-matcher": "^1.1.3",
|
||||
"strnum": "^2.2.0"
|
||||
"@nodable/entities": "^2.1.0",
|
||||
"fast-xml-builder": "^1.2.0",
|
||||
"path-expression-matcher": "^1.5.0",
|
||||
"strnum": "^2.3.0",
|
||||
"xml-naming": "^0.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"fxparser": "src/cli/cli.js"
|
||||
@@ -6630,9 +6669,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -7140,6 +7179,12 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/idb": {
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/idb/-/idb-8.0.3.tgz",
|
||||
"integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz",
|
||||
@@ -7549,15 +7594,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-plain-obj": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
|
||||
"integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-regex": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/is-regex/-/is-regex-1.2.1.tgz",
|
||||
@@ -7765,9 +7801,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jcore-react-native": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmmirror.com/jcore-react-native/-/jcore-react-native-2.3.5.tgz",
|
||||
"integrity": "sha512-rxkDPGiSyNVHjNibUGeqFaV07PbMqKZnoHJa0srt002QGUH4/9K4Ux2tK0EkqHLFvGFsJqGWwjaxEqQIPvoPTA==",
|
||||
"version": "2.3.6",
|
||||
"resolved": "https://registry.npmmirror.com/jcore-react-native/-/jcore-react-native-2.3.6.tgz",
|
||||
"integrity": "sha512-ImaqjrVDA36ceYqullW4tR109OBypvBlXu4nozBLifjIqcLuAjAKHF/vxmEehaifSEiAHOpLLcEElzOiY3Ypuw==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react-native": ">= 0.60"
|
||||
@@ -7903,9 +7939,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jpush-react-native": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmmirror.com/jpush-react-native/-/jpush-react-native-3.2.6.tgz",
|
||||
"integrity": "sha512-dnrdfwsZcAYsQLknVMRMFml/f5cDUFlVKaqpgN41au+g2lxYvPyY7v15XZbNiMdnAjzzVgPhmUW8kuptkLtDXw==",
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmmirror.com/jpush-react-native/-/jpush-react-native-3.2.7.tgz",
|
||||
"integrity": "sha512-oKZXNXnXMeyNBDmFEh70yVBsAhCyOgMmAT5VBiR3pfnk0cRgS/KQrDjng2flG9MPH8W9SKjov4gjQ5k/kCvHkA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"jcore-react-native": ">= 1.9.3",
|
||||
@@ -7978,9 +8014,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/katex": {
|
||||
"version": "0.16.42",
|
||||
"resolved": "https://registry.npmmirror.com/katex/-/katex-0.16.42.tgz",
|
||||
"integrity": "sha512-sZ4jqyEXfHTLEFK+qsFYToa3UZ0rtFcPGwKpyiRYh2NJn8obPWOQ+/u7ux0F6CAU/y78+Mksh1YkxTPXTh47TQ==",
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmmirror.com/katex/-/katex-0.17.0.tgz",
|
||||
"integrity": "sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw==",
|
||||
"funding": [
|
||||
"https://opencollective.com/katex",
|
||||
"https://github.com/sponsors/katex"
|
||||
@@ -8021,14 +8057,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/launch-editor": {
|
||||
"version": "2.13.1",
|
||||
"resolved": "https://registry.npmmirror.com/launch-editor/-/launch-editor-2.13.1.tgz",
|
||||
"integrity": "sha512-lPSddlAAluRKJ7/cjRFoXUFzaX7q/YKI7yPHuEvSJVqoXvFnJov1/Ud87Aa4zULIbA9Nja4mSPK8l0z/7eV2wA==",
|
||||
"version": "2.14.1",
|
||||
"resolved": "https://registry.npmmirror.com/launch-editor/-/launch-editor-2.14.1.tgz",
|
||||
"integrity": "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"picocolors": "^1.1.1",
|
||||
"shell-quote": "^1.8.3"
|
||||
"shell-quote": "^1.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/leven": {
|
||||
@@ -8322,9 +8358,19 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/linkify-it": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/linkify-it/-/linkify-it-5.0.1.tgz",
|
||||
"integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/markdown-it"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uc.micro": "^2.0.0"
|
||||
@@ -8614,14 +8660,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-it": {
|
||||
"version": "14.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/markdown-it/-/markdown-it-14.1.1.tgz",
|
||||
"integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
|
||||
"version": "14.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/markdown-it/-/markdown-it-14.2.0.tgz",
|
||||
"integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/markdown-it"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1",
|
||||
"entities": "^4.4.0",
|
||||
"linkify-it": "^5.0.0",
|
||||
"linkify-it": "^5.0.1",
|
||||
"mdurl": "^2.0.0",
|
||||
"punycode.js": "^2.3.1",
|
||||
"uc.micro": "^2.1.0"
|
||||
@@ -8667,18 +8723,6 @@
|
||||
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/merge-options": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmmirror.com/merge-options/-/merge-options-3.0.4.tgz",
|
||||
"integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-plain-obj": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/merge-stream/-/merge-stream-2.0.0.tgz",
|
||||
@@ -9649,9 +9693,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/path-expression-matcher": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz",
|
||||
"integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz",
|
||||
"integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==",
|
||||
"devOptional": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -9890,10 +9934,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
@@ -9914,9 +9961,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.0.tgz",
|
||||
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
|
||||
"version": "6.15.2",
|
||||
"resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.2.tgz",
|
||||
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||
"devOptional": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
@@ -10154,14 +10201,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-gesture-handler": {
|
||||
"version": "2.31.2",
|
||||
"resolved": "https://registry.npmmirror.com/react-native-gesture-handler/-/react-native-gesture-handler-2.31.2.tgz",
|
||||
"integrity": "sha512-rw5q74i2AfS7YGYdbxQDhOU7xqgY6WRM1132/CCm3erqjblhECZDZFHIm0tteHoC9ih24wogVBVVzcTBQtZ+5A==",
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/react-native-gesture-handler/-/react-native-gesture-handler-3.0.0.tgz",
|
||||
"integrity": "sha512-6E8o9D2sHwhFGiU0c4aCweMdJwIbQeBV+dq3IQ3HcqKhVGzg7ccEycap6i0zGCtIYfs3V29Xd4OycwcRj5qxBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@egjs/hammerjs": "^2.0.17",
|
||||
"@types/react-test-renderer": "^19.1.0",
|
||||
"hoist-non-react-statics": "^3.3.0",
|
||||
"invariant": "^2.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -10180,9 +10225,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-pager-view": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/react-native-pager-view/-/react-native-pager-view-8.0.1.tgz",
|
||||
"integrity": "sha512-pGOne2o0y0HOQLrlTLcGgOE48uJlqSZHRRwdW8nL6JJozMkPGJYi/G9e0EsJoWFpXYONjiDgr8IwxC4F6/r7Lg==",
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/react-native-pager-view/-/react-native-pager-view-8.0.2.tgz",
|
||||
"integrity": "sha512-Y8All2BbjidI4ZIF9kBOIIoldkqrY/2FXapHZMdjwD+ByXhiOWTFenPs5rq9KRN5uAH/gOXtQCqHLxQDNBQRiQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
@@ -10190,9 +10235,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-paper": {
|
||||
"version": "5.15.0",
|
||||
"resolved": "https://registry.npmmirror.com/react-native-paper/-/react-native-paper-5.15.0.tgz",
|
||||
"integrity": "sha512-I/1CQLfW9VM0Oo5I5dQI/hjgf1I6q2S1wwgzAdsv6whAQ3zO97GWHwtgNh9se9j8zBOJ86afPTQKxxUL0IJd9A==",
|
||||
"version": "5.15.3",
|
||||
"resolved": "https://registry.npmmirror.com/react-native-paper/-/react-native-paper-5.15.3.tgz",
|
||||
"integrity": "sha512-GEyNTmWElIZgnYw09AjjCNupRYzCmP79uAAyGSyCEUZz7KBz1wtJcC0wVUkozR1Rn3PK/td/9LlR6+F1hzmYvA==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"example",
|
||||
@@ -10235,9 +10280,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-native-reanimated": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/react-native-reanimated/-/react-native-reanimated-4.3.1.tgz",
|
||||
"integrity": "sha512-KhGsS0YkCA+gusgyzlf9hnqzVPIR398KTpqXyqq/+yYJJPAvyEEPKcxlB0xtOOXSMrR2A9uRKVARVQhZwrOh+Q==",
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/react-native-reanimated/-/react-native-reanimated-4.4.0.tgz",
|
||||
"integrity": "sha512-0XbC1SpF3JZOz5QfmTEx3vt8VkmkTlS05CBIOKEg5q5ZSNlGtlacntlhj5CrfZlN1ciHAeoliJouTC2cLGKbDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-native-is-edge-to-edge": "^1.3.1",
|
||||
@@ -10245,8 +10290,8 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "0.81 - 0.85",
|
||||
"react-native-worklets": "0.8.x"
|
||||
"react-native": "0.83 - 0.86",
|
||||
"react-native-worklets": "0.9.x"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-reanimated/node_modules/semver": {
|
||||
@@ -10262,9 +10307,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-safe-area-context": {
|
||||
"version": "5.7.0",
|
||||
"resolved": "https://registry.npmmirror.com/react-native-safe-area-context/-/react-native-safe-area-context-5.7.0.tgz",
|
||||
"integrity": "sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ==",
|
||||
"version": "5.8.0",
|
||||
"resolved": "https://registry.npmmirror.com/react-native-safe-area-context/-/react-native-safe-area-context-5.8.0.tgz",
|
||||
"integrity": "sha512-t+ZsAVzY/wWzzx34vqGbo3/as9EEESJdbyZNL7Yg5EYX+toYMtMqFoDDCvqZUi35eeGVsXc6pAaEk4edMwbuCQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
@@ -10286,9 +10331,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-share": {
|
||||
"version": "12.2.6",
|
||||
"resolved": "https://registry.npmmirror.com/react-native-share/-/react-native-share-12.2.6.tgz",
|
||||
"integrity": "sha512-K9jZCQaTIqSNG37kMVygU1rflVMJm2g0ikslnbbmQ7EgsckYpw7ipePyp01E64hG+HrWNl+z9ZnFWLCC6H+Tiw==",
|
||||
"version": "12.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/react-native-share/-/react-native-share-12.3.1.tgz",
|
||||
"integrity": "sha512-mRVRie0qKbtj+jWqJ2POPkeSd8SxnMp6aazFZVmq8ZbkUGtQLENR/L58ky8zH4VUEF/WYRmdBiBHWHVtzOewtQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
@@ -10359,28 +10404,28 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-worklets": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmmirror.com/react-native-worklets/-/react-native-worklets-0.8.3.tgz",
|
||||
"integrity": "sha512-oCBJROyLU7yG/1R8s0INMflygTH71bx+5XcYkH0CM938TlhSoVbiunE1WVW5FZa51vwYqfLie/IXMX2s1Kh3eg==",
|
||||
"version": "0.9.1",
|
||||
"resolved": "https://registry.npmmirror.com/react-native-worklets/-/react-native-worklets-0.9.1.tgz",
|
||||
"integrity": "sha512-kb6lGtBI5Ap41tvBPM09Np472r2GXuJ+jRApIFy1eXBk699eChG3U+lyqRC2/wz/VDpaJAy6i5XPcceNOoH3mA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/plugin-transform-arrow-functions": "^7.27.1",
|
||||
"@babel/plugin-transform-class-properties": "^7.27.1",
|
||||
"@babel/plugin-transform-classes": "^7.28.4",
|
||||
"@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1",
|
||||
"@babel/plugin-transform-optional-chaining": "^7.27.1",
|
||||
"@babel/plugin-transform-class-properties": "^7.28.6",
|
||||
"@babel/plugin-transform-classes": "^7.28.6",
|
||||
"@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6",
|
||||
"@babel/plugin-transform-optional-chaining": "^7.28.6",
|
||||
"@babel/plugin-transform-shorthand-properties": "^7.27.1",
|
||||
"@babel/plugin-transform-template-literals": "^7.27.1",
|
||||
"@babel/plugin-transform-unicode-regex": "^7.27.1",
|
||||
"@babel/preset-typescript": "^7.27.1",
|
||||
"@babel/preset-typescript": "^7.28.5",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"semver": "^7.7.3"
|
||||
"semver": "^7.7.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "*",
|
||||
"@react-native/metro-config": "*",
|
||||
"react": "*",
|
||||
"react-native": "0.81 - 0.85"
|
||||
"react-native": "0.83 - 0.86"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-worklets/node_modules/semver": {
|
||||
@@ -11082,9 +11127,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmmirror.com/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||
"version": "1.8.4",
|
||||
"resolved": "https://registry.npmmirror.com/shell-quote/-/shell-quote-1.8.4.tgz",
|
||||
"integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -11513,9 +11558,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/strnum": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/strnum/-/strnum-2.2.1.tgz",
|
||||
"integrity": "sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==",
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/strnum/-/strnum-2.3.0.tgz",
|
||||
"integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==",
|
||||
"devOptional": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -11703,18 +11748,36 @@
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/type-is/-/type-is-2.0.1.tgz",
|
||||
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/type-is/-/type-is-2.1.0.tgz",
|
||||
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"content-type": "^1.0.5",
|
||||
"content-type": "^2.0.0",
|
||||
"media-typer": "^1.1.0",
|
||||
"mime-types": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is/node_modules/content-type": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/content-type/-/content-type-2.0.0.tgz",
|
||||
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is/node_modules/mime-types": {
|
||||
@@ -12363,9 +12426,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/ws/-/ws-6.2.3.tgz",
|
||||
"integrity": "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==",
|
||||
"version": "6.2.4",
|
||||
"resolved": "https://registry.npmmirror.com/ws/-/ws-6.2.4.tgz",
|
||||
"integrity": "sha512-PNIUUyLI5YpkJZj60YBzX1o0ByQ4ovvfmq9N/Kig/PAYbVlGyz4R6G0SEWrD0O9acc0sT2+IdMBVLFv8FSi0Nw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -12385,6 +12448,22 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xml-naming": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/xml-naming/-/xml-naming-0.1.0.tgz",
|
||||
"integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==",
|
||||
"devOptional": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xml2js": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmmirror.com/xml2js/-/xml2js-0.6.0.tgz",
|
||||
@@ -12487,18 +12566,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmmirror.com/zod/-/zod-4.3.6.tgz",
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmmirror.com/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.12",
|
||||
"resolved": "https://registry.npmmirror.com/zustand/-/zustand-5.0.12.tgz",
|
||||
"integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==",
|
||||
"version": "5.0.14",
|
||||
"resolved": "https://registry.npmmirror.com/zustand/-/zustand-5.0.14.tgz",
|
||||
"integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
|
||||
39
package.json
39
package.json
@@ -17,13 +17,14 @@
|
||||
"@expo/vector-icons": "^15.1.1",
|
||||
"@livekit/react-native": "^2.11.0",
|
||||
"@livekit/react-native-webrtc": "^144.1.0",
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
"@react-native-async-storage/async-storage": "^3.1.1",
|
||||
"@shopify/flash-list": "^2.3.1",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"axios": "^1.13.6",
|
||||
"date-fns": "^4.1.0",
|
||||
"@tanstack/react-query": "^5.100.14",
|
||||
"axios": "^1.16.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"expo": "^56.0.8",
|
||||
"expo-background-task": "~56.0.16",
|
||||
"expo-callkit-telecom": "^0.3.8",
|
||||
"expo-camera": "~56.0.7",
|
||||
"expo-constants": "~56.0.16",
|
||||
"expo-dev-client": "~56.0.18",
|
||||
@@ -44,34 +45,34 @@
|
||||
"expo-task-manager": "~56.0.16",
|
||||
"expo-updates": "~56.0.17",
|
||||
"expo-video": "~56.1.2",
|
||||
"jcore-react-native": "^2.3.5",
|
||||
"jpush-react-native": "^3.2.6",
|
||||
"katex": "^0.16.42",
|
||||
"jcore-react-native": "^2.3.6",
|
||||
"jpush-react-native": "^3.2.7",
|
||||
"katex": "^0.17.0",
|
||||
"livekit-client": "^2.19.0",
|
||||
"markdown-it": "^14.1.1",
|
||||
"markdown-it": "^14.2.0",
|
||||
"pako": "^2.1.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-native": "0.85.3",
|
||||
"react-native-gesture-handler": "~2.31.1",
|
||||
"react-native-pager-view": "8.0.1",
|
||||
"react-native-paper": "^5.15.0",
|
||||
"react-native-reanimated": "4.3.1",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-gesture-handler": "^3.0.0",
|
||||
"react-native-pager-view": "^8.0.2",
|
||||
"react-native-paper": "^5.15.3",
|
||||
"react-native-reanimated": "^4.4.0",
|
||||
"react-native-safe-area-context": "~5.8.0",
|
||||
"react-native-screens": "4.25.2",
|
||||
"react-native-share": "^12.2.6",
|
||||
"react-native-share": "^12.3.1",
|
||||
"react-native-sse": "^1.2.1",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-webview": "13.16.1",
|
||||
"react-native-worklets": "0.8.3",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.11"
|
||||
"react-native-worklets": "^0.9.1",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-native-community/cli": "^20.1.2",
|
||||
"@react-native-community/cli": "^20.1.3",
|
||||
"@types/pako": "^2.0.4",
|
||||
"@types/react": "~19.2.2",
|
||||
"@types/react": "~19.2.16",
|
||||
"@types/react-native-vector-icons": "^6.4.18",
|
||||
"typescript": "~6.0.3"
|
||||
},
|
||||
|
||||
@@ -40,10 +40,27 @@ const CallScreen: React.FC = () => {
|
||||
const [remoteVideoTrack, setRemoteVideoTrack] = useState<VideoTrack | null>(null);
|
||||
const [localVideoTrack, setLocalVideoTrack] = useState<VideoTrack | null>(null);
|
||||
|
||||
// Subscribe to LiveKit track events
|
||||
// Event-driven video track subscription (no polling)
|
||||
useEffect(() => {
|
||||
const unsubs: (() => void)[] = [];
|
||||
|
||||
// Sync current tracks immediately
|
||||
const syncTracks = () => {
|
||||
const lp = liveKitService.localParticipant;
|
||||
if (lp) {
|
||||
const videoPub = lp.getTrackPublication(Track.Source.Camera);
|
||||
setLocalVideoTrack((videoPub?.track as VideoTrack | undefined) ?? null);
|
||||
}
|
||||
liveKitService.remoteParticipants.forEach((participant) => {
|
||||
const videoPub = participant.getTrackPublication(Track.Source.Camera);
|
||||
if (videoPub?.track) {
|
||||
setRemoteVideoTrack(videoPub.track as VideoTrack);
|
||||
}
|
||||
});
|
||||
};
|
||||
syncTracks();
|
||||
|
||||
// Remote video track events
|
||||
unsubs.push(
|
||||
liveKitService.on('trackSubscribed', ({ track }) => {
|
||||
if (track.kind === Track.Kind.Video) {
|
||||
@@ -69,28 +86,32 @@ const CallScreen: React.FC = () => {
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
liveKitService.on('trackUnmuted', ({ publication, participant }) => {
|
||||
liveKitService.on('trackUnmuted', ({ publication }) => {
|
||||
if (publication.source === Track.Source.Camera && publication.track) {
|
||||
setRemoteVideoTrack(publication.track as VideoTrack);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Poll local video track from localParticipant
|
||||
const localPollInterval = setInterval(() => {
|
||||
const lp = liveKitService.localParticipant;
|
||||
if (lp) {
|
||||
const videoPub = lp.getTrackPublication(Track.Source.Camera);
|
||||
const track = (videoPub?.track ?? null) as VideoTrack | null;
|
||||
setLocalVideoTrack((prev) => (prev === track ? prev : track));
|
||||
} else {
|
||||
setLocalVideoTrack((prev) => (prev === null ? prev : null));
|
||||
}
|
||||
}, 500);
|
||||
// Local video track events (replace 500ms polling)
|
||||
unsubs.push(
|
||||
liveKitService.on('localTrackPublished', ({ publication }) => {
|
||||
if (publication.source === Track.Source.Camera && publication.track) {
|
||||
setLocalVideoTrack(publication.track as VideoTrack);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
liveKitService.on('localTrackUnpublished', ({ publication }) => {
|
||||
if (publication.source === Track.Source.Camera) {
|
||||
setLocalVideoTrack(null);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubs.forEach((unsub) => unsub());
|
||||
clearInterval(localPollInterval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -6,9 +6,12 @@ import {
|
||||
TouchableOpacity,
|
||||
Image,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { MaterialCommunityIcons, Ionicons } from '@expo/vector-icons';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { callStore } from '../../stores/call';
|
||||
|
||||
const BUTTON_SIZE = 40;
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
@@ -22,69 +25,86 @@ const FloatingCallWindow: React.FC = () => {
|
||||
const toggleMinimize = callStore((s) => s.toggleMinimize);
|
||||
const endCall = callStore((s) => s.endCall);
|
||||
|
||||
// Only show when there's an active call and it's minimized
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
if (!currentCall || !isMinimized) return null;
|
||||
|
||||
const getStatusText = (): string => {
|
||||
switch (currentCall.status) {
|
||||
case 'calling':
|
||||
return '等待接听...';
|
||||
case 'ringing':
|
||||
return '来电响铃...';
|
||||
case 'connecting':
|
||||
return '连接中...';
|
||||
case 'connected':
|
||||
return formatDuration(callDuration);
|
||||
case 'connecting':
|
||||
case 'calling':
|
||||
case 'ringing':
|
||||
return '连接中...';
|
||||
case 'reconnecting':
|
||||
return '重连中...';
|
||||
case 'ended':
|
||||
return '已结束';
|
||||
case 'failed':
|
||||
return '连接失败';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const isConnected = currentCall.status === 'connected';
|
||||
const participantCount = currentCall.participants.size;
|
||||
const isVideoCall = currentCall.mediaType === 'video';
|
||||
const isGroupCall = participantCount > 2;
|
||||
const displayName = isGroupCall
|
||||
? `群组通话 (${participantCount})`
|
||||
: currentCall.peerName || '未知用户';
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={[styles.container, { top: insets.top + 4 }]}>
|
||||
<TouchableOpacity
|
||||
style={styles.window}
|
||||
style={styles.bar}
|
||||
onPress={toggleMinimize}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<View style={styles.avatarContainer}>
|
||||
{currentCall.peerAvatar ? (
|
||||
<Image
|
||||
source={{ uri: currentCall.peerAvatar }}
|
||||
style={styles.avatar}
|
||||
/>
|
||||
{/* Call type indicator */}
|
||||
<View style={styles.callTypeIcon}>
|
||||
<Ionicons
|
||||
name={isVideoCall ? 'videocam' : 'call'}
|
||||
size={14}
|
||||
color={isConnected ? '#34C759' : 'rgba(255,255,255,0.6)'}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Avatar + status indicator ring */}
|
||||
<View style={styles.avatarWrap}>
|
||||
{currentCall.peerAvatar && !isGroupCall ? (
|
||||
<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 style={[styles.avatar, styles.avatarFallback]}>
|
||||
{isGroupCall ? (
|
||||
<MaterialCommunityIcons name="account-group" size={22} color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.avatarText}>
|
||||
{currentCall.peerName?.charAt(0).toUpperCase() || '?'}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
{isConnected && <View style={styles.statusDot} />}
|
||||
</View>
|
||||
|
||||
{/* Info */}
|
||||
<View style={styles.info}>
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{currentCall.peerName || '未知用户'}
|
||||
{displayName}
|
||||
</Text>
|
||||
<Text style={[styles.status, isConnected && styles.statusTimer]}>
|
||||
{getStatusText()}
|
||||
</Text>
|
||||
<Text style={styles.status}>{getStatusText()}</Text>
|
||||
</View>
|
||||
|
||||
{/* End call button */}
|
||||
<TouchableOpacity
|
||||
style={styles.endButton}
|
||||
style={styles.endBtn}
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
endCall('hangup');
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
>
|
||||
<MaterialCommunityIcons name="phone-hangup" size={20} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
@@ -96,25 +116,34 @@ const FloatingCallWindow: React.FC = () => {
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'absolute',
|
||||
top: 100,
|
||||
left: 16,
|
||||
right: 16,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 10000,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
window: {
|
||||
bar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#2A2A4E',
|
||||
borderRadius: 16,
|
||||
padding: 12,
|
||||
backgroundColor: 'rgba(20, 20, 40, 0.92)',
|
||||
borderRadius: 18,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 14,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
shadowOpacity: 0.35,
|
||||
shadowRadius: 12,
|
||||
elevation: 10,
|
||||
borderWidth: 0.5,
|
||||
borderColor: 'rgba(255,255,255,0.08)',
|
||||
},
|
||||
avatarContainer: {
|
||||
marginRight: 12,
|
||||
callTypeIcon: {
|
||||
marginRight: 8,
|
||||
width: 20,
|
||||
alignItems: 'center',
|
||||
},
|
||||
avatarWrap: {
|
||||
position: 'relative',
|
||||
marginRight: 14,
|
||||
},
|
||||
avatar: {
|
||||
width: 44,
|
||||
@@ -122,7 +151,7 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 22,
|
||||
backgroundColor: '#3A3A5C',
|
||||
},
|
||||
avatarPlaceholder: {
|
||||
avatarFallback: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
@@ -131,28 +160,44 @@ const styles = StyleSheet.create({
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
statusDot: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
backgroundColor: '#34C759',
|
||||
borderWidth: 2,
|
||||
borderColor: 'rgba(20, 20, 40, 0.92)',
|
||||
},
|
||||
info: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
name: {
|
||||
fontSize: 15,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
marginBottom: 2,
|
||||
},
|
||||
status: {
|
||||
fontSize: 12,
|
||||
color: 'rgba(255, 255, 255, 0.6)',
|
||||
color: 'rgba(255, 255, 255, 0.45)',
|
||||
},
|
||||
endButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
statusTimer: {
|
||||
color: '#34C759',
|
||||
fontWeight: '500',
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
endBtn: {
|
||||
width: BUTTON_SIZE,
|
||||
height: BUTTON_SIZE,
|
||||
borderRadius: BUTTON_SIZE / 2,
|
||||
backgroundColor: '#E54D42',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginLeft: 8,
|
||||
marginLeft: 10,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -8,160 +8,190 @@ import {
|
||||
Animated,
|
||||
Modal,
|
||||
StatusBar,
|
||||
Dimensions,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { callStore } from '../../stores/call';
|
||||
import { blurActiveElement } from '../../infrastructure/platform';
|
||||
|
||||
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||
const AVATAR_SIZE = 104;
|
||||
const BTN_SIZE = 68;
|
||||
const BTN_ICON = 30;
|
||||
|
||||
const IncomingCallModal: React.FC = () => {
|
||||
const incomingCall = callStore((s) => s.incomingCall);
|
||||
const acceptCall = callStore((s) => s.acceptCall);
|
||||
const rejectCall = callStore((s) => s.rejectCall);
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const pulseAnim = useRef(new Animated.Value(1)).current;
|
||||
const rippleAnim = useRef(new Animated.Value(0)).current;
|
||||
const rippleAnim1 = useRef(new Animated.Value(0)).current;
|
||||
const rippleAnim2 = useRef(new Animated.Value(0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (incomingCall) {
|
||||
blurActiveElement();
|
||||
|
||||
const pulse = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1.06,
|
||||
duration: 1000,
|
||||
toValue: 1.05,
|
||||
duration: 1200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1,
|
||||
duration: 1000,
|
||||
duration: 1200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
]),
|
||||
);
|
||||
|
||||
const ripple = Animated.loop(
|
||||
const ripple1 = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(rippleAnim, {
|
||||
Animated.timing(rippleAnim1, {
|
||||
toValue: 1,
|
||||
duration: 2000,
|
||||
duration: 2200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(rippleAnim, {
|
||||
Animated.timing(rippleAnim1, {
|
||||
toValue: 0,
|
||||
duration: 0,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
]),
|
||||
);
|
||||
|
||||
Animated.parallel([pulse, ripple]).start();
|
||||
const ripple2 = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.delay(1100),
|
||||
Animated.timing(rippleAnim2, {
|
||||
toValue: 1,
|
||||
duration: 2200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(rippleAnim2, {
|
||||
toValue: 0,
|
||||
duration: 0,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
Animated.parallel([pulse, ripple1, ripple2]).start();
|
||||
return () => {
|
||||
pulse.stop();
|
||||
ripple.stop();
|
||||
ripple1.stop();
|
||||
ripple2.stop();
|
||||
};
|
||||
}
|
||||
}, [incomingCall, pulseAnim, rippleAnim]);
|
||||
|
||||
const handleAccept = () => {
|
||||
acceptCall();
|
||||
};
|
||||
|
||||
const handleReject = () => {
|
||||
rejectCall();
|
||||
};
|
||||
}, [incomingCall, pulseAnim, rippleAnim1, rippleAnim2]);
|
||||
|
||||
if (!incomingCall) return null;
|
||||
|
||||
const callerName = incomingCall.callerName || '未知用户';
|
||||
const callerInitial = callerName.charAt(0).toUpperCase();
|
||||
const isVideoCall = incomingCall.callType === 'video';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={!!incomingCall}
|
||||
visible
|
||||
transparent
|
||||
animationType="fade"
|
||||
statusBarTranslucent
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
|
||||
<View style={styles.root}>
|
||||
<StatusBar barStyle="light-content" translucent backgroundColor="transparent" />
|
||||
|
||||
{/* Avatar with ripple effect - positioned in upper area */}
|
||||
{/* Top safe area + label */}
|
||||
<View style={[styles.topLabel, { paddingTop: insets.top + 16 }]}>
|
||||
<Text style={styles.topLabelText}>
|
||||
{isVideoCall ? '视频通话邀请' : '语音通话邀请'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Avatar section with ripples */}
|
||||
<View style={styles.avatarSection}>
|
||||
{/* Ripple rings */}
|
||||
{[0, 1, 2].map((i) => (
|
||||
{/* Ripple rings — staggered */}
|
||||
{[
|
||||
{ anim: rippleAnim1, scaleRange: [1, 1.6], opacityRange: [0.2, 0] as const },
|
||||
{ anim: rippleAnim2, scaleRange: [1, 1.4], opacityRange: [0.15, 0] as const },
|
||||
].map(({ anim, scaleRange, opacityRange }, i) => (
|
||||
<Animated.View
|
||||
key={i}
|
||||
style={[
|
||||
styles.rippleRing,
|
||||
styles.ripple,
|
||||
{
|
||||
transform: [
|
||||
{
|
||||
scale: rippleAnim.interpolate({
|
||||
scale: anim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [1, 1.5 + i * 0.2],
|
||||
outputRange: scaleRange,
|
||||
}),
|
||||
},
|
||||
],
|
||||
opacity: rippleAnim.interpolate({
|
||||
inputRange: [0, 0.4, 1],
|
||||
outputRange: [0.25, 0.12, 0],
|
||||
opacity: anim.interpolate({
|
||||
inputRange: [0, 0.5, 1],
|
||||
outputRange: [0, ...opacityRange],
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
|
||||
<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 style={{ transform: [{ scale: pulseAnim }] }}>
|
||||
<View style={styles.avatarBorder}>
|
||||
{incomingCall.callerAvatar ? (
|
||||
<Image source={{ uri: incomingCall.callerAvatar }} style={styles.avatar} />
|
||||
) : (
|
||||
<View style={[styles.avatar, styles.avatarFallback]}>
|
||||
<Text style={styles.avatarInitial}>{callerInitial}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
{/* Caller info */}
|
||||
<View style={styles.infoSection}>
|
||||
<Text style={styles.callerName} numberOfLines={1}>
|
||||
{incomingCall.callerName || '未知用户'}
|
||||
{callerName}
|
||||
</Text>
|
||||
<Text style={styles.callType}>
|
||||
{incomingCall.callType === 'video' ? '视频通话' : '语音通话'}
|
||||
{isVideoCall ? '邀请你进行视频通话' : '邀请你进行语音通话'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Bottom controls */}
|
||||
<View style={styles.controls}>
|
||||
{/* Bottom action buttons */}
|
||||
<View style={[styles.actions, { paddingBottom: insets.bottom + 32 }]}>
|
||||
{/* Decline */}
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
onPress={handleReject}
|
||||
style={styles.actionTouchable}
|
||||
onPress={rejectCall}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View style={styles.declineCircle}>
|
||||
<MaterialCommunityIcons name="phone-hangup" size={28} color="#fff" />
|
||||
<MaterialCommunityIcons
|
||||
name="phone-hangup"
|
||||
size={BTN_ICON}
|
||||
color="#fff"
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.actionLabel}>拒绝</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Accept */}
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
onPress={handleAccept}
|
||||
style={styles.actionTouchable}
|
||||
onPress={acceptCall}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View style={styles.acceptCircle}>
|
||||
<MaterialCommunityIcons name="phone" size={28} color="#fff" />
|
||||
<MaterialCommunityIcons
|
||||
name="phone"
|
||||
size={BTN_ICON}
|
||||
color="#fff"
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.actionLabel}>接听</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -171,36 +201,45 @@ const IncomingCallModal: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const AVATAR_SIZE = 100;
|
||||
const ACTION_CIRCLE_SIZE = 60;
|
||||
export default IncomingCallModal;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
root: {
|
||||
flex: 1,
|
||||
backgroundColor: '#0D0D0D',
|
||||
backgroundColor: '#0A0A14',
|
||||
},
|
||||
avatarSection: {
|
||||
position: 'absolute',
|
||||
top: '18%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: AVATAR_SIZE + 60,
|
||||
justifyContent: 'center',
|
||||
topLabel: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
rippleRing: {
|
||||
topLabelText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
color: 'rgba(255,255,255,0.35)',
|
||||
letterSpacing: 0.5,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
|
||||
// ---- Avatar + ripples ----
|
||||
avatarSection: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
maxHeight: 260,
|
||||
},
|
||||
ripple: {
|
||||
position: 'absolute',
|
||||
width: AVATAR_SIZE + 16,
|
||||
height: AVATAR_SIZE + 16,
|
||||
borderRadius: (AVATAR_SIZE + 16) / 2,
|
||||
width: AVATAR_SIZE + 32,
|
||||
height: AVATAR_SIZE + 32,
|
||||
borderRadius: (AVATAR_SIZE + 32) / 2,
|
||||
borderWidth: 2,
|
||||
borderColor: '#4CD964',
|
||||
},
|
||||
avatarContainer: {
|
||||
width: AVATAR_SIZE + 8,
|
||||
height: AVATAR_SIZE + 8,
|
||||
borderRadius: (AVATAR_SIZE + 8) / 2,
|
||||
backgroundColor: '#4CD964',
|
||||
avatarBorder: {
|
||||
width: AVATAR_SIZE + 10,
|
||||
height: AVATAR_SIZE + 10,
|
||||
borderRadius: (AVATAR_SIZE + 10) / 2,
|
||||
borderWidth: 2.5,
|
||||
borderColor: 'rgba(76, 217, 100, 0.4)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
@@ -208,72 +247,80 @@ const styles = StyleSheet.create({
|
||||
width: AVATAR_SIZE,
|
||||
height: AVATAR_SIZE,
|
||||
borderRadius: AVATAR_SIZE / 2,
|
||||
backgroundColor: '#3A3A5C',
|
||||
backgroundColor: '#2A2A4E',
|
||||
},
|
||||
avatarPlaceholder: {
|
||||
avatarFallback: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
avatarText: {
|
||||
fontSize: 36,
|
||||
avatarInitial: {
|
||||
fontSize: 42,
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
|
||||
// ---- Info ----
|
||||
infoSection: {
|
||||
position: 'absolute',
|
||||
top: '42%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 40,
|
||||
marginTop: -20,
|
||||
marginBottom: 40,
|
||||
},
|
||||
callerName: {
|
||||
fontSize: 24,
|
||||
fontSize: 28,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
marginBottom: 6,
|
||||
textAlign: 'center',
|
||||
maxWidth: 280,
|
||||
maxWidth: 300,
|
||||
},
|
||||
callType: {
|
||||
fontSize: 14,
|
||||
color: 'rgba(255, 255, 255, 0.5)',
|
||||
letterSpacing: 0.3,
|
||||
fontSize: 15,
|
||||
fontWeight: '400',
|
||||
color: 'rgba(255,255,255,0.45)',
|
||||
marginTop: 8,
|
||||
},
|
||||
controls: {
|
||||
position: 'absolute',
|
||||
bottom: '12%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
|
||||
// ---- Actions ----
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
gap: 60,
|
||||
gap: 72,
|
||||
paddingHorizontal: 40,
|
||||
},
|
||||
actionButton: {
|
||||
actionTouchable: {
|
||||
alignItems: 'center',
|
||||
width: 80,
|
||||
},
|
||||
declineCircle: {
|
||||
width: ACTION_CIRCLE_SIZE,
|
||||
height: ACTION_CIRCLE_SIZE,
|
||||
borderRadius: ACTION_CIRCLE_SIZE / 2,
|
||||
width: BTN_SIZE,
|
||||
height: BTN_SIZE,
|
||||
borderRadius: BTN_SIZE / 2,
|
||||
backgroundColor: '#E54D42',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
shadowColor: '#E54D42',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.45,
|
||||
shadowRadius: 10,
|
||||
elevation: 8,
|
||||
},
|
||||
acceptCircle: {
|
||||
width: ACTION_CIRCLE_SIZE,
|
||||
height: ACTION_CIRCLE_SIZE,
|
||||
borderRadius: ACTION_CIRCLE_SIZE / 2,
|
||||
width: BTN_SIZE,
|
||||
height: BTN_SIZE,
|
||||
borderRadius: BTN_SIZE / 2,
|
||||
backgroundColor: '#4CD964',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
shadowColor: '#4CD964',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.45,
|
||||
shadowRadius: 10,
|
||||
elevation: 8,
|
||||
},
|
||||
actionLabel: {
|
||||
fontSize: 12,
|
||||
color: 'rgba(255, 255, 255, 0.6)',
|
||||
marginTop: 10,
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
marginTop: 12,
|
||||
},
|
||||
});
|
||||
|
||||
export default IncomingCallModal;
|
||||
|
||||
@@ -152,7 +152,7 @@ export class CacheDataSource implements ICacheDataSource {
|
||||
const keys = await AsyncStorage.getAllKeys();
|
||||
const cacheKeys = keys.filter(k => k.startsWith(this.persistPrefix));
|
||||
if (cacheKeys.length > 0) {
|
||||
await AsyncStorage.multiRemove(cacheKeys);
|
||||
await AsyncStorage.removeMany(cacheKeys);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new DataSourceError(
|
||||
|
||||
@@ -154,9 +154,9 @@ export class MediaCacheManager {
|
||||
|
||||
if (recordKeys.length === 0) return;
|
||||
|
||||
const records = await AsyncStorage.multiGet(recordKeys);
|
||||
|
||||
for (const [key, value] of records) {
|
||||
const records = await AsyncStorage.getMany(recordKeys);
|
||||
|
||||
for (const [key, value] of Object.entries(records)) {
|
||||
if (value) {
|
||||
const entry: CacheEntry = JSON.parse(value);
|
||||
// 验证文件是否存在
|
||||
@@ -614,7 +614,7 @@ export class MediaCacheManager {
|
||||
const keys = await AsyncStorage.getAllKeys();
|
||||
const recordKeys = keys.filter(k => k.startsWith(CACHE_RECORD_PREFIX));
|
||||
if (recordKeys.length > 0) {
|
||||
await AsyncStorage.multiRemove(recordKeys);
|
||||
await AsyncStorage.removeMany(recordKeys);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -107,9 +107,9 @@ export const DataStorageScreen: React.FC = () => {
|
||||
|
||||
const keys = await AsyncStorage.getAllKeys();
|
||||
if (keys.length > 0) {
|
||||
const items = await AsyncStorage.multiGet(keys);
|
||||
const items = await AsyncStorage.getMany(keys);
|
||||
let totalSize = 0;
|
||||
for (const [, value] of items) {
|
||||
for (const value of Object.values(items)) {
|
||||
if (value) {
|
||||
totalSize += value.length * 2;
|
||||
}
|
||||
|
||||
284
src/services/callkeep/callKeepService.ts
Normal file
284
src/services/callkeep/callKeepService.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import { Platform } from 'react-native';
|
||||
import {
|
||||
startOutgoingCall,
|
||||
reportIncomingCall,
|
||||
answerCall,
|
||||
endCall,
|
||||
reportCallEnded,
|
||||
reportOutgoingCallConnected,
|
||||
fulfillIncomingCallConnected,
|
||||
failIncomingCallConnected,
|
||||
setMuted,
|
||||
reportVideo,
|
||||
setAudioSessionPortOverride,
|
||||
setRTCAudioSessionConfiguration,
|
||||
addCallSessionAddedListener,
|
||||
addCallAnsweredListener,
|
||||
addCallEndedListener,
|
||||
addOutgoingCallStartedListener,
|
||||
addSetMutedActionListener,
|
||||
addVideoChangedListener,
|
||||
addAudioSessionActivatedListener,
|
||||
addAudioSessionDeactivatedListener,
|
||||
addAudioRouteChangedListener,
|
||||
type CallParticipant,
|
||||
type IncomingCallEvent,
|
||||
type CallEndedReason,
|
||||
type CallSession,
|
||||
type CallAnsweredEvent,
|
||||
type CallEndedEvent,
|
||||
type SetMutedActionEvent,
|
||||
} from 'expo-callkit-telecom';
|
||||
|
||||
const isWeb = Platform.OS === 'web';
|
||||
|
||||
export interface CallKeepCallbacks {
|
||||
onIncomingCallFromPush: (session: CallSession) => void;
|
||||
onSystemAnswered: () => void;
|
||||
onSystemEnded: () => void;
|
||||
onSystemMuted: (muted: boolean) => void;
|
||||
onOutgoingStarted: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin state-machine adapter between callStore (Zustand) and expo-callkit-telecom.
|
||||
*
|
||||
* callStore remains the source of truth for call state.
|
||||
* This service delegates to the system call API and translates system UI events
|
||||
* back into callStore actions via registered callbacks.
|
||||
*/
|
||||
class CallKeepServiceImpl {
|
||||
private _activeSystemCallId: string | null = null;
|
||||
private _pendingRequestId: string | null = null;
|
||||
private _callbacks: CallKeepCallbacks | null = null;
|
||||
private _subscriptions: Array<{ remove: () => void }> = [];
|
||||
private _initialized = false;
|
||||
|
||||
// ---- Initialization ----
|
||||
|
||||
initialize(callbacks: CallKeepCallbacks): void {
|
||||
if (isWeb) return;
|
||||
if (this._initialized) return;
|
||||
|
||||
this._callbacks = callbacks;
|
||||
this._initialized = true;
|
||||
|
||||
this._setupListeners();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this._subscriptions.forEach((s) => s.remove());
|
||||
this._subscriptions = [];
|
||||
this._callbacks = null;
|
||||
this._initialized = false;
|
||||
}
|
||||
|
||||
private _setupListeners(): void {
|
||||
// Outgoing call accepted by the OS — media should start connecting
|
||||
this._subscriptions.push(
|
||||
addOutgoingCallStartedListener(() => {
|
||||
this._callbacks?.onOutgoingStarted();
|
||||
}),
|
||||
);
|
||||
|
||||
// Incoming call — a new session was created (from push or reportIncomingCall)
|
||||
this._subscriptions.push(
|
||||
addCallSessionAddedListener((event) => {
|
||||
if (event.session.origin === 'incoming') {
|
||||
this._activeSystemCallId = event.session.id;
|
||||
if (!this._pendingRequestId) {
|
||||
this._callbacks?.onIncomingCallFromPush(event.session);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// User answered incoming call (from system UI or via answerCall)
|
||||
this._subscriptions.push(
|
||||
addCallAnsweredListener((event: CallAnsweredEvent) => {
|
||||
this._activeSystemCallId = event.id;
|
||||
this._pendingRequestId = event.requestId;
|
||||
this._callbacks?.onSystemAnswered();
|
||||
}),
|
||||
);
|
||||
|
||||
// Call ended from system UI (user pressed end in CallKit UI)
|
||||
this._subscriptions.push(
|
||||
addCallEndedListener((_event: CallEndedEvent) => {
|
||||
this._activeSystemCallId = null;
|
||||
this._pendingRequestId = null;
|
||||
this._callbacks?.onSystemEnded();
|
||||
}),
|
||||
);
|
||||
|
||||
// System mute toggle (CallKit mute button)
|
||||
this._subscriptions.push(
|
||||
addSetMutedActionListener((event: SetMutedActionEvent) => {
|
||||
this._callbacks?.onSystemMuted(event.isMuted);
|
||||
}),
|
||||
);
|
||||
|
||||
// Video changed from system
|
||||
this._subscriptions.push(
|
||||
addVideoChangedListener((event) => {
|
||||
if (this._callbacks && event.hasVideo !== undefined) {
|
||||
// could add onSystemVideoChanged callback if needed
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Audio session lifecycle — keep for debugging
|
||||
this._subscriptions.push(
|
||||
addAudioSessionActivatedListener(() => {
|
||||
console.log('[CallKeep] Audio session activated');
|
||||
}),
|
||||
);
|
||||
|
||||
this._subscriptions.push(
|
||||
addAudioSessionDeactivatedListener(() => {
|
||||
console.log('[CallKeep] Audio session deactivated');
|
||||
}),
|
||||
);
|
||||
|
||||
this._subscriptions.push(
|
||||
addAudioRouteChangedListener((event) => {
|
||||
console.log('[CallKeep] Audio route changed:', event.currentRoute);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Outgoing Call ----
|
||||
|
||||
async startOutgoingCall(
|
||||
calleeId: string,
|
||||
calleeName: string,
|
||||
hasVideo: boolean,
|
||||
): Promise<string | null> {
|
||||
if (isWeb) return null;
|
||||
|
||||
try {
|
||||
// End any stale system call before starting a new one
|
||||
if (this._activeSystemCallId) {
|
||||
console.warn('[CallKeep] Cleaning up stale system call before new outgoing call');
|
||||
this.endActiveCall();
|
||||
}
|
||||
|
||||
const recipient: CallParticipant = {
|
||||
id: calleeId,
|
||||
displayName: calleeName,
|
||||
};
|
||||
const id = await startOutgoingCall(recipient, { hasVideo });
|
||||
this._activeSystemCallId = id;
|
||||
console.log('[CallKeep] Outgoing call started:', id);
|
||||
return id;
|
||||
} catch (err) {
|
||||
console.error('[CallKeep] Failed to start outgoing call:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
reportOutgoingCallConnected(): void {
|
||||
if (isWeb || !this._activeSystemCallId) return;
|
||||
reportOutgoingCallConnected(this._activeSystemCallId);
|
||||
}
|
||||
|
||||
// ---- Incoming Call ----
|
||||
|
||||
reportIncomingCallEvent(event: IncomingCallEvent): void {
|
||||
if (isWeb) return;
|
||||
try {
|
||||
reportIncomingCall(event);
|
||||
} catch (err) {
|
||||
console.error('[CallKeep] Failed to report incoming call:', err);
|
||||
}
|
||||
}
|
||||
|
||||
answerCallFromApp(): void {
|
||||
if (isWeb || !this._activeSystemCallId) return;
|
||||
answerCall(this._activeSystemCallId);
|
||||
}
|
||||
|
||||
fulfillIncomingCallConnected(): void {
|
||||
if (isWeb || !this._pendingRequestId) return;
|
||||
try {
|
||||
fulfillIncomingCallConnected(this._pendingRequestId);
|
||||
this._pendingRequestId = null;
|
||||
} catch (err) {
|
||||
console.error('[CallKeep] Failed to fulfill incoming call:', err);
|
||||
}
|
||||
}
|
||||
|
||||
failIncomingCallConnected(): void {
|
||||
if (isWeb || !this._activeSystemCallId || !this._pendingRequestId) return;
|
||||
try {
|
||||
failIncomingCallConnected(this._activeSystemCallId, this._pendingRequestId);
|
||||
this._pendingRequestId = null;
|
||||
} catch (err) {
|
||||
console.error('[CallKeep] Failed to fail incoming call:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- End Call ----
|
||||
|
||||
endActiveCall(): void {
|
||||
if (isWeb || !this._activeSystemCallId) return;
|
||||
try {
|
||||
endCall(this._activeSystemCallId);
|
||||
} catch (err) {
|
||||
console.error('[CallKeep] Failed to end call:', err);
|
||||
}
|
||||
this._activeSystemCallId = null;
|
||||
this._pendingRequestId = null;
|
||||
}
|
||||
|
||||
reportCallEndedExternal(reason: CallEndedReason): void {
|
||||
if (isWeb || !this._activeSystemCallId) return;
|
||||
try {
|
||||
reportCallEnded(this._activeSystemCallId, reason);
|
||||
} catch (err) {
|
||||
console.error('[CallKeep] Failed to report call ended:', err);
|
||||
}
|
||||
this._activeSystemCallId = null;
|
||||
this._pendingRequestId = null;
|
||||
}
|
||||
|
||||
// ---- Audio Session ----
|
||||
|
||||
configureAudioForCall(hasVideo: boolean): void {
|
||||
if (isWeb) return;
|
||||
setRTCAudioSessionConfiguration(hasVideo);
|
||||
}
|
||||
|
||||
// ---- Controls ----
|
||||
|
||||
setMuted(muted: boolean): void {
|
||||
if (isWeb || !this._activeSystemCallId) return;
|
||||
setMuted(this._activeSystemCallId, muted);
|
||||
}
|
||||
|
||||
reportVideoEnabled(enabled: boolean): void {
|
||||
if (isWeb || !this._activeSystemCallId) return;
|
||||
reportVideo(this._activeSystemCallId, enabled);
|
||||
}
|
||||
|
||||
setAudioOutputToSpeaker(enabled: boolean): void {
|
||||
if (isWeb) return;
|
||||
setAudioSessionPortOverride(enabled);
|
||||
}
|
||||
|
||||
// ---- Getters ----
|
||||
|
||||
get activeSystemCallId(): string | null {
|
||||
return this._activeSystemCallId;
|
||||
}
|
||||
|
||||
get pendingRequestId(): string | null {
|
||||
return this._pendingRequestId;
|
||||
}
|
||||
|
||||
get isWebPlatform(): boolean {
|
||||
return isWeb;
|
||||
}
|
||||
}
|
||||
|
||||
export const callKeepService = new CallKeepServiceImpl();
|
||||
2
src/services/callkeep/index.ts
Normal file
2
src/services/callkeep/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { callKeepService } from './callKeepService';
|
||||
export type { CallKeepCallbacks } from './callKeepService';
|
||||
@@ -164,7 +164,7 @@ class ApiClient {
|
||||
// 清除 token
|
||||
async clearToken(): Promise<void> {
|
||||
try {
|
||||
await AsyncStorage.multiRemove([TOKEN_KEY, REFRESH_TOKEN_KEY]);
|
||||
await AsyncStorage.removeMany([TOKEN_KEY, REFRESH_TOKEN_KEY]);
|
||||
} catch (error) {
|
||||
console.error('清除token失败:', error);
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ export class CacheDataSource implements ICacheDataSource {
|
||||
const keys = await AsyncStorage.getAllKeys();
|
||||
const cacheKeys = keys.filter(k => k.startsWith(this.persistPrefix));
|
||||
if (cacheKeys.length > 0) {
|
||||
await AsyncStorage.multiRemove(cacheKeys);
|
||||
await AsyncStorage.removeMany(cacheKeys);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new DataSourceError(
|
||||
|
||||
@@ -44,6 +44,8 @@ export type WSMessageType =
|
||||
| 'call_peer_muted'
|
||||
| 'call_invited'
|
||||
| 'call_answered_elsewhere'
|
||||
| 'call_participant_joined'
|
||||
| 'call_participant_left'
|
||||
| 'sync_required';
|
||||
|
||||
export interface WSCallIncomingMessage {
|
||||
@@ -103,6 +105,19 @@ export interface WSCallAnsweredElsewhereMessage {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface WSCallParticipantJoinedMessage {
|
||||
type: 'call_participant_joined';
|
||||
call_id: string;
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
export interface WSCallParticipantLeftMessage {
|
||||
type: 'call_participant_left';
|
||||
call_id: string;
|
||||
user_id: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface WSErrorMessage {
|
||||
type: 'error';
|
||||
code: string;
|
||||
@@ -257,6 +272,8 @@ export type WSMessage =
|
||||
| WSCallPeerMutedMessage
|
||||
| WSCallInvitedMessage
|
||||
| WSCallAnsweredElsewhereMessage
|
||||
| WSCallParticipantJoinedMessage
|
||||
| WSCallParticipantLeftMessage
|
||||
| WSErrorMessage
|
||||
| WSSyncRequiredMessage;
|
||||
|
||||
@@ -300,6 +317,9 @@ class WebSocketService {
|
||||
private isFallbackMode = false;
|
||||
private fallbackCheckTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// 通话中阻止后台断开(对方请求权限等场景会导致 AppState 切换,不应断开 WebSocket)
|
||||
preventDisconnectOnBackground = false;
|
||||
|
||||
private getWSUrl(token: string | null): string {
|
||||
return `${WS_URL}?token=${encodeURIComponent(token || '')}&compress=1`;
|
||||
}
|
||||
@@ -747,6 +767,14 @@ class WebSocketService {
|
||||
this.sendFireAndForget('call_ready', { call_id: callId });
|
||||
}
|
||||
|
||||
sendCallGroupInvite(groupId: string, conversationId: string, callType: string): void {
|
||||
this.sendFireAndForget('call_group_invite', {
|
||||
group_id: groupId,
|
||||
conversation_id: conversationId,
|
||||
call_type: callType,
|
||||
});
|
||||
}
|
||||
|
||||
private handleMessageSent(payload: any): void {
|
||||
// 找到对应的发送请求并resolve
|
||||
const pendingMsg = this.pendingMessages.find(p =>
|
||||
@@ -1059,9 +1087,14 @@ class WebSocketService {
|
||||
this.lastAppState = AppState.currentState;
|
||||
this.appStateSubscription = AppState.addEventListener('change', (nextState: AppStateStatus) => {
|
||||
// 进入后台时断开 WebSocket,由 JPush 负责后台推送通知
|
||||
// 但如果正在通话中则不断开,避免对方请求权限等场景导致通话中断
|
||||
if (this.lastAppState === 'active' && nextState.match(/inactive|background/)) {
|
||||
console.log('[WebSocket] App entering background, disconnecting');
|
||||
this.disconnect();
|
||||
if (this.preventDisconnectOnBackground) {
|
||||
console.log('[WebSocket] App entering background but call is active, skipping disconnect');
|
||||
} else {
|
||||
console.log('[WebSocket] App entering background, disconnecting');
|
||||
this.disconnect();
|
||||
}
|
||||
}
|
||||
// 回到前台时重连 WebSocket
|
||||
if (this.lastAppState.match(/inactive|background/) && nextState === 'active' && !this.isConnected()) {
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import '../../polyfills';
|
||||
import { Platform } from 'react-native';
|
||||
import { Room, RoomEvent, Track, ConnectionState, LocalParticipant, RemoteParticipant, RemoteTrackPublication, TrackPublication, Participant as LKParticipant } from 'livekit-client';
|
||||
import {
|
||||
Room,
|
||||
RoomEvent,
|
||||
Track,
|
||||
ConnectionState,
|
||||
LocalParticipant,
|
||||
RemoteParticipant,
|
||||
RemoteTrackPublication,
|
||||
LocalTrackPublication,
|
||||
TrackPublication,
|
||||
Participant as LKParticipant,
|
||||
} from 'livekit-client';
|
||||
|
||||
export interface LiveKitServiceConfig {
|
||||
url: string;
|
||||
@@ -20,6 +30,11 @@ export interface LiveKitEventMap {
|
||||
trackUnmuted: { participant: LKParticipant; publication: TrackPublication };
|
||||
connectionStatusChanged: ConnectionStatus;
|
||||
error: Error;
|
||||
activeSpeakersChanged: { speakers: LKParticipant[] };
|
||||
participantConnected: { participant: RemoteParticipant };
|
||||
participantDisconnected: { participant: RemoteParticipant };
|
||||
localTrackPublished: { publication: LocalTrackPublication; participant: LocalParticipant };
|
||||
localTrackUnpublished: { publication: LocalTrackPublication; participant: LocalParticipant };
|
||||
}
|
||||
|
||||
type EventHandler<K extends keyof LiveKitEventMap> = (data: LiveKitEventMap[K]) => void;
|
||||
@@ -45,26 +60,11 @@ class LiveKitServiceImpl {
|
||||
return this.room?.remoteParticipants ?? new Map();
|
||||
}
|
||||
|
||||
async connect(url: string, token: string, isVideoCall: boolean = false): Promise<void> {
|
||||
async connect(url: string, token: string): Promise<void> {
|
||||
if (this.room) {
|
||||
await this.disconnect();
|
||||
}
|
||||
|
||||
// Configure native audio session before connecting (iOS only)
|
||||
if (Platform.OS === 'ios') {
|
||||
try {
|
||||
const { AudioSession } = require('@livekit/react-native');
|
||||
await AudioSession.configureAudio({
|
||||
ios: {
|
||||
defaultOutput: isVideoCall ? 'speaker' : 'default',
|
||||
},
|
||||
});
|
||||
await AudioSession.startAudioSession();
|
||||
} catch (err) {
|
||||
console.warn('[LiveKit] Audio session configuration failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
this.room = new Room({
|
||||
adaptiveStream: true,
|
||||
dynacast: true,
|
||||
@@ -99,18 +99,16 @@ class LiveKitServiceImpl {
|
||||
this.room.off(RoomEvent.Reconnecting, this.onReconnecting);
|
||||
this.room.off(RoomEvent.Reconnected, this.onReconnected);
|
||||
this.room.off(RoomEvent.ConnectionStateChanged, this.onConnectionStateChanged);
|
||||
this.room.off(RoomEvent.ActiveSpeakersChanged, this.onActiveSpeakersChanged);
|
||||
this.room.off(RoomEvent.ParticipantConnected, this.onParticipantConnected);
|
||||
this.room.off(RoomEvent.ParticipantDisconnected, this.onParticipantDisconnected);
|
||||
this.room.off(RoomEvent.LocalTrackPublished, this.onLocalTrackPublished);
|
||||
this.room.off(RoomEvent.LocalTrackUnpublished, this.onLocalTrackUnpublished);
|
||||
|
||||
await this.room.disconnect();
|
||||
this.room = null;
|
||||
this._connectionStatus = 'disconnected';
|
||||
|
||||
if (Platform.OS === 'ios') {
|
||||
try {
|
||||
const { AudioSession } = require('@livekit/react-native');
|
||||
await AudioSession.stopAudioSession();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
this.emit('connectionStatusChanged', 'disconnected');
|
||||
this.emit('disconnected', undefined);
|
||||
}
|
||||
@@ -122,20 +120,60 @@ class LiveKitServiceImpl {
|
||||
|
||||
async setVideoEnabled(enabled: boolean): Promise<void> {
|
||||
if (!this.room?.localParticipant) return;
|
||||
console.log('[LiveKit] setVideoEnabled called:', enabled);
|
||||
try {
|
||||
await this.room.localParticipant.setCameraEnabled(enabled);
|
||||
console.log('[LiveKit] setVideoEnabled succeeded:', enabled);
|
||||
} catch (err) {
|
||||
console.error('[LiveKit] setVideoEnabled failed:', err);
|
||||
throw err;
|
||||
await this.room.localParticipant.setCameraEnabled(enabled);
|
||||
}
|
||||
|
||||
async setSpeakerOn(_speakerOn: boolean): Promise<void> {
|
||||
// Audio routing is handled by expo-callkit-telecom
|
||||
}
|
||||
|
||||
async flipCamera(): Promise<void> {
|
||||
if (!this.room?.localParticipant) return;
|
||||
// Camera flip via LiveKit's native support (platform-specific)
|
||||
const videoTrack = this.room.localParticipant.getTrackPublication(Track.Source.Camera);
|
||||
if (videoTrack?.track && 'switchCamera' in videoTrack.track) {
|
||||
await (videoTrack.track as any).switchCamera();
|
||||
}
|
||||
}
|
||||
|
||||
async setSpeakerOn(speakerOn: boolean): Promise<void> {
|
||||
if (Platform.OS === 'web' || Platform.OS === 'android') return;
|
||||
const AudioSession = require('@livekit/react-native').AudioSession;
|
||||
await AudioSession.selectAudioOutput(speakerOn ? 'force_speaker' : 'default');
|
||||
async switchMicrophone(deviceId: string): Promise<void> {
|
||||
if (!this.room) return;
|
||||
await this.room.switchActiveDevice('audioinput', deviceId);
|
||||
}
|
||||
|
||||
enumerateAudioInputDevices(): Promise<MediaDeviceInfo[]> {
|
||||
// Platform-specific: delegates to native livekit SDK where available
|
||||
if (typeof navigator !== 'undefined' && navigator.mediaDevices?.enumerateDevices) {
|
||||
return navigator.mediaDevices.enumerateDevices().then(d => d.filter(x => x.kind === 'audioinput'));
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
enumerateVideoInputDevices(): Promise<MediaDeviceInfo[]> {
|
||||
if (typeof navigator !== 'undefined' && navigator.mediaDevices?.enumerateDevices) {
|
||||
return navigator.mediaDevices.enumerateDevices().then(d => d.filter(x => x.kind === 'videoinput'));
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
getActiveSpeakers(): LKParticipant[] {
|
||||
return this.room?.activeSpeakers ?? [];
|
||||
}
|
||||
|
||||
getParticipantTracks(identity: string): {
|
||||
audio: RemoteTrackPublication | null;
|
||||
video: RemoteTrackPublication | null;
|
||||
} {
|
||||
const participant = this.room?.remoteParticipants.get(identity);
|
||||
if (!participant) return { audio: null, video: null };
|
||||
return {
|
||||
audio: participant.getTrackPublication(Track.Source.Microphone) as RemoteTrackPublication | null,
|
||||
video: participant.getTrackPublication(Track.Source.Camera) as RemoteTrackPublication | null,
|
||||
};
|
||||
}
|
||||
|
||||
getParticipantCount(): number {
|
||||
return (this.room?.remoteParticipants.size ?? 0) + 1; // +1 for local
|
||||
}
|
||||
|
||||
isMuted(): boolean {
|
||||
@@ -150,22 +188,6 @@ class LiveKitServiceImpl {
|
||||
return videoTrack?.isSubscribed ?? false;
|
||||
}
|
||||
|
||||
getRemoteAudioTrack(): Track | null {
|
||||
for (const [, participant] of this.remoteParticipants) {
|
||||
const audioPub = participant.getTrackPublication(Track.Source.Microphone);
|
||||
if (audioPub?.track) return audioPub.track;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getRemoteVideoTrack(): Track | null {
|
||||
for (const [, participant] of this.remoteParticipants) {
|
||||
const videoPub = participant.getTrackPublication(Track.Source.Camera);
|
||||
if (videoPub?.track) return videoPub.track;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
on<K extends keyof LiveKitEventMap>(event: K, handler: EventHandler<K>): () => void {
|
||||
if (!this.handlers.has(event)) {
|
||||
this.handlers.set(event, new Set());
|
||||
@@ -192,6 +214,11 @@ class LiveKitServiceImpl {
|
||||
this.room.on(RoomEvent.Reconnecting, this.onReconnecting);
|
||||
this.room.on(RoomEvent.Reconnected, this.onReconnected);
|
||||
this.room.on(RoomEvent.ConnectionStateChanged, this.onConnectionStateChanged);
|
||||
this.room.on(RoomEvent.ActiveSpeakersChanged, this.onActiveSpeakersChanged);
|
||||
this.room.on(RoomEvent.ParticipantConnected, this.onParticipantConnected);
|
||||
this.room.on(RoomEvent.ParticipantDisconnected, this.onParticipantDisconnected);
|
||||
this.room.on(RoomEvent.LocalTrackPublished, this.onLocalTrackPublished);
|
||||
this.room.on(RoomEvent.LocalTrackUnpublished, this.onLocalTrackUnpublished);
|
||||
}
|
||||
|
||||
private onTrackSubscribed = (track: Track, publication: RemoteTrackPublication, participant: RemoteParticipant): void => {
|
||||
@@ -240,6 +267,26 @@ class LiveKitServiceImpl {
|
||||
this.emit('connectionStatusChanged', status);
|
||||
};
|
||||
|
||||
private onActiveSpeakersChanged = (speakers: LKParticipant[]): void => {
|
||||
this.emit('activeSpeakersChanged', { speakers });
|
||||
};
|
||||
|
||||
private onParticipantConnected = (participant: RemoteParticipant): void => {
|
||||
this.emit('participantConnected', { participant });
|
||||
};
|
||||
|
||||
private onParticipantDisconnected = (participant: RemoteParticipant): void => {
|
||||
this.emit('participantDisconnected', { participant });
|
||||
};
|
||||
|
||||
private onLocalTrackPublished = (publication: LocalTrackPublication, participant: LocalParticipant): void => {
|
||||
this.emit('localTrackPublished', { publication, participant });
|
||||
};
|
||||
|
||||
private onLocalTrackUnpublished = (publication: LocalTrackPublication, participant: LocalParticipant): void => {
|
||||
this.emit('localTrackUnpublished', { publication, participant });
|
||||
};
|
||||
|
||||
private emit<K extends keyof LiveKitEventMap>(event: K, data: LiveKitEventMap[K]): void {
|
||||
const handlers = this.handlers.get(event);
|
||||
if (handlers) {
|
||||
@@ -255,4 +302,4 @@ class LiveKitServiceImpl {
|
||||
}
|
||||
|
||||
export const liveKitService = new LiveKitServiceImpl();
|
||||
export default liveKitService;
|
||||
export default liveKitService;
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
api,
|
||||
} from '@/services/core';
|
||||
import { liveKitService } from '@/services/livekit';
|
||||
import { callKeepService } from '@/services/callkeep';
|
||||
import type { IncomingCallEvent, CallSession as SystemCallSession } from 'expo-callkit-telecom';
|
||||
import { getCurrentUserId } from '../auth/sessionStore';
|
||||
import { useUserStore } from '../userStore';
|
||||
import { userManager } from '../user/UserManager';
|
||||
@@ -22,14 +24,27 @@ export type CallStatus =
|
||||
|
||||
export type CallType = 'voice' | 'video';
|
||||
|
||||
export interface CallParticipant {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar?: string | null;
|
||||
isMuted: boolean;
|
||||
isVideoEnabled: boolean;
|
||||
isLocal: boolean;
|
||||
isReady: boolean;
|
||||
joinedAt: number;
|
||||
}
|
||||
|
||||
export interface CallSession {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
groupId?: string;
|
||||
peerId: string;
|
||||
peerName?: string;
|
||||
peerAvatar?: string | null;
|
||||
status: CallStatus;
|
||||
callType: CallType;
|
||||
mediaType: 'voice' | 'video';
|
||||
startedAt?: number;
|
||||
duration: number;
|
||||
isMuted: boolean;
|
||||
@@ -38,6 +53,9 @@ export interface CallSession {
|
||||
isPeerVideoEnabled: boolean;
|
||||
isInitiator: boolean;
|
||||
isPeerReady: boolean;
|
||||
participants: Map<string, CallParticipant>;
|
||||
activeSpeakerId: string | null;
|
||||
pinnedParticipantId: string | null;
|
||||
}
|
||||
|
||||
export interface IncomingCallInfo {
|
||||
@@ -72,6 +90,19 @@ interface CallState {
|
||||
toggleMinimize: () => void;
|
||||
toggleVideo: () => Promise<void>;
|
||||
setVideoEnabled: (enabled: boolean) => Promise<void>;
|
||||
handleSystemAnswer: () => Promise<void>;
|
||||
handleIncomingFromPush: (session: SystemCallSession) => void;
|
||||
handleSystemMuted: (muted: boolean) => void;
|
||||
startGroupCall: (
|
||||
groupId: string,
|
||||
conversationId: string,
|
||||
callType: CallType
|
||||
) => Promise<void>;
|
||||
addParticipant: (userId: string, info: { name: string; avatar?: string | null }) => void;
|
||||
removeParticipant: (userId: string) => void;
|
||||
setActiveSpeaker: (userId: string | null) => void;
|
||||
pinParticipant: (userId: string | null) => void;
|
||||
updateParticipantState: (userId: string, updates: Partial<CallParticipant>) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
@@ -99,6 +130,41 @@ function cleanupProcessedCallIds() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build initial participants map for a new call session.
|
||||
*/
|
||||
function buildInitialParticipants(
|
||||
localUserId: string,
|
||||
localName: string,
|
||||
calleeId: string,
|
||||
calleeName: string,
|
||||
calleeAvatar?: string | null,
|
||||
): Map<string, CallParticipant> {
|
||||
const m = new Map<string, CallParticipant>();
|
||||
const now = Date.now();
|
||||
m.set(localUserId, {
|
||||
id: localUserId,
|
||||
name: localName,
|
||||
avatar: null,
|
||||
isMuted: false,
|
||||
isVideoEnabled: false,
|
||||
isLocal: true,
|
||||
isReady: true,
|
||||
joinedAt: now,
|
||||
});
|
||||
m.set(calleeId, {
|
||||
id: calleeId,
|
||||
name: calleeName,
|
||||
avatar: calleeAvatar ?? null,
|
||||
isMuted: false,
|
||||
isVideoEnabled: false,
|
||||
isLocal: false,
|
||||
isReady: false,
|
||||
joinedAt: 0,
|
||||
});
|
||||
return m;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch LiveKit token from the backend and connect to the room.
|
||||
* Connects audio-only first, then enables video separately.
|
||||
@@ -116,10 +182,13 @@ async function joinLiveKitRoom(callId: string, videoEnabled: boolean): Promise<v
|
||||
// Store video preference for enabling after connection
|
||||
pendingVideoEnabled = videoEnabled;
|
||||
|
||||
// Configure RTCAudioSession for the call type before connecting
|
||||
callKeepService.configureAudioForCall(videoEnabled);
|
||||
|
||||
setupLiveKitEvents(callId);
|
||||
|
||||
// Always connect as audio-only first
|
||||
await liveKitService.connect(url, token, videoEnabled);
|
||||
await liveKitService.connect(url, token);
|
||||
|
||||
// Enable microphone
|
||||
await liveKitService.setMuted(false);
|
||||
@@ -172,6 +241,10 @@ function setupLiveKitEvents(callId: string): void {
|
||||
|
||||
// Schedule video enable after settling (avoids iOS deadlock)
|
||||
enablePendingVideo();
|
||||
|
||||
// Sync with system CallKit/Telecom UI
|
||||
callKeepService.fulfillIncomingCallConnected();
|
||||
callKeepService.reportOutgoingCallConnected();
|
||||
})
|
||||
);
|
||||
|
||||
@@ -278,6 +351,67 @@ function setupLiveKitEvents(callId: string): void {
|
||||
console.error('[CallStore] LiveKit error:', err);
|
||||
})
|
||||
);
|
||||
|
||||
liveKitUnsubs.push(
|
||||
liveKitService.on('activeSpeakersChanged', ({ speakers }) => {
|
||||
const activeId = speakers.length > 0 ? speakers[0].identity : null;
|
||||
callStore.getState().setActiveSpeaker(activeId);
|
||||
})
|
||||
);
|
||||
|
||||
liveKitUnsubs.push(
|
||||
liveKitService.on('participantConnected', ({ participant }) => {
|
||||
console.log('[CallStore] Remote participant connected:', participant.identity);
|
||||
callStore.setState((s) => {
|
||||
if (!s.currentCall) return {};
|
||||
const newParticipants = new Map(s.currentCall.participants);
|
||||
if (!newParticipants.has(participant.identity)) {
|
||||
newParticipants.set(participant.identity, {
|
||||
id: participant.identity,
|
||||
name: participant.name || participant.identity,
|
||||
avatar: null,
|
||||
isMuted: false,
|
||||
isVideoEnabled: false,
|
||||
isLocal: false,
|
||||
isReady: true,
|
||||
joinedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
return { currentCall: { ...s.currentCall, participants: newParticipants } };
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
liveKitUnsubs.push(
|
||||
liveKitService.on('participantDisconnected', ({ participant }) => {
|
||||
console.log('[CallStore] Remote participant disconnected:', participant.identity);
|
||||
callStore.getState().removeParticipant(participant.identity);
|
||||
})
|
||||
);
|
||||
|
||||
liveKitUnsubs.push(
|
||||
liveKitService.on('localTrackPublished', ({ publication }) => {
|
||||
if (publication.source === 'camera') {
|
||||
callStore.setState((s) => ({
|
||||
currentCall: s.currentCall
|
||||
? { ...s.currentCall, isVideoEnabled: true }
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
liveKitUnsubs.push(
|
||||
liveKitService.on('localTrackUnpublished', ({ publication }) => {
|
||||
if (publication.source === 'camera') {
|
||||
callStore.setState((s) => ({
|
||||
currentCall: s.currentCall
|
||||
? { ...s.currentCall, isVideoEnabled: false }
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -308,6 +442,28 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
initCallUnsub = null;
|
||||
}
|
||||
|
||||
// Initialize CallKit/Telecom service with system UI callbacks
|
||||
callKeepService.initialize({
|
||||
onOutgoingStarted: () => {
|
||||
console.log('[CallStore] System outgoing call started');
|
||||
},
|
||||
onSystemAnswered: () => {
|
||||
console.log('[CallStore] System answered');
|
||||
get().acceptCall();
|
||||
},
|
||||
onSystemEnded: () => {
|
||||
console.log('[CallStore] System ended call');
|
||||
get().endCall('system_ended');
|
||||
},
|
||||
onSystemMuted: (muted: boolean) => {
|
||||
get().toggleMute();
|
||||
},
|
||||
onIncomingCallFromPush: (_session) => {
|
||||
// Incoming call from push when app was killed — already handled by WS event
|
||||
console.log('[CallStore] Incoming call from push (already handled by WS)');
|
||||
},
|
||||
});
|
||||
|
||||
const unsubs: Array<() => void> = [];
|
||||
|
||||
unsubs.push(
|
||||
@@ -371,14 +527,30 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
},
|
||||
});
|
||||
|
||||
wsService.preventDisconnectOnBackground = true;
|
||||
processedCallIds.set(msg.call_id, Date.now());
|
||||
|
||||
// Report incoming call to system CallKit/Telecom UI
|
||||
callKeepService.reportIncomingCallEvent({
|
||||
eventId: `${msg.call_id}-${Date.now()}`,
|
||||
serverCallId: msg.call_id,
|
||||
hasVideo: msg.call_type === 'video',
|
||||
startedAt: new Date(msg.created_at).toISOString(),
|
||||
caller: {
|
||||
id: msg.caller_id,
|
||||
displayName: callerName,
|
||||
avatarUrl: caller?.avatar || undefined,
|
||||
},
|
||||
metadata: { conversationId: msg.conversation_id },
|
||||
});
|
||||
|
||||
if (callTimeoutTimer) clearTimeout(callTimeoutTimer);
|
||||
const remainingTime = Math.max(lifetime - callAge - 1000, 5000);
|
||||
callTimeoutTimer = setTimeout(() => {
|
||||
const { incomingCall: ic } = get();
|
||||
if (ic?.callId === msg.call_id) {
|
||||
console.log('[CallStore] Incoming call timeout');
|
||||
wsService.preventDisconnectOnBackground = false;
|
||||
wsService.sendCallReject(msg.call_id);
|
||||
processedCallIds.set(msg.call_id, Date.now());
|
||||
set({ incomingCall: null });
|
||||
@@ -450,6 +622,7 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
|
||||
if (incomingCall?.callId === msg.call_id) {
|
||||
console.log('[CallStore] Incoming call cancelled by caller');
|
||||
wsService.preventDisconnectOnBackground = false;
|
||||
if (callTimeoutTimer) {
|
||||
clearTimeout(callTimeoutTimer);
|
||||
callTimeoutTimer = null;
|
||||
@@ -464,6 +637,7 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
const { incomingCall } = get();
|
||||
if (incomingCall?.callId === msg.call_id) {
|
||||
console.log('[CallStore] Call answered on another device');
|
||||
wsService.preventDisconnectOnBackground = false;
|
||||
processedCallIds.set(msg.call_id, Date.now());
|
||||
if (callTimeoutTimer) {
|
||||
clearTimeout(callTimeoutTimer);
|
||||
@@ -506,6 +680,24 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
})
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
wsService.on('call_participant_joined', (msg) => {
|
||||
console.log('[CallStore] Participant joined:', msg.user_id);
|
||||
const { currentCall } = get();
|
||||
if (!currentCall || currentCall.id !== msg.call_id) return;
|
||||
get().addParticipant(msg.user_id, { name: msg.user_id });
|
||||
})
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
wsService.on('call_participant_left', (msg) => {
|
||||
console.log('[CallStore] Participant left:', msg.user_id, 'reason:', msg.reason);
|
||||
const { currentCall } = get();
|
||||
if (!currentCall || currentCall.id !== msg.call_id) return;
|
||||
get().removeParticipant(msg.user_id);
|
||||
})
|
||||
);
|
||||
|
||||
const cleanup = () => {
|
||||
cleanupResources();
|
||||
if (unsubInvited) {
|
||||
@@ -537,10 +729,17 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
return;
|
||||
}
|
||||
|
||||
wsService.preventDisconnectOnBackground = true;
|
||||
|
||||
const cachedCallee = useUserStore.getState().userCache[calleeId];
|
||||
const callee = calleeInfo || cachedCallee;
|
||||
const calleeName = callee?.nickname || callee?.username || calleeId;
|
||||
|
||||
const localUser = useUserStore.getState().userCache[myUserId];
|
||||
const localName = localUser?.nickname || localUser?.username || myUserId;
|
||||
|
||||
const initialParticipants = buildInitialParticipants(myUserId, localName, calleeId, calleeName, callee?.avatar);
|
||||
|
||||
set({
|
||||
currentCall: {
|
||||
id: '',
|
||||
@@ -549,6 +748,7 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
peerName: calleeName,
|
||||
peerAvatar: callee?.avatar,
|
||||
callType,
|
||||
mediaType: callType,
|
||||
status: 'calling',
|
||||
duration: 0,
|
||||
isMuted: false,
|
||||
@@ -557,20 +757,38 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
isPeerVideoEnabled: false,
|
||||
isInitiator: true,
|
||||
isPeerReady: false,
|
||||
participants: initialParticipants,
|
||||
activeSpeakerId: null,
|
||||
pinnedParticipantId: null,
|
||||
},
|
||||
});
|
||||
|
||||
wsService.sendCallInvite(conversationId, calleeId, callType);
|
||||
|
||||
callKeepService.startOutgoingCall(calleeId, calleeName, callType === 'video').then((systemCallId) => {
|
||||
if (systemCallId) {
|
||||
// Sync system call ID with store, but don't overwrite if WS already set it
|
||||
const { currentCall: cc } = get();
|
||||
if (cc && !cc.id) {
|
||||
set((s) => ({
|
||||
currentCall: s.currentCall ? { ...s.currentCall, id: systemCallId } : null,
|
||||
}));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (unsubInvited) {
|
||||
unsubInvited();
|
||||
}
|
||||
unsubInvited = wsService.on('call_invited', (msg) => {
|
||||
set((s) => ({
|
||||
currentCall: s.currentCall
|
||||
? { ...s.currentCall, id: msg.call_id }
|
||||
: null,
|
||||
}));
|
||||
set((s) => {
|
||||
if (!s.currentCall) return {};
|
||||
// WS call_id is the authoritative one; sync system call ID too
|
||||
if (s.currentCall.id !== msg.call_id) {
|
||||
callKeepService.endActiveCall(); // end old system session with wrong ID
|
||||
}
|
||||
return { currentCall: { ...s.currentCall, id: msg.call_id } };
|
||||
});
|
||||
});
|
||||
|
||||
if (callTimeoutTimer) clearTimeout(callTimeoutTimer);
|
||||
@@ -587,6 +805,8 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
const { incomingCall } = get();
|
||||
if (!incomingCall) return;
|
||||
|
||||
wsService.preventDisconnectOnBackground = true;
|
||||
|
||||
console.log('[CallStore] acceptCall, incomingCall.callType:', incomingCall.callType);
|
||||
|
||||
if (callTimeoutTimer) {
|
||||
@@ -597,6 +817,33 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
const isVideoCall = incomingCall.callType === 'video';
|
||||
console.log('[CallStore] acceptCall, isVideoCall:', isVideoCall);
|
||||
|
||||
const myUserId = getCurrentUserId();
|
||||
const localUser = myUserId ? useUserStore.getState().userCache[myUserId] : null;
|
||||
const localName = localUser?.nickname || localUser?.username || myUserId || 'me';
|
||||
|
||||
const participants = new Map<string, CallParticipant>();
|
||||
const now = Date.now();
|
||||
participants.set(myUserId || 'me', {
|
||||
id: myUserId || 'me',
|
||||
name: localName,
|
||||
avatar: null,
|
||||
isMuted: false,
|
||||
isVideoEnabled: false,
|
||||
isLocal: true,
|
||||
isReady: true,
|
||||
joinedAt: now,
|
||||
});
|
||||
participants.set(incomingCall.callerId, {
|
||||
id: incomingCall.callerId,
|
||||
name: incomingCall.callerName || incomingCall.callerId,
|
||||
avatar: incomingCall.callerAvatar ?? null,
|
||||
isMuted: false,
|
||||
isVideoEnabled: false,
|
||||
isLocal: false,
|
||||
isReady: false,
|
||||
joinedAt: 0,
|
||||
});
|
||||
|
||||
set({
|
||||
currentCall: {
|
||||
id: incomingCall.callId,
|
||||
@@ -605,6 +852,7 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
peerName: incomingCall.callerName,
|
||||
peerAvatar: incomingCall.callerAvatar,
|
||||
callType: incomingCall.callType as CallType,
|
||||
mediaType: incomingCall.callType as CallType,
|
||||
status: 'connecting',
|
||||
duration: 0,
|
||||
isMuted: false,
|
||||
@@ -613,6 +861,9 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
isPeerVideoEnabled: false,
|
||||
isInitiator: false,
|
||||
isPeerReady: false,
|
||||
participants,
|
||||
activeSpeakerId: null,
|
||||
pinnedParticipantId: null,
|
||||
},
|
||||
incomingCall: null,
|
||||
});
|
||||
@@ -623,6 +874,7 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
await joinLiveKitRoom(incomingCall.callId, isVideoCall);
|
||||
} catch (err) {
|
||||
console.error('[CallStore] Failed to accept call:', err);
|
||||
callKeepService.failIncomingCallConnected();
|
||||
get().endCall('connection_failed');
|
||||
}
|
||||
},
|
||||
@@ -631,12 +883,15 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
const { incomingCall } = get();
|
||||
if (!incomingCall) return;
|
||||
|
||||
wsService.preventDisconnectOnBackground = false;
|
||||
|
||||
if (callTimeoutTimer) {
|
||||
clearTimeout(callTimeoutTimer);
|
||||
callTimeoutTimer = null;
|
||||
}
|
||||
|
||||
wsService.sendCallReject(incomingCall.callId);
|
||||
callKeepService.endActiveCall();
|
||||
set({ incomingCall: null });
|
||||
},
|
||||
|
||||
@@ -644,6 +899,8 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
const { currentCall } = get();
|
||||
if (!currentCall) return;
|
||||
|
||||
wsService.preventDisconnectOnBackground = false;
|
||||
|
||||
cleanupResources();
|
||||
|
||||
if (unsubInvited) {
|
||||
@@ -664,6 +921,13 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
console.error('[CallStore] Error disconnecting LiveKit:', err);
|
||||
}
|
||||
|
||||
// Sync end with system CallKit/Telecom UI
|
||||
if (reason === 'ended' || !reason) {
|
||||
callKeepService.endActiveCall();
|
||||
} else {
|
||||
callKeepService.reportCallEndedExternal(reason as any);
|
||||
}
|
||||
|
||||
if (callId && reason !== 'ended') {
|
||||
wsService.sendCallEnd(callId, reason);
|
||||
}
|
||||
@@ -675,6 +939,7 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
|
||||
const newMuted = !currentCall.isMuted;
|
||||
liveKitService.setMuted(newMuted);
|
||||
callKeepService.setMuted(newMuted);
|
||||
if (currentCall.id) {
|
||||
wsService.sendCallMute(currentCall.id, newMuted);
|
||||
}
|
||||
@@ -692,6 +957,7 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
? { ...s.currentCall, isSpeakerOn: newSpeakerOn }
|
||||
: null,
|
||||
}));
|
||||
callKeepService.setAudioOutputToSpeaker(newSpeakerOn);
|
||||
await liveKitService.setSpeakerOn(newSpeakerOn);
|
||||
},
|
||||
|
||||
@@ -713,6 +979,7 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
|
||||
try {
|
||||
await liveKitService.setVideoEnabled(enabled);
|
||||
callKeepService.reportVideoEnabled(enabled);
|
||||
|
||||
set((s) => ({
|
||||
currentCall: s.currentCall
|
||||
@@ -724,6 +991,272 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
handleSystemAnswer: async () => {
|
||||
const { incomingCall, currentCall } = get();
|
||||
// If already handling a call from the in-app flow, skip
|
||||
if (currentCall && currentCall.status !== 'idle') return;
|
||||
if (!incomingCall) return;
|
||||
|
||||
wsService.preventDisconnectOnBackground = true;
|
||||
|
||||
if (callTimeoutTimer) {
|
||||
clearTimeout(callTimeoutTimer);
|
||||
callTimeoutTimer = null;
|
||||
}
|
||||
|
||||
const isVideoCall = incomingCall.callType === 'video';
|
||||
|
||||
const myUserId = getCurrentUserId();
|
||||
const localUser = myUserId ? useUserStore.getState().userCache[myUserId] : null;
|
||||
const localName = localUser?.nickname || localUser?.username || myUserId || 'me';
|
||||
|
||||
const sysParticipants = new Map<string, CallParticipant>();
|
||||
const sysNow = Date.now();
|
||||
sysParticipants.set(myUserId || 'me', {
|
||||
id: myUserId || 'me',
|
||||
name: localName,
|
||||
avatar: null,
|
||||
isMuted: false,
|
||||
isVideoEnabled: false,
|
||||
isLocal: true,
|
||||
isReady: true,
|
||||
joinedAt: sysNow,
|
||||
});
|
||||
sysParticipants.set(incomingCall.callerId, {
|
||||
id: incomingCall.callerId,
|
||||
name: incomingCall.callerName || incomingCall.callerId,
|
||||
avatar: incomingCall.callerAvatar ?? null,
|
||||
isMuted: false,
|
||||
isVideoEnabled: false,
|
||||
isLocal: false,
|
||||
isReady: false,
|
||||
joinedAt: 0,
|
||||
});
|
||||
|
||||
set({
|
||||
currentCall: {
|
||||
id: incomingCall.callId,
|
||||
conversationId: incomingCall.conversationId,
|
||||
peerId: incomingCall.callerId,
|
||||
peerName: incomingCall.callerName,
|
||||
peerAvatar: incomingCall.callerAvatar,
|
||||
callType: incomingCall.callType as CallType,
|
||||
mediaType: incomingCall.callType as CallType,
|
||||
status: 'connecting',
|
||||
duration: 0,
|
||||
isMuted: false,
|
||||
isSpeakerOn: isVideoCall,
|
||||
isVideoEnabled: isVideoCall,
|
||||
isPeerVideoEnabled: false,
|
||||
isInitiator: false,
|
||||
isPeerReady: false,
|
||||
participants: sysParticipants,
|
||||
activeSpeakerId: null,
|
||||
pinnedParticipantId: null,
|
||||
},
|
||||
incomingCall: null,
|
||||
});
|
||||
|
||||
wsService.sendCallAnswer(incomingCall.callId);
|
||||
|
||||
try {
|
||||
await joinLiveKitRoom(incomingCall.callId, isVideoCall);
|
||||
} catch (err) {
|
||||
console.error('[CallStore] handleSystemAnswer failed:', err);
|
||||
callKeepService.failIncomingCallConnected();
|
||||
get().endCall('connection_failed');
|
||||
}
|
||||
},
|
||||
|
||||
handleIncomingFromPush: (session: SystemCallSession) => {
|
||||
const ie = session.incomingCallEvent;
|
||||
if (!ie) return;
|
||||
|
||||
const { currentCall, incomingCall } = get();
|
||||
if (currentCall || incomingCall) return;
|
||||
|
||||
wsService.preventDisconnectOnBackground = true;
|
||||
|
||||
const metadata = ie.metadata ?? {};
|
||||
const conversationId = (metadata.conversationId as string) || '';
|
||||
|
||||
set({
|
||||
incomingCall: {
|
||||
callId: ie.serverCallId,
|
||||
conversationId,
|
||||
callerId: ie.caller.id,
|
||||
callerName: ie.caller.displayName || ie.caller.id,
|
||||
callerAvatar: ie.caller.avatarUrl ?? null,
|
||||
callType: ie.hasVideo ? 'video' : 'voice',
|
||||
receivedAt: Date.now(),
|
||||
lifetime: 55000,
|
||||
},
|
||||
});
|
||||
|
||||
if (callTimeoutTimer) clearTimeout(callTimeoutTimer);
|
||||
callTimeoutTimer = setTimeout(() => {
|
||||
const { incomingCall: ic } = get();
|
||||
if (ic?.callId === ie.serverCallId) {
|
||||
console.log('[CallStore] Incoming push call timeout');
|
||||
wsService.preventDisconnectOnBackground = false;
|
||||
callKeepService.endActiveCall();
|
||||
set({ incomingCall: null });
|
||||
}
|
||||
}, 55000);
|
||||
},
|
||||
|
||||
handleSystemMuted: (muted: boolean) => {
|
||||
const { currentCall } = get();
|
||||
if (!currentCall) return;
|
||||
|
||||
liveKitService.setMuted(muted);
|
||||
if (currentCall.id) {
|
||||
wsService.sendCallMute(currentCall.id, muted);
|
||||
}
|
||||
set((s) => ({
|
||||
currentCall: s.currentCall ? { ...s.currentCall, isMuted: muted } : null,
|
||||
}));
|
||||
},
|
||||
|
||||
startGroupCall: async (groupId: string, conversationId: string, callType: CallType) => {
|
||||
const { currentCall } = get();
|
||||
if (currentCall && currentCall.status !== 'idle') {
|
||||
console.warn('[CallStore] Already in a call');
|
||||
return;
|
||||
}
|
||||
|
||||
const myUserId = getCurrentUserId();
|
||||
if (!myUserId) {
|
||||
console.error('[CallStore] Not logged in');
|
||||
return;
|
||||
}
|
||||
|
||||
wsService.preventDisconnectOnBackground = true;
|
||||
|
||||
const localUser = useUserStore.getState().userCache[myUserId];
|
||||
const localName = localUser?.nickname || localUser?.username || myUserId;
|
||||
|
||||
const participants = new Map<string, CallParticipant>();
|
||||
const now = Date.now();
|
||||
participants.set(myUserId, {
|
||||
id: myUserId,
|
||||
name: localName,
|
||||
avatar: null,
|
||||
isMuted: false,
|
||||
isVideoEnabled: false,
|
||||
isLocal: true,
|
||||
isReady: true,
|
||||
joinedAt: now,
|
||||
});
|
||||
|
||||
set({
|
||||
currentCall: {
|
||||
id: '',
|
||||
conversationId,
|
||||
groupId,
|
||||
peerId: '',
|
||||
peerName: undefined,
|
||||
peerAvatar: null,
|
||||
callType,
|
||||
mediaType: callType,
|
||||
status: 'calling',
|
||||
duration: 0,
|
||||
isMuted: false,
|
||||
isSpeakerOn: callType === 'video',
|
||||
isVideoEnabled: callType === 'video',
|
||||
isPeerVideoEnabled: false,
|
||||
isInitiator: true,
|
||||
isPeerReady: false,
|
||||
participants,
|
||||
activeSpeakerId: null,
|
||||
pinnedParticipantId: null,
|
||||
},
|
||||
});
|
||||
|
||||
wsService.sendCallGroupInvite(groupId, conversationId, callType);
|
||||
|
||||
if (unsubInvited) unsubInvited();
|
||||
unsubInvited = wsService.on('call_invited', (msg: any) => {
|
||||
set((s) => {
|
||||
if (!s.currentCall) return {};
|
||||
return { currentCall: { ...s.currentCall, id: msg.call_id } };
|
||||
});
|
||||
});
|
||||
|
||||
if (callTimeoutTimer) clearTimeout(callTimeoutTimer);
|
||||
callTimeoutTimer = setTimeout(() => {
|
||||
const { currentCall: cc } = get();
|
||||
if (cc && cc.status === 'calling') {
|
||||
console.warn('[CallStore] Group call timeout');
|
||||
get().endCall('timeout');
|
||||
}
|
||||
}, CALL_TIMEOUT_MS);
|
||||
},
|
||||
|
||||
addParticipant: (userId: string, info: { name: string; avatar?: string | null }) => {
|
||||
const { currentCall } = get();
|
||||
if (!currentCall) return;
|
||||
|
||||
const newParticipants = new Map(currentCall.participants);
|
||||
if (!newParticipants.has(userId)) {
|
||||
newParticipants.set(userId, {
|
||||
id: userId,
|
||||
name: info.name || userId,
|
||||
avatar: info.avatar ?? null,
|
||||
isMuted: false,
|
||||
isVideoEnabled: false,
|
||||
isLocal: false,
|
||||
isReady: true,
|
||||
joinedAt: Date.now(),
|
||||
});
|
||||
} else {
|
||||
const existing = newParticipants.get(userId)!;
|
||||
newParticipants.set(userId, {
|
||||
...existing,
|
||||
isReady: true,
|
||||
joinedAt: existing.joinedAt || Date.now(),
|
||||
});
|
||||
}
|
||||
set({ currentCall: { ...currentCall, participants: newParticipants } });
|
||||
},
|
||||
|
||||
removeParticipant: (userId: string) => {
|
||||
const { currentCall } = get();
|
||||
if (!currentCall) return;
|
||||
|
||||
const newParticipants = new Map(currentCall.participants);
|
||||
newParticipants.delete(userId);
|
||||
set({ currentCall: { ...currentCall, participants: newParticipants } });
|
||||
},
|
||||
|
||||
setActiveSpeaker: (userId: string | null) => {
|
||||
set((s) => ({
|
||||
currentCall: s.currentCall
|
||||
? { ...s.currentCall, activeSpeakerId: userId }
|
||||
: null,
|
||||
}));
|
||||
},
|
||||
|
||||
pinParticipant: (userId: string | null) => {
|
||||
set((s) => ({
|
||||
currentCall: s.currentCall
|
||||
? { ...s.currentCall, pinnedParticipantId: userId }
|
||||
: null,
|
||||
}));
|
||||
},
|
||||
|
||||
updateParticipantState: (userId: string, updates: Partial<CallParticipant>) => {
|
||||
const { currentCall } = get();
|
||||
if (!currentCall) return;
|
||||
|
||||
const p = currentCall.participants.get(userId);
|
||||
if (!p) return;
|
||||
|
||||
const newParticipants = new Map(currentCall.participants);
|
||||
newParticipants.set(userId, { ...p, ...updates });
|
||||
set({ currentCall: { ...currentCall, participants: newParticipants } });
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
cleanupResources();
|
||||
|
||||
@@ -738,6 +1271,7 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
}
|
||||
|
||||
liveKitService.dispose();
|
||||
callKeepService.endActiveCall();
|
||||
|
||||
processedCallIds.clear();
|
||||
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
*/
|
||||
|
||||
export { callStore } from './callStore';
|
||||
export type { CallType, CallSession, CallStatus, IncomingCallInfo } from './callStore';
|
||||
export type { CallType, CallSession, CallStatus, IncomingCallInfo, CallParticipant } from './callStore';
|
||||
Reference in New Issue
Block a user