@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.
Files changed (121) hide show
  1. package/android/build.gradle +32 -0
  2. package/android/src/main/AndroidManifest.xml +2 -0
  3. package/android/src/main/java/com/multiplayer/sessionrecorder/ScreenMaskingModule.kt +202 -0
  4. package/android/src/main/java/com/multiplayer/sessionrecorder/ScreenMaskingPackage.kt +16 -0
  5. package/android/src/main/java/com/multiplayer/sessionrecorder/SessionRecorderModule.kt +202 -0
  6. package/android/src/main/java/com/multiplayer/sessionrecorder/SessionRecorderPackage.kt +16 -0
  7. package/dist/components/MaskableComponent.d.ts +22 -0
  8. package/dist/components/MaskableComponent.js +1 -0
  9. package/dist/components/MaskableComponent.js.map +1 -0
  10. package/dist/components/MaskableTextInput.d.ts +14 -0
  11. package/dist/components/MaskableTextInput.js +1 -0
  12. package/dist/components/MaskableTextInput.js.map +1 -0
  13. package/dist/components/SessionRecorderWidget/FinalPopover.d.ts +11 -0
  14. package/dist/components/SessionRecorderWidget/FinalPopover.js +1 -0
  15. package/dist/components/SessionRecorderWidget/FinalPopover.js.map +1 -0
  16. package/dist/components/SessionRecorderWidget/FloatingButton.d.ts +8 -0
  17. package/dist/components/SessionRecorderWidget/FloatingButton.js +1 -0
  18. package/dist/components/SessionRecorderWidget/FloatingButton.js.map +1 -0
  19. package/dist/components/SessionRecorderWidget/InitialPopover.d.ts +13 -0
  20. package/dist/components/SessionRecorderWidget/InitialPopover.js +1 -0
  21. package/dist/components/SessionRecorderWidget/InitialPopover.js.map +1 -0
  22. package/dist/components/SessionRecorderWidget/ModalContainer.d.ts +8 -0
  23. package/dist/components/SessionRecorderWidget/ModalContainer.js +1 -0
  24. package/dist/components/SessionRecorderWidget/ModalContainer.js.map +1 -0
  25. package/dist/components/SessionRecorderWidget/ModalHeader.d.ts +6 -0
  26. package/dist/components/SessionRecorderWidget/ModalHeader.js +1 -0
  27. package/dist/components/SessionRecorderWidget/ModalHeader.js.map +1 -0
  28. package/dist/components/SessionRecorderWidget/SessionRecorderWidget.d.ts +5 -0
  29. package/dist/components/SessionRecorderWidget/SessionRecorderWidget.js +1 -0
  30. package/dist/components/SessionRecorderWidget/SessionRecorderWidget.js.map +1 -0
  31. package/dist/components/SessionRecorderWidget/icons.d.ts +11 -0
  32. package/dist/components/SessionRecorderWidget/icons.js +1 -0
  33. package/dist/components/SessionRecorderWidget/icons.js.map +1 -0
  34. package/dist/components/SessionRecorderWidget/index.d.ts +2 -0
  35. package/dist/components/SessionRecorderWidget/index.js +1 -0
  36. package/dist/components/SessionRecorderWidget/index.js.map +1 -0
  37. package/dist/components/SessionRecorderWidget/styles.d.ts +145 -0
  38. package/dist/components/SessionRecorderWidget/styles.js +1 -0
  39. package/dist/components/SessionRecorderWidget/styles.js.map +1 -0
  40. package/dist/components/index.d.ts +2 -0
  41. package/dist/components/index.js +1 -1
  42. package/dist/components/index.js.map +1 -1
  43. package/dist/config/defaults.js +1 -1
  44. package/dist/config/defaults.js.map +1 -1
  45. package/dist/config/masking.js +1 -1
  46. package/dist/config/masking.js.map +1 -1
  47. package/dist/context/SessionRecorderContext.d.ts +5 -3
  48. package/dist/context/SessionRecorderContext.js +1 -1
  49. package/dist/context/SessionRecorderContext.js.map +1 -1
  50. package/dist/index.d.ts +0 -1
  51. package/dist/index.js +1 -1
  52. package/dist/index.js.map +1 -1
  53. package/dist/native/ScreenMasking.d.ts +21 -0
  54. package/dist/native/ScreenMasking.js +1 -0
  55. package/dist/native/ScreenMasking.js.map +1 -0
  56. package/dist/native/SessionRecorderNative.d.ts +21 -0
  57. package/dist/native/SessionRecorderNative.js +1 -0
  58. package/dist/native/SessionRecorderNative.js.map +1 -0
  59. package/dist/patch/xhr.d.ts +1 -1
  60. package/dist/patch/xhr.js +1 -1
  61. package/dist/patch/xhr.js.map +1 -1
  62. package/dist/recorder/screenRecorder.d.ts +1 -0
  63. package/dist/recorder/screenRecorder.js +1 -1
  64. package/dist/recorder/screenRecorder.js.map +1 -1
  65. package/dist/recorder/screenshotManager.d.ts +10 -0
  66. package/dist/recorder/screenshotManager.js +1 -0
  67. package/dist/recorder/screenshotManager.js.map +1 -0
  68. package/dist/services/screenMaskingService.d.ts +39 -0
  69. package/dist/services/screenMaskingService.js +1 -0
  70. package/dist/services/screenMaskingService.js.map +1 -0
  71. package/dist/services/storage.service.d.ts +18 -2
  72. package/dist/services/storage.service.js +1 -1
  73. package/dist/services/storage.service.js.map +1 -1
  74. package/dist/session-recorder.d.ts +2 -1
  75. package/dist/session-recorder.js +1 -1
  76. package/dist/session-recorder.js.map +1 -1
  77. package/dist/types/session-recorder.d.ts +6 -0
  78. package/dist/types/session-recorder.js.map +1 -1
  79. package/dist/utils/componentRegistry.d.ts +64 -0
  80. package/dist/utils/componentRegistry.js +1 -0
  81. package/dist/utils/componentRegistry.js.map +1 -0
  82. package/dist/utils/platform.d.ts +3 -0
  83. package/dist/utils/reactNativeHierarchyExtractor.d.ts +38 -0
  84. package/dist/utils/reactNativeHierarchyExtractor.js +1 -0
  85. package/dist/utils/reactNativeHierarchyExtractor.js.map +1 -0
  86. package/dist/utils/screenshotMasker.d.ts +96 -0
  87. package/dist/utils/screenshotMasker.js +1 -0
  88. package/dist/utils/screenshotMasker.js.map +1 -0
  89. package/dist/utils/viewHierarchyTracker.d.ts +89 -0
  90. package/dist/utils/viewHierarchyTracker.js +1 -0
  91. package/dist/utils/viewHierarchyTracker.js.map +1 -0
  92. package/ios/ScreenMasking.m +12 -0
  93. package/ios/ScreenMasking.podspec +21 -0
  94. package/ios/ScreenMasking.swift +205 -0
  95. package/ios/SessionRecorder.m +12 -0
  96. package/ios/SessionRecorder.podspec +21 -0
  97. package/ios/SessionRecorder.swift +205 -0
  98. package/package.json +12 -2
  99. package/react-native.config.js +15 -0
  100. package/src/components/SessionRecorderWidget/FinalPopover.tsx +62 -0
  101. package/src/components/SessionRecorderWidget/FloatingButton.tsx +136 -0
  102. package/src/components/SessionRecorderWidget/InitialPopover.tsx +89 -0
  103. package/src/components/SessionRecorderWidget/ModalContainer.tsx +128 -0
  104. package/src/components/SessionRecorderWidget/ModalHeader.tsx +24 -0
  105. package/src/components/SessionRecorderWidget/SessionRecorderWidget.tsx +109 -0
  106. package/src/components/SessionRecorderWidget/icons.tsx +52 -0
  107. package/src/components/SessionRecorderWidget/index.ts +3 -0
  108. package/src/components/SessionRecorderWidget/styles.ts +150 -0
  109. package/src/components/index.ts +3 -1
  110. package/src/config/defaults.ts +1 -0
  111. package/src/config/masking.ts +1 -0
  112. package/src/context/SessionRecorderContext.tsx +12 -34
  113. package/src/index.ts +1 -9
  114. package/src/native/ScreenMasking.ts +34 -0
  115. package/src/native/SessionRecorderNative.ts +34 -0
  116. package/src/patch/xhr.ts +7 -7
  117. package/src/recorder/screenRecorder.ts +31 -2
  118. package/src/services/screenMaskingService.ts +114 -0
  119. package/src/services/storage.service.ts +45 -4
  120. package/src/session-recorder.ts +9 -3
  121. 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",
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