@multiplayer-app/session-recorder-react-native 0.0.1-beta.3 → 0.0.1-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/build.gradle +32 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/multiplayer/sessionrecorder/ScreenMaskingModule.kt +202 -0
- package/android/src/main/java/com/multiplayer/sessionrecorder/ScreenMaskingPackage.kt +16 -0
- package/android/src/main/java/com/multiplayer/sessionrecorder/SessionRecorderModule.kt +202 -0
- package/android/src/main/java/com/multiplayer/sessionrecorder/SessionRecorderPackage.kt +16 -0
- package/dist/components/MaskableComponent.d.ts +22 -0
- package/dist/components/MaskableComponent.js +1 -0
- package/dist/components/MaskableComponent.js.map +1 -0
- package/dist/components/MaskableTextInput.d.ts +14 -0
- package/dist/components/MaskableTextInput.js +1 -0
- package/dist/components/MaskableTextInput.js.map +1 -0
- package/dist/components/SessionRecorderWidget/FinalPopover.d.ts +11 -0
- package/dist/components/SessionRecorderWidget/FinalPopover.js +1 -0
- package/dist/components/SessionRecorderWidget/FinalPopover.js.map +1 -0
- package/dist/components/SessionRecorderWidget/FloatingButton.d.ts +8 -0
- package/dist/components/SessionRecorderWidget/FloatingButton.js +1 -0
- package/dist/components/SessionRecorderWidget/FloatingButton.js.map +1 -0
- package/dist/components/SessionRecorderWidget/InitialPopover.d.ts +13 -0
- package/dist/components/SessionRecorderWidget/InitialPopover.js +1 -0
- package/dist/components/SessionRecorderWidget/InitialPopover.js.map +1 -0
- package/dist/components/SessionRecorderWidget/ModalContainer.d.ts +8 -0
- package/dist/components/SessionRecorderWidget/ModalContainer.js +1 -0
- package/dist/components/SessionRecorderWidget/ModalContainer.js.map +1 -0
- package/dist/components/SessionRecorderWidget/ModalHeader.d.ts +6 -0
- package/dist/components/SessionRecorderWidget/ModalHeader.js +1 -0
- package/dist/components/SessionRecorderWidget/ModalHeader.js.map +1 -0
- package/dist/components/SessionRecorderWidget/SessionRecorderWidget.d.ts +5 -0
- package/dist/components/SessionRecorderWidget/SessionRecorderWidget.js +1 -0
- package/dist/components/SessionRecorderWidget/SessionRecorderWidget.js.map +1 -0
- package/dist/components/SessionRecorderWidget/icons.d.ts +11 -0
- package/dist/components/SessionRecorderWidget/icons.js +1 -0
- package/dist/components/SessionRecorderWidget/icons.js.map +1 -0
- package/dist/components/SessionRecorderWidget/index.d.ts +2 -0
- package/dist/components/SessionRecorderWidget/index.js +1 -0
- package/dist/components/SessionRecorderWidget/index.js.map +1 -0
- package/dist/components/SessionRecorderWidget/styles.d.ts +145 -0
- package/dist/components/SessionRecorderWidget/styles.js +1 -0
- package/dist/components/SessionRecorderWidget/styles.js.map +1 -0
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.js +1 -1
- package/dist/components/index.js.map +1 -1
- package/dist/config/defaults.js +1 -1
- package/dist/config/defaults.js.map +1 -1
- package/dist/config/masking.js +1 -1
- package/dist/config/masking.js.map +1 -1
- package/dist/context/SessionRecorderContext.d.ts +5 -3
- package/dist/context/SessionRecorderContext.js +1 -1
- package/dist/context/SessionRecorderContext.js.map +1 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/native/ScreenMasking.d.ts +21 -0
- package/dist/native/ScreenMasking.js +1 -0
- package/dist/native/ScreenMasking.js.map +1 -0
- package/dist/native/SessionRecorderNative.d.ts +21 -0
- package/dist/native/SessionRecorderNative.js +1 -0
- package/dist/native/SessionRecorderNative.js.map +1 -0
- package/dist/patch/xhr.d.ts +1 -1
- package/dist/patch/xhr.js +1 -1
- package/dist/patch/xhr.js.map +1 -1
- package/dist/recorder/screenRecorder.d.ts +1 -0
- package/dist/recorder/screenRecorder.js +1 -1
- package/dist/recorder/screenRecorder.js.map +1 -1
- package/dist/recorder/screenshotManager.d.ts +10 -0
- package/dist/recorder/screenshotManager.js +1 -0
- package/dist/recorder/screenshotManager.js.map +1 -0
- package/dist/services/screenMaskingService.d.ts +39 -0
- package/dist/services/screenMaskingService.js +1 -0
- package/dist/services/screenMaskingService.js.map +1 -0
- package/dist/services/storage.service.d.ts +18 -2
- package/dist/services/storage.service.js +1 -1
- package/dist/services/storage.service.js.map +1 -1
- package/dist/session-recorder.d.ts +2 -1
- package/dist/session-recorder.js +1 -1
- package/dist/session-recorder.js.map +1 -1
- package/dist/types/session-recorder.d.ts +6 -0
- package/dist/types/session-recorder.js.map +1 -1
- package/dist/utils/componentRegistry.d.ts +64 -0
- package/dist/utils/componentRegistry.js +1 -0
- package/dist/utils/componentRegistry.js.map +1 -0
- package/dist/utils/platform.d.ts +3 -0
- package/dist/utils/reactNativeHierarchyExtractor.d.ts +38 -0
- package/dist/utils/reactNativeHierarchyExtractor.js +1 -0
- package/dist/utils/reactNativeHierarchyExtractor.js.map +1 -0
- package/dist/utils/screenshotMasker.d.ts +96 -0
- package/dist/utils/screenshotMasker.js +1 -0
- package/dist/utils/screenshotMasker.js.map +1 -0
- package/dist/utils/viewHierarchyTracker.d.ts +89 -0
- package/dist/utils/viewHierarchyTracker.js +1 -0
- package/dist/utils/viewHierarchyTracker.js.map +1 -0
- package/ios/ScreenMasking.m +12 -0
- package/ios/ScreenMasking.podspec +21 -0
- package/ios/ScreenMasking.swift +205 -0
- package/ios/SessionRecorder.m +12 -0
- package/ios/SessionRecorder.podspec +21 -0
- package/ios/SessionRecorder.swift +205 -0
- package/package.json +12 -2
- package/react-native.config.js +15 -0
- package/src/components/SessionRecorderWidget/FinalPopover.tsx +62 -0
- package/src/components/SessionRecorderWidget/FloatingButton.tsx +136 -0
- package/src/components/SessionRecorderWidget/InitialPopover.tsx +89 -0
- package/src/components/SessionRecorderWidget/ModalContainer.tsx +128 -0
- package/src/components/SessionRecorderWidget/ModalHeader.tsx +24 -0
- package/src/components/SessionRecorderWidget/SessionRecorderWidget.tsx +109 -0
- package/src/components/SessionRecorderWidget/icons.tsx +52 -0
- package/src/components/SessionRecorderWidget/index.ts +3 -0
- package/src/components/SessionRecorderWidget/styles.ts +150 -0
- package/src/components/index.ts +3 -1
- package/src/config/defaults.ts +1 -0
- package/src/config/masking.ts +1 -0
- package/src/context/SessionRecorderContext.tsx +12 -34
- package/src/index.ts +1 -9
- package/src/native/ScreenMasking.ts +34 -0
- package/src/native/SessionRecorderNative.ts +34 -0
- package/src/patch/xhr.ts +7 -7
- package/src/recorder/screenRecorder.ts +31 -2
- package/src/services/screenMaskingService.ts +114 -0
- package/src/services/storage.service.ts +45 -4
- package/src/session-recorder.ts +9 -3
- package/src/types/session-recorder.ts +7 -1
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
import React
|
|
3
|
+
|
|
4
|
+
@objc(SessionRecorder)
|
|
5
|
+
class SessionRecorder: NSObject {
|
|
6
|
+
|
|
7
|
+
@objc func captureAndMask(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
|
8
|
+
DispatchQueue.main.async {
|
|
9
|
+
guard let window = UIApplication.shared.windows.first(where: { $0.isKeyWindow }) else {
|
|
10
|
+
reject("NO_WINDOW", "Unable to get key window", nil)
|
|
11
|
+
return
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
UIGraphicsBeginImageContextWithOptions(window.bounds.size, false, UIScreen.main.scale)
|
|
15
|
+
window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)
|
|
16
|
+
let screenshot = UIGraphicsGetImageFromCurrentImageContext()
|
|
17
|
+
UIGraphicsEndImageContext()
|
|
18
|
+
|
|
19
|
+
guard let image = screenshot else {
|
|
20
|
+
reject("CAPTURE_FAILED", "Failed to capture screen", nil)
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Apply masking to sensitive elements
|
|
25
|
+
let maskedImage = self.applyMasking(to: image, in: window)
|
|
26
|
+
|
|
27
|
+
if let data = maskedImage.jpegData(compressionQuality: 0.5) {
|
|
28
|
+
let base64 = data.base64EncodedString()
|
|
29
|
+
resolve(base64)
|
|
30
|
+
} else {
|
|
31
|
+
reject("ENCODING_FAILED", "Failed to encode image", nil)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@objc func captureAndMaskWithOptions(_ options: NSDictionary, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
|
37
|
+
DispatchQueue.main.async {
|
|
38
|
+
guard let window = UIApplication.shared.windows.first(where: { $0.isKeyWindow }) else {
|
|
39
|
+
reject("NO_WINDOW", "Unable to get key window", nil)
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
UIGraphicsBeginImageContextWithOptions(window.bounds.size, false, UIScreen.main.scale)
|
|
44
|
+
window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)
|
|
45
|
+
let screenshot = UIGraphicsGetImageFromCurrentImageContext()
|
|
46
|
+
UIGraphicsEndImageContext()
|
|
47
|
+
|
|
48
|
+
guard let image = screenshot else {
|
|
49
|
+
reject("CAPTURE_FAILED", "Failed to capture screen", nil)
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Apply masking with custom options
|
|
54
|
+
let maskedImage = self.applyMaskingWithOptions(to: image, in: window, options: options)
|
|
55
|
+
|
|
56
|
+
if let data = maskedImage.jpegData(compressionQuality: 0.5) {
|
|
57
|
+
let base64 = data.base64EncodedString()
|
|
58
|
+
resolve(base64)
|
|
59
|
+
} else {
|
|
60
|
+
reject("ENCODING_FAILED", "Failed to encode image", nil)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private func applyMasking(to image: UIImage, in window: UIWindow) -> UIImage {
|
|
66
|
+
return applyMaskingWithOptions(to: image, in: window, options: [:])
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private func applyMaskingWithOptions(to image: UIImage, in window: UIWindow, options: NSDictionary) -> UIImage {
|
|
70
|
+
UIGraphicsBeginImageContextWithOptions(image.size, false, image.scale)
|
|
71
|
+
guard let context = UIGraphicsGetCurrentContext() else { return image }
|
|
72
|
+
|
|
73
|
+
// Draw the original image
|
|
74
|
+
image.draw(in: CGRect(origin: .zero, size: image.size))
|
|
75
|
+
|
|
76
|
+
// Find and mask sensitive elements
|
|
77
|
+
let sensitiveElements = findSensitiveElements(in: window)
|
|
78
|
+
|
|
79
|
+
for element in sensitiveElements {
|
|
80
|
+
let frame = element.frame
|
|
81
|
+
let maskingType = getMaskingType(for: element)
|
|
82
|
+
|
|
83
|
+
switch maskingType {
|
|
84
|
+
case .blur:
|
|
85
|
+
applyBlurMask(in: context, frame: frame)
|
|
86
|
+
case .rectangle:
|
|
87
|
+
applyRectangleMask(in: context, frame: frame)
|
|
88
|
+
case .pixelate:
|
|
89
|
+
applyPixelateMask(in: context, frame: frame, image: image)
|
|
90
|
+
case .none:
|
|
91
|
+
break
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let maskedImage = UIGraphicsGetImageFromCurrentImageContext() ?? image
|
|
96
|
+
UIGraphicsEndImageContext()
|
|
97
|
+
|
|
98
|
+
return maskedImage
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private func findSensitiveElements(in view: UIView) -> [UIView] {
|
|
102
|
+
var sensitiveElements: [UIView] = []
|
|
103
|
+
|
|
104
|
+
func traverseView(_ view: UIView) {
|
|
105
|
+
// Check if this view should be masked
|
|
106
|
+
if shouldMaskView(view) {
|
|
107
|
+
sensitiveElements.append(view)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Recursively check subviews
|
|
111
|
+
for subview in view.subviews {
|
|
112
|
+
traverseView(subview)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
traverseView(view)
|
|
117
|
+
return sensitiveElements
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private func shouldMaskView(_ view: UIView) -> Bool {
|
|
121
|
+
// Check for UITextField - mask all text fields when inputMasking is enabled
|
|
122
|
+
if view is UITextField {
|
|
123
|
+
return true
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Check for UITextView - mask all text views when inputMasking is enabled
|
|
127
|
+
if view is UITextView {
|
|
128
|
+
return true
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return false
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private func getMaskingType(for view: UIView) -> MaskingType {
|
|
135
|
+
// Default masking type for all text inputs
|
|
136
|
+
return .rectangle
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private func applyBlurMask(in context: CGContext, frame: CGRect) {
|
|
140
|
+
// Create a blur effect
|
|
141
|
+
context.setFillColor(UIColor.black.withAlphaComponent(0.8).cgColor)
|
|
142
|
+
context.fill(frame)
|
|
143
|
+
|
|
144
|
+
// Add some noise to make it look blurred
|
|
145
|
+
context.setFillColor(UIColor.white.withAlphaComponent(0.3).cgColor)
|
|
146
|
+
for _ in 0..<20 {
|
|
147
|
+
let randomX = frame.origin.x + CGFloat.random(in: 0...frame.width)
|
|
148
|
+
let randomY = frame.origin.y + CGFloat.random(in: 0...frame.height)
|
|
149
|
+
let randomSize = CGFloat.random(in: 2...8)
|
|
150
|
+
context.fillEllipse(in: CGRect(x: randomX, y: randomY, width: randomSize, height: randomSize))
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private func applyRectangleMask(in context: CGContext, frame: CGRect) {
|
|
155
|
+
// Simple rectangle fill
|
|
156
|
+
context.setFillColor(UIColor.gray.cgColor)
|
|
157
|
+
context.fill(frame)
|
|
158
|
+
|
|
159
|
+
// Add some text-like pattern
|
|
160
|
+
context.setFillColor(UIColor.darkGray.cgColor)
|
|
161
|
+
let lineHeight: CGFloat = 4
|
|
162
|
+
let spacing: CGFloat = 8
|
|
163
|
+
|
|
164
|
+
for i in stride(from: frame.origin.y + spacing, to: frame.origin.y + frame.height - spacing, by: lineHeight + spacing) {
|
|
165
|
+
let lineWidth = CGFloat.random(in: frame.width * 0.3...frame.width * 0.8)
|
|
166
|
+
let lineX = frame.origin.x + CGFloat.random(in: 0...(frame.width - lineWidth))
|
|
167
|
+
context.fill(CGRect(x: lineX, y: i, width: lineWidth, height: lineHeight))
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private func applyPixelateMask(in context: CGContext, frame: CGRect, image: UIImage) {
|
|
172
|
+
// Create a pixelated effect
|
|
173
|
+
let pixelSize: CGFloat = 8
|
|
174
|
+
let pixelCountX = Int(frame.width / pixelSize)
|
|
175
|
+
let pixelCountY = Int(frame.height / pixelSize)
|
|
176
|
+
|
|
177
|
+
for x in 0..<pixelCountX {
|
|
178
|
+
for y in 0..<pixelCountY {
|
|
179
|
+
let pixelFrame = CGRect(
|
|
180
|
+
x: frame.origin.x + CGFloat(x) * pixelSize,
|
|
181
|
+
y: frame.origin.y + CGFloat(y) * pixelSize,
|
|
182
|
+
width: pixelSize,
|
|
183
|
+
height: pixelSize
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
// Use a random color for each pixel
|
|
187
|
+
let randomColor = UIColor(
|
|
188
|
+
red: CGFloat.random(in: 0...1),
|
|
189
|
+
green: CGFloat.random(in: 0...1),
|
|
190
|
+
blue: CGFloat.random(in: 0...1),
|
|
191
|
+
alpha: 1.0
|
|
192
|
+
)
|
|
193
|
+
context.setFillColor(randomColor.cgColor)
|
|
194
|
+
context.fill(pixelFrame)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private enum MaskingType {
|
|
201
|
+
case blur
|
|
202
|
+
case rectangle
|
|
203
|
+
case pixelate
|
|
204
|
+
case none
|
|
205
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@multiplayer-app/session-recorder-react-native",
|
|
3
|
-
"version": "0.0.1-beta.
|
|
3
|
+
"version": "0.0.1-beta.5",
|
|
4
4
|
"description": "Multiplayer Fullstack Session Recorder for React Native",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Multiplayer Software, Inc.",
|
|
@@ -83,9 +83,19 @@
|
|
|
83
83
|
"peerDependencies": {
|
|
84
84
|
"@opentelemetry/api": "^1.9.0",
|
|
85
85
|
"react": ">=18.0.0 <20.0.0",
|
|
86
|
-
"react-native": ">=0.72.0 <0.82.0"
|
|
86
|
+
"react-native": ">=0.72.0 <0.82.0",
|
|
87
|
+
"react-native-svg": "^15.13.0"
|
|
87
88
|
},
|
|
88
89
|
"optionalDependencies": {
|
|
89
90
|
"expo-constants": "^15.0.0"
|
|
91
|
+
},
|
|
92
|
+
"react-native": {
|
|
93
|
+
"ios": {
|
|
94
|
+
"podspecPath": "./ios/SessionRecorder.podspec"
|
|
95
|
+
},
|
|
96
|
+
"android": {
|
|
97
|
+
"sourceDir": "./android",
|
|
98
|
+
"packageImportPath": "import com.multiplayer.sessionrecorder.SessionRecorderPackage;"
|
|
99
|
+
}
|
|
90
100
|
}
|
|
91
101
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
dependencies: {
|
|
3
|
+
'react-native-session-recorder': {
|
|
4
|
+
platforms: {
|
|
5
|
+
android: {
|
|
6
|
+
sourceDir: '../android',
|
|
7
|
+
packageImportPath: 'import com.multiplayer.sessionrecorder.SessionRecorderPackage;'
|
|
8
|
+
},
|
|
9
|
+
ios: {
|
|
10
|
+
podspecPath: '../ios/SessionRecorder.podspec'
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import React, { useState } from 'react'
|
|
2
|
+
import { View, Text, Pressable, TextInput, Alert } from 'react-native'
|
|
3
|
+
import { WidgetTextOverridesConfig } from '../../types'
|
|
4
|
+
import { sharedStyles } from './styles'
|
|
5
|
+
import ModalHeader from './ModalHeader'
|
|
6
|
+
|
|
7
|
+
interface FinalPopoverProps {
|
|
8
|
+
textOverrides: WidgetTextOverridesConfig
|
|
9
|
+
onStopRecording: (comment: string) => void
|
|
10
|
+
onCancelSession: () => void
|
|
11
|
+
onClose: () => void
|
|
12
|
+
isSubmitting: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const FinalPopover: React.FC<FinalPopoverProps> = ({ textOverrides, onStopRecording, onCancelSession, isSubmitting }) => {
|
|
16
|
+
const [comment, setComment] = useState('')
|
|
17
|
+
|
|
18
|
+
const handleStopRecording = async () => {
|
|
19
|
+
try {
|
|
20
|
+
await onStopRecording(comment)
|
|
21
|
+
} catch (error) {
|
|
22
|
+
Alert.alert('Error', 'Failed to save session')
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<View style={sharedStyles.popoverContent}>
|
|
28
|
+
<ModalHeader>
|
|
29
|
+
<Pressable onPress={onCancelSession} style={sharedStyles.cancelButton}>
|
|
30
|
+
<Text style={sharedStyles.cancelButtonText}>{textOverrides.cancelButtonText}</Text>
|
|
31
|
+
</Pressable>
|
|
32
|
+
</ModalHeader>
|
|
33
|
+
|
|
34
|
+
<View style={sharedStyles.popoverBody}>
|
|
35
|
+
<Text style={sharedStyles.title}>{textOverrides.finalTitle}</Text>
|
|
36
|
+
<Text style={sharedStyles.description}>{textOverrides.finalDescription}</Text>
|
|
37
|
+
|
|
38
|
+
<TextInput
|
|
39
|
+
style={sharedStyles.commentInput}
|
|
40
|
+
placeholder={textOverrides.commentPlaceholder}
|
|
41
|
+
value={comment}
|
|
42
|
+
onChangeText={setComment}
|
|
43
|
+
multiline
|
|
44
|
+
numberOfLines={3}
|
|
45
|
+
textAlignVertical='top'
|
|
46
|
+
/>
|
|
47
|
+
|
|
48
|
+
<View style={sharedStyles.popoverFooter}>
|
|
49
|
+
<Pressable
|
|
50
|
+
style={[sharedStyles.actionButton, sharedStyles.stopButton]}
|
|
51
|
+
onPress={handleStopRecording}
|
|
52
|
+
disabled={isSubmitting}
|
|
53
|
+
>
|
|
54
|
+
<Text style={sharedStyles.actionButtonText}>{isSubmitting ? 'Saving...' : textOverrides.saveButtonText}</Text>
|
|
55
|
+
</Pressable>
|
|
56
|
+
</View>
|
|
57
|
+
</View>
|
|
58
|
+
</View>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export default FinalPopover
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import React, { useRef, useEffect, useMemo } from 'react'
|
|
2
|
+
import { StyleSheet, Platform, Animated, PanResponder, View, Dimensions } from 'react-native'
|
|
3
|
+
import { SessionState } from '../../types'
|
|
4
|
+
import { StorageService } from '../../services/storage.service'
|
|
5
|
+
import { RecordIcon, CapturingIcon, PausedIcon } from './icons'
|
|
6
|
+
|
|
7
|
+
interface FloatingButtonProps {
|
|
8
|
+
sessionState: SessionState | null
|
|
9
|
+
onPress: () => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const buttonSize = 52
|
|
13
|
+
const rightOffset = 20
|
|
14
|
+
const topOffset = Platform.OS === 'ios' ? 60 : 40
|
|
15
|
+
|
|
16
|
+
const FloatingButton: React.FC<FloatingButtonProps> = ({ sessionState, onPress }) => {
|
|
17
|
+
const position = useRef(new Animated.ValueXY({ x: 0, y: 0 })).current
|
|
18
|
+
const lastPosition = useRef({ top: topOffset, right: rightOffset })
|
|
19
|
+
const storageService = useRef(StorageService.getInstance()).current
|
|
20
|
+
|
|
21
|
+
const screenBounds = useMemo(() => {
|
|
22
|
+
const { width, height } = Dimensions.get('window')
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
minTop: topOffset,
|
|
26
|
+
maxTop: height - buttonSize,
|
|
27
|
+
minRight: 0,
|
|
28
|
+
maxRight: width - buttonSize
|
|
29
|
+
}
|
|
30
|
+
}, [])
|
|
31
|
+
|
|
32
|
+
// Load saved position on component mount
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
const savedPosition = storageService.getFloatingButtonPosition()
|
|
35
|
+
if (savedPosition) {
|
|
36
|
+
const { width } = Dimensions.get('window')
|
|
37
|
+
const top = savedPosition.y
|
|
38
|
+
const right = width - savedPosition.x - buttonSize
|
|
39
|
+
lastPosition.current = { top, right }
|
|
40
|
+
position.setValue({ x: right, y: top })
|
|
41
|
+
} else {
|
|
42
|
+
position.setValue({ x: lastPosition.current.right, y: lastPosition.current.top })
|
|
43
|
+
}
|
|
44
|
+
}, [])
|
|
45
|
+
|
|
46
|
+
const panResponder = useRef(
|
|
47
|
+
PanResponder.create({
|
|
48
|
+
onStartShouldSetPanResponder: () => true,
|
|
49
|
+
onMoveShouldSetPanResponder: (evt, gestureState) => {
|
|
50
|
+
const distance = Math.sqrt(gestureState.dx * gestureState.dx + gestureState.dy * gestureState.dy)
|
|
51
|
+
return distance > 5
|
|
52
|
+
},
|
|
53
|
+
onPanResponderGrant: () => {
|
|
54
|
+
// Set the initial position for this gesture
|
|
55
|
+
position.setValue({ x: lastPosition.current.right, y: lastPosition.current.top })
|
|
56
|
+
},
|
|
57
|
+
onPanResponderMove: (evt, gestureState) => {
|
|
58
|
+
// Calculate new position based on gesture movement
|
|
59
|
+
const newTop = lastPosition.current.top + gestureState.dy
|
|
60
|
+
const newRight = lastPosition.current.right - gestureState.dx
|
|
61
|
+
|
|
62
|
+
// Update position during drag
|
|
63
|
+
position.setValue({ x: newRight, y: newTop })
|
|
64
|
+
},
|
|
65
|
+
onPanResponderRelease: (e, gestureState) => {
|
|
66
|
+
// Check if this was actually a drag (significant movement)
|
|
67
|
+
const distance = Math.sqrt(gestureState.dx * gestureState.dx + gestureState.dy * gestureState.dy)
|
|
68
|
+
|
|
69
|
+
// If it was a tap (no significant movement), trigger onPress
|
|
70
|
+
if (distance <= 5) {
|
|
71
|
+
onPress()
|
|
72
|
+
} else {
|
|
73
|
+
// Calculate new position after dragging
|
|
74
|
+
const newTop = lastPosition.current.top + gestureState.dy
|
|
75
|
+
const newRight = lastPosition.current.right - gestureState.dx
|
|
76
|
+
|
|
77
|
+
// Clamp to screen bounds
|
|
78
|
+
const clampedTop = Math.max(screenBounds.minTop, Math.min(screenBounds.maxTop, newTop))
|
|
79
|
+
const clampedRight = Math.max(screenBounds.minRight, Math.min(screenBounds.maxRight, newRight))
|
|
80
|
+
|
|
81
|
+
// Update position
|
|
82
|
+
lastPosition.current = { top: clampedTop, right: clampedRight }
|
|
83
|
+
position.setValue({ x: clampedRight, y: clampedTop })
|
|
84
|
+
|
|
85
|
+
// Convert back to x,y coordinates for storage
|
|
86
|
+
const { width } = Dimensions.get('window')
|
|
87
|
+
const storagePosition = {
|
|
88
|
+
x: width - clampedRight - buttonSize,
|
|
89
|
+
y: clampedTop
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Persist position to AsyncStorage (debounced)
|
|
93
|
+
storageService.saveFloatingButtonPosition(storagePosition)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
).current
|
|
98
|
+
|
|
99
|
+
// Memoized button icon and color for performance
|
|
100
|
+
const content = useMemo(() => {
|
|
101
|
+
switch (sessionState) {
|
|
102
|
+
case SessionState.started:
|
|
103
|
+
return { icon: <CapturingIcon size={28} color='white' />, color: '#FF4444' }
|
|
104
|
+
case SessionState.paused:
|
|
105
|
+
return { icon: <PausedIcon size={28} color='white' />, color: '#FFA500' }
|
|
106
|
+
default:
|
|
107
|
+
return { icon: <RecordIcon size={28} color='#718096' />, color: '#ffffff' }
|
|
108
|
+
}
|
|
109
|
+
}, [sessionState])
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<Animated.View style={[styles.draggableButton, { top: position.y, right: position.x }]} {...panResponder.panHandlers}>
|
|
113
|
+
<View style={[styles.floatingButton, { backgroundColor: content.color }]}>{content.icon}</View>
|
|
114
|
+
</Animated.View>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const styles = StyleSheet.create({
|
|
119
|
+
draggableButton: {
|
|
120
|
+
position: 'absolute'
|
|
121
|
+
},
|
|
122
|
+
floatingButton: {
|
|
123
|
+
elevation: 8,
|
|
124
|
+
shadowRadius: 4,
|
|
125
|
+
width: buttonSize,
|
|
126
|
+
shadowColor: '#000',
|
|
127
|
+
height: buttonSize,
|
|
128
|
+
shadowOpacity: 0.25,
|
|
129
|
+
alignItems: 'center',
|
|
130
|
+
justifyContent: 'center',
|
|
131
|
+
borderRadius: buttonSize / 2,
|
|
132
|
+
shadowOffset: { width: 0, height: 2 }
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
export default FloatingButton
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import React, { useState } from 'react'
|
|
2
|
+
import { View, Text, Pressable, Alert, Switch } from 'react-native'
|
|
3
|
+
import { SessionType } from '@multiplayer-app/session-recorder-common'
|
|
4
|
+
import { WidgetTextOverridesConfig } from '../../types'
|
|
5
|
+
import { sharedStyles } from './styles'
|
|
6
|
+
import ModalHeader from './ModalHeader'
|
|
7
|
+
|
|
8
|
+
interface InitialPopoverProps {
|
|
9
|
+
textOverrides: WidgetTextOverridesConfig
|
|
10
|
+
showContinuousRecording: boolean
|
|
11
|
+
onStartRecording: (sessionType: SessionType) => void
|
|
12
|
+
onSaveContinuousSession: () => void
|
|
13
|
+
onClose: () => void
|
|
14
|
+
isSubmitting: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const InitialPopover: React.FC<InitialPopoverProps> = ({
|
|
18
|
+
textOverrides,
|
|
19
|
+
showContinuousRecording,
|
|
20
|
+
onStartRecording,
|
|
21
|
+
onSaveContinuousSession,
|
|
22
|
+
onClose,
|
|
23
|
+
isSubmitting
|
|
24
|
+
}) => {
|
|
25
|
+
const [continuousRecording, setContinuousRecording] = useState(false)
|
|
26
|
+
|
|
27
|
+
const handleStartRecording = async () => {
|
|
28
|
+
try {
|
|
29
|
+
const sessionType = continuousRecording ? SessionType.CONTINUOUS : SessionType.PLAIN
|
|
30
|
+
onStartRecording(sessionType)
|
|
31
|
+
} catch (error) {
|
|
32
|
+
Alert.alert('Error', 'Failed to start recording')
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<View style={sharedStyles.popoverContent}>
|
|
38
|
+
<ModalHeader />
|
|
39
|
+
|
|
40
|
+
<View style={sharedStyles.popoverBody}>
|
|
41
|
+
{showContinuousRecording && (
|
|
42
|
+
<View style={sharedStyles.continuousRecordingSection}>
|
|
43
|
+
<Text style={sharedStyles.continuousRecordingLabel}>{textOverrides.continuousRecordingLabel}</Text>
|
|
44
|
+
<Switch
|
|
45
|
+
value={continuousRecording}
|
|
46
|
+
onValueChange={setContinuousRecording}
|
|
47
|
+
trackColor={{ false: '#767577', true: '#81b0ff' }}
|
|
48
|
+
thumbColor={continuousRecording ? '#007AFF' : '#f4f3f4'}
|
|
49
|
+
/>
|
|
50
|
+
</View>
|
|
51
|
+
)}
|
|
52
|
+
|
|
53
|
+
<Text style={sharedStyles.title}>
|
|
54
|
+
{showContinuousRecording ? textOverrides.initialTitleWithContinuous : textOverrides.initialTitleWithoutContinuous}
|
|
55
|
+
</Text>
|
|
56
|
+
|
|
57
|
+
<Text style={sharedStyles.description}>
|
|
58
|
+
{showContinuousRecording
|
|
59
|
+
? textOverrides.initialDescriptionWithContinuous
|
|
60
|
+
: textOverrides.initialDescriptionWithoutContinuous}
|
|
61
|
+
</Text>
|
|
62
|
+
|
|
63
|
+
<View style={sharedStyles.popoverFooter}>
|
|
64
|
+
<Pressable style={[sharedStyles.actionButton, sharedStyles.startButton]} onPress={handleStartRecording}>
|
|
65
|
+
<Text style={sharedStyles.actionButtonText}>{textOverrides.startRecordingButtonText}</Text>
|
|
66
|
+
</Pressable>
|
|
67
|
+
</View>
|
|
68
|
+
|
|
69
|
+
{showContinuousRecording && continuousRecording && (
|
|
70
|
+
<View style={sharedStyles.continuousOverlay}>
|
|
71
|
+
<View style={sharedStyles.continuousOverlayContent}>
|
|
72
|
+
<Text style={sharedStyles.continuousOverlayTitle}>🔴 {textOverrides.continuousOverlayTitle}</Text>
|
|
73
|
+
<Text style={sharedStyles.continuousOverlayDescription}>{textOverrides.continuousOverlayDescription}</Text>
|
|
74
|
+
</View>
|
|
75
|
+
<Pressable
|
|
76
|
+
style={[sharedStyles.actionButton, sharedStyles.saveButton]}
|
|
77
|
+
onPress={onSaveContinuousSession}
|
|
78
|
+
disabled={isSubmitting}
|
|
79
|
+
>
|
|
80
|
+
<Text style={sharedStyles.actionButtonText}>{textOverrides.saveLastSnapshotButtonText}</Text>
|
|
81
|
+
</Pressable>
|
|
82
|
+
</View>
|
|
83
|
+
)}
|
|
84
|
+
</View>
|
|
85
|
+
</View>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export default InitialPopover
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { Animated, Pressable, StyleSheet, Dimensions, Modal, SafeAreaView } from 'react-native'
|
|
3
|
+
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'
|
|
4
|
+
|
|
5
|
+
const { height: SCREEN_HEIGHT } = Dimensions.get('window')
|
|
6
|
+
const MODAL_HEIGHT = SCREEN_HEIGHT * 0.7
|
|
7
|
+
|
|
8
|
+
interface ModalContainerProps {
|
|
9
|
+
isVisible: boolean
|
|
10
|
+
onClose: () => void
|
|
11
|
+
children: React.ReactNode
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const ModalContainer: React.FC<ModalContainerProps> = ({ isVisible, onClose, children }) => {
|
|
15
|
+
const fadeAnim = useRef(new Animated.Value(0)).current
|
|
16
|
+
const translateY = useRef(new Animated.Value(0)).current
|
|
17
|
+
const [animatedFinished, setAnimatedFinished] = useState(false)
|
|
18
|
+
|
|
19
|
+
const SWIPE_THRESHOLD = 100 // Distance to trigger close
|
|
20
|
+
const MAX_SWIPE_DISTANCE = 200 // Maximum swipe distance
|
|
21
|
+
|
|
22
|
+
const animateClose = () => {
|
|
23
|
+
Animated.parallel([
|
|
24
|
+
Animated.timing(fadeAnim, {
|
|
25
|
+
toValue: 0,
|
|
26
|
+
duration: 250,
|
|
27
|
+
useNativeDriver: true
|
|
28
|
+
}),
|
|
29
|
+
Animated.timing(translateY, {
|
|
30
|
+
toValue: MODAL_HEIGHT,
|
|
31
|
+
duration: 250,
|
|
32
|
+
useNativeDriver: true
|
|
33
|
+
})
|
|
34
|
+
]).start(() => {
|
|
35
|
+
onClose()
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (isVisible) {
|
|
41
|
+
// Start from bottom and animate to position
|
|
42
|
+
translateY.setValue(MODAL_HEIGHT)
|
|
43
|
+
Animated.parallel([
|
|
44
|
+
Animated.timing(fadeAnim, {
|
|
45
|
+
toValue: 1,
|
|
46
|
+
duration: 300,
|
|
47
|
+
useNativeDriver: true
|
|
48
|
+
}),
|
|
49
|
+
Animated.timing(translateY, {
|
|
50
|
+
toValue: 0,
|
|
51
|
+
duration: 300,
|
|
52
|
+
useNativeDriver: true
|
|
53
|
+
})
|
|
54
|
+
]).start()
|
|
55
|
+
}
|
|
56
|
+
}, [isVisible, fadeAnim, translateY])
|
|
57
|
+
|
|
58
|
+
const panGesture = Gesture.Pan()
|
|
59
|
+
.onUpdate((event) => {
|
|
60
|
+
translateY.setValue(event.translationY)
|
|
61
|
+
})
|
|
62
|
+
.onEnd((event) => {
|
|
63
|
+
const { translationY, velocityY } = event
|
|
64
|
+
|
|
65
|
+
// If swiped down with sufficient distance or velocity, close modal
|
|
66
|
+
if (translationY > SWIPE_THRESHOLD || velocityY > 500) {
|
|
67
|
+
animateClose()
|
|
68
|
+
} else {
|
|
69
|
+
// Snap back to original position
|
|
70
|
+
Animated.spring(translateY, {
|
|
71
|
+
toValue: 0,
|
|
72
|
+
useNativeDriver: true,
|
|
73
|
+
tension: 100,
|
|
74
|
+
friction: 8
|
|
75
|
+
}).start()
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
.activeOffsetY(10)
|
|
79
|
+
.runOnJS(true)
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<>
|
|
83
|
+
{isVisible && (
|
|
84
|
+
<Animated.View style={{ ...styles.backdrop, opacity: fadeAnim }}>
|
|
85
|
+
<Pressable style={styles.backdropPressable} onPress={animateClose} />
|
|
86
|
+
</Animated.View>
|
|
87
|
+
)}
|
|
88
|
+
<Modal visible={isVisible} transparent animationType='none' onRequestClose={onClose}>
|
|
89
|
+
<GestureHandlerRootView style={{ flex: 1 }}>
|
|
90
|
+
<GestureDetector gesture={panGesture}>
|
|
91
|
+
<Animated.View style={[styles.modal, { transform: [{ translateY }] }]}>
|
|
92
|
+
<SafeAreaView style={styles.safeArea}>{children}</SafeAreaView>
|
|
93
|
+
</Animated.View>
|
|
94
|
+
</GestureDetector>
|
|
95
|
+
</GestureHandlerRootView>
|
|
96
|
+
</Modal>
|
|
97
|
+
</>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const styles = StyleSheet.create({
|
|
102
|
+
backdrop: {
|
|
103
|
+
position: 'absolute',
|
|
104
|
+
top: 0,
|
|
105
|
+
left: 0,
|
|
106
|
+
right: 0,
|
|
107
|
+
bottom: 0,
|
|
108
|
+
backgroundColor: 'rgba(0, 0, 0, 0.5)'
|
|
109
|
+
},
|
|
110
|
+
backdropPressable: {
|
|
111
|
+
flex: 1
|
|
112
|
+
},
|
|
113
|
+
safeArea: {
|
|
114
|
+
flex: 1
|
|
115
|
+
},
|
|
116
|
+
modal: {
|
|
117
|
+
position: 'absolute',
|
|
118
|
+
bottom: 0,
|
|
119
|
+
left: 0,
|
|
120
|
+
right: 0,
|
|
121
|
+
height: 'auto',
|
|
122
|
+
backgroundColor: 'white',
|
|
123
|
+
borderTopLeftRadius: 20,
|
|
124
|
+
borderTopRightRadius: 20
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
export default ModalContainer
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { View, Pressable, Linking } from 'react-native'
|
|
3
|
+
import { sharedStyles } from './styles'
|
|
4
|
+
import { LogoIcon } from './icons'
|
|
5
|
+
|
|
6
|
+
interface ModalHeaderProps {
|
|
7
|
+
children?: React.ReactNode
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const ModalHeader: React.FC<ModalHeaderProps> = ({ children }) => {
|
|
11
|
+
return (
|
|
12
|
+
<View style={sharedStyles.popoverHeader}>
|
|
13
|
+
<View style={sharedStyles.modalHandle} />
|
|
14
|
+
<View style={sharedStyles.popoverHeaderContent}>
|
|
15
|
+
<Pressable onPress={() => Linking.openURL('https://www.multiplayer.app')}>
|
|
16
|
+
<LogoIcon size={42} />
|
|
17
|
+
</Pressable>
|
|
18
|
+
{children}
|
|
19
|
+
</View>
|
|
20
|
+
</View>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default ModalHeader
|