@regantis-sdk/react-native-chat 0.1.2 → 0.1.3

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/README.md CHANGED
@@ -25,6 +25,17 @@ pod install
25
25
 
26
26
  Android is autolinked by React Native.
27
27
 
28
+ ### iOS camera
29
+
30
+ To use the built-in camera action, add a camera usage description to the host app `Info.plist`:
31
+
32
+ ```xml
33
+ <key>NSCameraUsageDescription</key>
34
+ <string>Take photos to attach to support chat messages.</string>
35
+ ```
36
+
37
+ Android opens the system camera app and does not require an additional app permission for this SDK flow.
38
+
28
39
  ## Basic usage
29
40
 
30
41
  ```jsx
@@ -112,8 +123,8 @@ Server configuration remains the default. Per-app overrides are optional:
112
123
  - WebSocket realtime updates with HTTP polling fallback
113
124
  - typing state
114
125
  - customer-side translation selector
115
- - images and files
116
- - current-app screenshot attachment
126
+ - image gallery, file picker and direct camera capture
127
+ - in-chat image lightbox with pinch zoom and pan
117
128
  - native incoming-message sound
118
129
  - close/reopen conversation
119
130
  - rating and rating comment
@@ -167,4 +178,12 @@ The React Native component now mirrors the mobile web widget flow: it opens on t
167
178
  />
168
179
  ```
169
180
 
170
- Use `initialView="chat"` when an application should open directly in the conversation. `onClose` can be supplied when the host app wants the widget-style minimize action to navigate away from the chat page.
181
+ Use `initialView="chat"` when an application should open directly in the conversation. `onClose` can be supplied when the host app wants the widget-style minimize action to navigate away from the chat page. `onBack` controls the top-left back button on the welcome screen.
182
+
183
+ ```jsx
184
+ <RegantisChat
185
+ apiKey="YOUR_REACT_NATIVE_CHAT_API_KEY"
186
+ onBack={() => navigation.goBack()}
187
+ onClose={() => navigation.goBack()}
188
+ />
189
+ ```
@@ -21,4 +21,5 @@ repositories {
21
21
 
22
22
  dependencies {
23
23
  implementation "com.facebook.react:react-android"
24
+ implementation "androidx.core:core:1.13.1"
24
25
  }
@@ -1 +1,13 @@
1
- <manifest xmlns:android="http://schemas.android.com/apk/res/android" />
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ <application>
3
+ <provider
4
+ android:name="androidx.core.content.FileProvider"
5
+ android:authorities="${applicationId}.regantis.chat.fileprovider"
6
+ android:exported="false"
7
+ android:grantUriPermissions="true">
8
+ <meta-data
9
+ android:name="android.support.FILE_PROVIDER_PATHS"
10
+ android:resource="@xml/regantis_chat_file_paths" />
11
+ </provider>
12
+ </application>
13
+ </manifest>
@@ -1,21 +1,22 @@
1
1
  package technology.regantis.chat;
2
2
 
3
3
  import android.app.Activity;
4
+ import android.content.ClipData;
4
5
  import android.content.Context;
5
6
  import android.content.Intent;
6
7
  import android.content.SharedPreferences;
7
- import android.graphics.Bitmap;
8
- import android.graphics.Canvas;
8
+ import android.content.pm.ResolveInfo;
9
9
  import android.database.Cursor;
10
10
  import android.media.Ringtone;
11
11
  import android.media.RingtoneManager;
12
12
  import android.net.Uri;
13
+ import android.provider.MediaStore;
13
14
  import android.provider.OpenableColumns;
14
- import android.view.View;
15
15
  import android.webkit.MimeTypeMap;
16
16
 
17
17
  import androidx.annotation.NonNull;
18
18
  import androidx.annotation.Nullable;
19
+ import androidx.core.content.FileProvider;
19
20
 
20
21
  import com.facebook.react.bridge.ActivityEventListener;
21
22
  import com.facebook.react.bridge.Arguments;
@@ -27,44 +28,87 @@ import com.facebook.react.bridge.ReactMethod;
27
28
  import com.facebook.react.bridge.WritableMap;
28
29
 
29
30
  import java.io.File;
30
- import java.io.FileOutputStream;
31
+ import java.util.List;
31
32
 
32
33
  public class RegantisChatNativeModule extends ReactContextBaseJavaModule {
33
34
  private static final String MODULE_NAME = "RegantisChatNative";
34
35
  private static final String STORAGE_NAME = "regantis_react_native_chat";
35
36
  private static final int PICK_ATTACHMENT_REQUEST = 17841;
37
+ private static final int TAKE_PHOTO_REQUEST = 17842;
36
38
 
37
39
  private final ReactApplicationContext reactContext;
38
40
  private Promise pickerPromise;
41
+ private Promise cameraPromise;
42
+ private File cameraFile;
43
+ private Uri cameraOutputUri;
39
44
 
40
45
  private final ActivityEventListener activityEventListener = new BaseActivityEventListener() {
41
46
  @Override
42
47
  public void onActivityResult(Activity activity, int requestCode, int resultCode, @Nullable Intent data) {
43
- if (requestCode != PICK_ATTACHMENT_REQUEST || pickerPromise == null) return;
48
+ if (requestCode == PICK_ATTACHMENT_REQUEST && pickerPromise != null) {
49
+ Promise promise = pickerPromise;
50
+ pickerPromise = null;
44
51
 
45
- Promise promise = pickerPromise;
46
- pickerPromise = null;
52
+ if (resultCode != Activity.RESULT_OK || data == null || data.getData() == null) {
53
+ promise.resolve(null);
54
+ return;
55
+ }
47
56
 
48
- if (resultCode != Activity.RESULT_OK || data == null || data.getData() == null) {
49
- promise.resolve(null);
57
+ Uri uri = data.getData();
58
+ try {
59
+ int flags = data.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
60
+ if ((flags & Intent.FLAG_GRANT_READ_URI_PERMISSION) != 0) {
61
+ reactContext.getContentResolver().takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
62
+ }
63
+ } catch (Exception ignored) {
64
+ }
65
+
66
+ WritableMap result = Arguments.createMap();
67
+ result.putString("uri", uri.toString());
68
+ result.putString("name", getDisplayName(uri));
69
+ result.putString("type", getMimeType(uri));
70
+ result.putDouble("size", getSize(uri));
71
+ promise.resolve(result);
50
72
  return;
51
73
  }
52
74
 
53
- Uri uri = data.getData();
54
- try {
55
- int flags = data.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
56
- if ((flags & Intent.FLAG_GRANT_READ_URI_PERMISSION) != 0) {
57
- reactContext.getContentResolver().takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
75
+ if (requestCode == TAKE_PHOTO_REQUEST && cameraPromise != null) {
76
+ Promise promise = cameraPromise;
77
+ File file = cameraFile;
78
+ Uri outputUri = cameraOutputUri;
79
+ cameraPromise = null;
80
+ cameraFile = null;
81
+ cameraOutputUri = null;
82
+
83
+ if (outputUri != null) {
84
+ try {
85
+ reactContext.revokeUriPermission(
86
+ outputUri,
87
+ Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
88
+ );
89
+ } catch (Exception ignored) {
90
+ }
91
+ }
92
+
93
+ if (resultCode != Activity.RESULT_OK) {
94
+ if (file != null) file.delete();
95
+ promise.resolve(null);
96
+ return;
97
+ }
98
+
99
+ if (file == null || !file.exists() || file.length() < 1) {
100
+ if (file != null) file.delete();
101
+ promise.reject("CAMERA_FAILED", "Camera did not return an image");
102
+ return;
58
103
  }
59
- } catch (Exception ignored) {
60
- }
61
104
 
62
- WritableMap result = Arguments.createMap();
63
- result.putString("uri", uri.toString());
64
- result.putString("name", getDisplayName(uri));
65
- result.putString("type", getMimeType(uri));
66
- result.putDouble("size", getSize(uri));
67
- promise.resolve(result);
105
+ WritableMap result = Arguments.createMap();
106
+ result.putString("uri", Uri.fromFile(file).toString());
107
+ result.putString("name", file.getName());
108
+ result.putString("type", "image/jpeg");
109
+ result.putDouble("size", file.length());
110
+ promise.resolve(result);
111
+ }
68
112
  }
69
113
  };
70
114
 
@@ -128,49 +172,58 @@ public class RegantisChatNativeModule extends ReactContextBaseJavaModule {
128
172
  }
129
173
 
130
174
  @ReactMethod
131
- public void captureScreenshot(Promise promise) {
175
+ public void takePhoto(Promise promise) {
132
176
  Activity activity = getCurrentActivity();
133
177
  if (activity == null) {
134
178
  promise.reject("NO_ACTIVITY", "No active Android Activity");
135
179
  return;
136
180
  }
181
+ if (cameraPromise != null || pickerPromise != null) {
182
+ promise.reject("PICKER_BUSY", "An attachment picker is already open");
183
+ return;
184
+ }
137
185
 
138
- activity.runOnUiThread(() -> {
139
- try {
140
- View root = activity.getWindow().getDecorView().getRootView();
141
- int width = root.getWidth();
142
- int height = root.getHeight();
143
- if (width < 1 || height < 1) {
144
- promise.reject("SCREENSHOT_FAILED", "Invalid app window size");
145
- return;
146
- }
186
+ try {
187
+ Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
188
+ if (intent.resolveActivity(reactContext.getPackageManager()) == null) {
189
+ promise.reject("CAMERA_UNAVAILABLE", "No camera application is available");
190
+ return;
191
+ }
147
192
 
148
- Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
149
- Canvas canvas = new Canvas(bitmap);
150
- root.draw(canvas);
193
+ File directory = new File(reactContext.getCacheDir(), "regantis-chat/camera");
194
+ if (!directory.exists() && !directory.mkdirs()) {
195
+ promise.reject("CAMERA_FAILED", "Unable to create camera directory");
196
+ return;
197
+ }
151
198
 
152
- File directory = new File(reactContext.getCacheDir(), "regantis-chat");
153
- if (!directory.exists() && !directory.mkdirs()) {
154
- promise.reject("SCREENSHOT_FAILED", "Unable to create screenshot directory");
155
- return;
156
- }
157
- File file = new File(directory, "screenshot-" + System.currentTimeMillis() + ".png");
158
- try (FileOutputStream stream = new FileOutputStream(file)) {
159
- bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream);
160
- } finally {
161
- bitmap.recycle();
162
- }
199
+ File file = File.createTempFile("camera-", ".jpg", directory);
200
+ String authority = reactContext.getPackageName() + ".regantis.chat.fileprovider";
201
+ Uri outputUri = FileProvider.getUriForFile(reactContext, authority, file);
163
202
 
164
- WritableMap result = Arguments.createMap();
165
- result.putString("uri", Uri.fromFile(file).toString());
166
- result.putString("name", file.getName());
167
- result.putString("type", "image/png");
168
- result.putDouble("size", file.length());
169
- promise.resolve(result);
170
- } catch (Exception error) {
171
- promise.reject("SCREENSHOT_FAILED", error);
203
+ intent.putExtra(MediaStore.EXTRA_OUTPUT, outputUri);
204
+ intent.setClipData(ClipData.newRawUri("RegantisChatCamera", outputUri));
205
+ intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
206
+
207
+ List<ResolveInfo> cameraActivities = reactContext.getPackageManager().queryIntentActivities(intent, 0);
208
+ for (ResolveInfo info : cameraActivities) {
209
+ reactContext.grantUriPermission(
210
+ info.activityInfo.packageName,
211
+ outputUri,
212
+ Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
213
+ );
172
214
  }
173
- });
215
+
216
+ cameraPromise = promise;
217
+ cameraFile = file;
218
+ cameraOutputUri = outputUri;
219
+ activity.startActivityForResult(intent, TAKE_PHOTO_REQUEST);
220
+ } catch (Exception error) {
221
+ cameraPromise = null;
222
+ if (cameraFile != null) cameraFile.delete();
223
+ cameraFile = null;
224
+ cameraOutputUri = null;
225
+ promise.reject("CAMERA_FAILED", error);
226
+ }
174
227
  }
175
228
 
176
229
  @ReactMethod
@@ -180,7 +233,7 @@ public class RegantisChatNativeModule extends ReactContextBaseJavaModule {
180
233
  promise.reject("NO_ACTIVITY", "No active Android Activity");
181
234
  return;
182
235
  }
183
- if (pickerPromise != null) {
236
+ if (pickerPromise != null || cameraPromise != null) {
184
237
  promise.reject("PICKER_BUSY", "An attachment picker is already open");
185
238
  return;
186
239
  }
@@ -0,0 +1,4 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <paths xmlns:android="http://schemas.android.com/apk/res/android">
3
+ <cache-path name="regantis_chat_cache" path="regantis-chat/" />
4
+ </paths>
package/index.d.ts CHANGED
@@ -62,6 +62,7 @@ export type RegantisChatProps = {
62
62
  style?: StyleProp<ViewStyle>;
63
63
  initialView?: 'home' | 'chat';
64
64
  onClose?: () => void;
65
+ onBack?: () => void;
65
66
  onViewChange?: (view: 'home' | 'chat') => void;
66
67
  keyboardVerticalOffset?: number;
67
68
  onMessage?: (message: any) => void;
@@ -78,7 +79,7 @@ export class RegantisChatClient {
78
79
  sendMessage(message: string): Promise<any>;
79
80
  saveContact(name: string, email: string): Promise<any>;
80
81
  uploadAttachment(file: { uri: string; name?: string; type?: string }, kind: 'image' | 'file'): Promise<any>;
81
- pickAndUpload(kind: 'image' | 'file' | 'screenshot'): Promise<any>;
82
+ pickAndUpload(kind: 'image' | 'file' | 'camera'): Promise<any>;
82
83
  setTranslationLanguage(languageCode: string): Promise<void>;
83
84
  closeChat(): Promise<any>;
84
85
  reopenChat(): Promise<any>;
@@ -1,5 +1,5 @@
1
1
  #import <React/RCTBridgeModule.h>
2
2
  #import <UIKit/UIKit.h>
3
3
 
4
- @interface RegantisChatNative : NSObject <RCTBridgeModule, UIDocumentPickerDelegate>
4
+ @interface RegantisChatNative : NSObject <RCTBridgeModule, UIDocumentPickerDelegate, UIImagePickerControllerDelegate, UINavigationControllerDelegate>
5
5
  @end
@@ -66,50 +66,35 @@ RCT_EXPORT_METHOD(playIncomingSound)
66
66
  AudioServicesPlaySystemSound(1007);
67
67
  }
68
68
 
69
- RCT_REMAP_METHOD(captureScreenshot,
70
- captureScreenshotWithResolver:(RCTPromiseResolveBlock)resolve
69
+ RCT_REMAP_METHOD(takePhoto,
70
+ takePhotoWithResolver:(RCTPromiseResolveBlock)resolve
71
71
  rejecter:(RCTPromiseRejectBlock)reject)
72
72
  {
73
73
  dispatch_async(dispatch_get_main_queue(), ^{
74
- UIViewController *controller = RCTPresentedViewController();
75
- UIWindow *window = controller.view.window ?: UIApplication.sharedApplication.keyWindow;
76
- if (window == nil) {
77
- reject(@"SCREENSHOT_FAILED", @"No active iOS window", nil);
74
+ if (self.pickerResolve != nil) {
75
+ reject(@"PICKER_BUSY", @"An attachment picker is already open", nil);
78
76
  return;
79
77
  }
80
78
 
81
- UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithBounds:window.bounds];
82
- UIImage *image = [renderer imageWithActions:^(UIGraphicsImageRendererContext * _Nonnull rendererContext) {
83
- [window drawViewHierarchyInRect:window.bounds afterScreenUpdates:YES];
84
- }];
85
- NSData *data = UIImagePNGRepresentation(image);
86
- if (data == nil) {
87
- reject(@"SCREENSHOT_FAILED", @"Unable to encode screenshot", nil);
79
+ if (![UIImagePickerController isSourceTypeAvailable:UIImagePickerControllerSourceTypeCamera]) {
80
+ reject(@"CAMERA_UNAVAILABLE", @"Camera is not available on this device", nil);
88
81
  return;
89
82
  }
90
83
 
91
- NSString *filename = [NSString stringWithFormat:@"screenshot-%lld.png", (long long)(NSDate.date.timeIntervalSince1970 * 1000.0)];
92
- NSString *directory = [NSTemporaryDirectory() stringByAppendingPathComponent:@"regantis-chat"];
93
- NSError *directoryError = nil;
94
- [[NSFileManager defaultManager] createDirectoryAtPath:directory withIntermediateDirectories:YES attributes:nil error:&directoryError];
95
- if (directoryError != nil) {
96
- reject(@"SCREENSHOT_FAILED", directoryError.localizedDescription, directoryError);
84
+ UIViewController *controller = RCTPresentedViewController();
85
+ if (controller == nil) {
86
+ reject(@"NO_VIEW_CONTROLLER", @"No active iOS view controller", nil);
97
87
  return;
98
88
  }
99
89
 
100
- NSString *path = [directory stringByAppendingPathComponent:filename];
101
- NSError *writeError = nil;
102
- if (![data writeToFile:path options:NSDataWritingAtomic error:&writeError]) {
103
- reject(@"SCREENSHOT_FAILED", writeError.localizedDescription, writeError);
104
- return;
105
- }
90
+ UIImagePickerController *picker = [[UIImagePickerController alloc] init];
91
+ picker.sourceType = UIImagePickerControllerSourceTypeCamera;
92
+ picker.delegate = self;
93
+ picker.allowsEditing = NO;
106
94
 
107
- resolve(@{
108
- @"uri": [NSURL fileURLWithPath:path].absoluteString ?: @"",
109
- @"name": filename,
110
- @"type": @"image/png",
111
- @"size": @(data.length)
112
- });
95
+ self.pickerResolve = resolve;
96
+ self.pickerReject = reject;
97
+ [controller presentViewController:picker animated:YES completion:nil];
113
98
  });
114
99
  }
115
100
 
@@ -146,6 +131,71 @@ RCT_REMAP_METHOD(pickAttachment,
146
131
  });
147
132
  }
148
133
 
134
+ - (void)imagePickerControllerDidCancel:(UIImagePickerController *)picker
135
+ {
136
+ RCTPromiseResolveBlock resolve = self.pickerResolve;
137
+ self.pickerResolve = nil;
138
+ self.pickerReject = nil;
139
+ [picker dismissViewControllerAnimated:YES completion:^{
140
+ if (resolve) resolve(nil);
141
+ }];
142
+ }
143
+
144
+ - (void)imagePickerController:(UIImagePickerController *)picker didFinishPickingMediaWithInfo:(NSDictionary<UIImagePickerControllerInfoKey,id> *)info
145
+ {
146
+ RCTPromiseResolveBlock resolve = self.pickerResolve;
147
+ RCTPromiseRejectBlock reject = self.pickerReject;
148
+ self.pickerResolve = nil;
149
+ self.pickerReject = nil;
150
+
151
+ UIImage *image = info[UIImagePickerControllerOriginalImage];
152
+ if (image == nil) {
153
+ [picker dismissViewControllerAnimated:YES completion:^{
154
+ if (reject) reject(@"CAMERA_FAILED", @"Camera did not return an image", nil);
155
+ }];
156
+ return;
157
+ }
158
+
159
+ NSData *data = UIImageJPEGRepresentation(image, 0.92);
160
+ if (data == nil) {
161
+ [picker dismissViewControllerAnimated:YES completion:^{
162
+ if (reject) reject(@"CAMERA_FAILED", @"Unable to encode camera image", nil);
163
+ }];
164
+ return;
165
+ }
166
+
167
+ NSString *directory = [NSTemporaryDirectory() stringByAppendingPathComponent:@"regantis-chat/camera"];
168
+ NSError *directoryError = nil;
169
+ [[NSFileManager defaultManager] createDirectoryAtPath:directory withIntermediateDirectories:YES attributes:nil error:&directoryError];
170
+ if (directoryError != nil) {
171
+ [picker dismissViewControllerAnimated:YES completion:^{
172
+ if (reject) reject(@"CAMERA_FAILED", directoryError.localizedDescription, directoryError);
173
+ }];
174
+ return;
175
+ }
176
+
177
+ NSString *filename = [NSString stringWithFormat:@"camera-%lld.jpg", (long long)(NSDate.date.timeIntervalSince1970 * 1000.0)];
178
+ NSString *path = [directory stringByAppendingPathComponent:filename];
179
+ NSError *writeError = nil;
180
+ if (![data writeToFile:path options:NSDataWritingAtomic error:&writeError]) {
181
+ [picker dismissViewControllerAnimated:YES completion:^{
182
+ if (reject) reject(@"CAMERA_FAILED", writeError.localizedDescription, writeError);
183
+ }];
184
+ return;
185
+ }
186
+
187
+ NSDictionary *result = @{
188
+ @"uri": [NSURL fileURLWithPath:path].absoluteString ?: @"",
189
+ @"name": filename,
190
+ @"type": @"image/jpeg",
191
+ @"size": @(data.length)
192
+ };
193
+
194
+ [picker dismissViewControllerAnimated:YES completion:^{
195
+ if (resolve) resolve(result);
196
+ }];
197
+ }
198
+
149
199
  - (void)documentPickerWasCancelled:(UIDocumentPickerViewController *)controller
150
200
  {
151
201
  if (self.pickerResolve) self.pickerResolve(nil);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@regantis-sdk/react-native-chat",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Regantis customer support chat SDK for React Native Android and iOS",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -3,12 +3,14 @@
3
3
  const React = require('react');
4
4
  const {
5
5
  ActivityIndicator,
6
+ Animated,
6
7
  AppState,
7
8
  FlatList,
8
9
  Image,
9
10
  KeyboardAvoidingView,
10
11
  Linking,
11
12
  Modal,
13
+ PanResponder,
12
14
  Platform,
13
15
  Pressable,
14
16
  ScrollView,
@@ -21,6 +23,170 @@ const { RegantisChatClient } = require('./client');
21
23
 
22
24
  const EMOJIS = ['🙂', '😁', '😂', '😊', '😍', '😐', '🤔', '😞', '😢', '😭', '🎉', '❤️', '👌', '👍', '👎', '🙏'];
23
25
 
26
+
27
+ const LOCAL_TEXTS = {
28
+ en: { attach_camera: 'Take photo', lightbox_close: 'Close', lightbox_reset: 'Reset zoom', back: 'Back' },
29
+ ro: { attach_camera: 'Fă o fotografie', lightbox_close: 'Închide', lightbox_reset: 'Resetează zoom', back: 'Înapoi' },
30
+ de: { attach_camera: 'Foto aufnehmen', lightbox_close: 'Schließen', lightbox_reset: 'Zoom zurücksetzen', back: 'Zurück' },
31
+ fr: { attach_camera: 'Prendre une photo', lightbox_close: 'Fermer', lightbox_reset: 'Réinitialiser le zoom', back: 'Retour' },
32
+ es: { attach_camera: 'Tomar una foto', lightbox_close: 'Cerrar', lightbox_reset: 'Restablecer zoom', back: 'Atrás' },
33
+ it: { attach_camera: 'Scatta una foto', lightbox_close: 'Chiudi', lightbox_reset: 'Reimposta zoom', back: 'Indietro' },
34
+ };
35
+
36
+ function localText(language, key) {
37
+ const code = String(language || 'en').trim().toLowerCase().split(/[-_]/)[0];
38
+ return (LOCAL_TEXTS[code] && LOCAL_TEXTS[code][key]) || LOCAL_TEXTS.en[key] || '';
39
+ }
40
+
41
+ function touchDistance(touches) {
42
+ if (!touches || touches.length < 2) return 0;
43
+ const dx = Number(touches[0].pageX || 0) - Number(touches[1].pageX || 0);
44
+ const dy = Number(touches[0].pageY || 0) - Number(touches[1].pageY || 0);
45
+ return Math.sqrt(dx * dx + dy * dy);
46
+ }
47
+
48
+ function ImageLightbox({ visible, uri, onClose, closeLabel, resetLabel }) {
49
+ const scale = React.useRef(new Animated.Value(1)).current;
50
+ const translateX = React.useRef(new Animated.Value(0)).current;
51
+ const translateY = React.useRef(new Animated.Value(0)).current;
52
+ const current = React.useRef({ scale: 1, x: 0, y: 0 });
53
+ const gesture = React.useRef({ mode: '', distance: 0, scale: 1, x: 0, y: 0, startX: 0, startY: 0 });
54
+
55
+ const resetZoom = React.useCallback((animated = true) => {
56
+ current.current = { scale: 1, x: 0, y: 0 };
57
+ gesture.current.mode = '';
58
+ const values = [
59
+ Animated.spring(scale, { toValue: 1, useNativeDriver: true, friction: 8, tension: 70 }),
60
+ Animated.spring(translateX, { toValue: 0, useNativeDriver: true, friction: 8, tension: 70 }),
61
+ Animated.spring(translateY, { toValue: 0, useNativeDriver: true, friction: 8, tension: 70 }),
62
+ ];
63
+ if (animated) Animated.parallel(values).start();
64
+ else {
65
+ scale.setValue(1);
66
+ translateX.setValue(0);
67
+ translateY.setValue(0);
68
+ }
69
+ }, [scale, translateX, translateY]);
70
+
71
+ React.useEffect(() => {
72
+ if (visible) resetZoom(false);
73
+ }, [visible, uri, resetZoom]);
74
+
75
+ const panResponder = React.useMemo(() => PanResponder.create({
76
+ onStartShouldSetPanResponder: () => true,
77
+ onMoveShouldSetPanResponder: () => true,
78
+ onPanResponderGrant: event => {
79
+ const touches = event.nativeEvent.touches || [];
80
+ if (touches.length >= 2) {
81
+ gesture.current = {
82
+ mode: 'pinch',
83
+ distance: Math.max(1, touchDistance(touches)),
84
+ scale: current.current.scale,
85
+ x: current.current.x,
86
+ y: current.current.y,
87
+ startX: 0,
88
+ startY: 0,
89
+ };
90
+ } else if (touches.length === 1) {
91
+ gesture.current = {
92
+ mode: 'pan',
93
+ distance: 0,
94
+ scale: current.current.scale,
95
+ x: current.current.x,
96
+ y: current.current.y,
97
+ startX: Number(touches[0].pageX || 0),
98
+ startY: Number(touches[0].pageY || 0),
99
+ };
100
+ }
101
+ },
102
+ onPanResponderMove: event => {
103
+ const touches = event.nativeEvent.touches || [];
104
+ if (touches.length >= 2) {
105
+ const distance = Math.max(1, touchDistance(touches));
106
+ if (gesture.current.mode !== 'pinch') {
107
+ gesture.current.mode = 'pinch';
108
+ gesture.current.distance = distance;
109
+ gesture.current.scale = current.current.scale;
110
+ }
111
+ const nextScale = Math.max(1, Math.min(5, gesture.current.scale * (distance / Math.max(1, gesture.current.distance))));
112
+ current.current.scale = nextScale;
113
+ scale.setValue(nextScale);
114
+ if (nextScale <= 1.01) {
115
+ current.current.x = 0;
116
+ current.current.y = 0;
117
+ translateX.setValue(0);
118
+ translateY.setValue(0);
119
+ }
120
+ return;
121
+ }
122
+
123
+ if (touches.length === 1 && current.current.scale > 1.01) {
124
+ const x = Number(touches[0].pageX || 0);
125
+ const y = Number(touches[0].pageY || 0);
126
+ if (gesture.current.mode !== 'pan') {
127
+ gesture.current.mode = 'pan';
128
+ gesture.current.x = current.current.x;
129
+ gesture.current.y = current.current.y;
130
+ gesture.current.startX = x;
131
+ gesture.current.startY = y;
132
+ }
133
+ const nextX = gesture.current.x + (x - gesture.current.startX);
134
+ const nextY = gesture.current.y + (y - gesture.current.startY);
135
+ current.current.x = nextX;
136
+ current.current.y = nextY;
137
+ translateX.setValue(nextX);
138
+ translateY.setValue(nextY);
139
+ }
140
+ },
141
+ onPanResponderRelease: () => {
142
+ gesture.current.mode = '';
143
+ if (current.current.scale <= 1.05) resetZoom(true);
144
+ },
145
+ onPanResponderTerminate: () => {
146
+ gesture.current.mode = '';
147
+ if (current.current.scale <= 1.05) resetZoom(true);
148
+ },
149
+ }), [resetZoom, scale, translateX, translateY]);
150
+
151
+ return (
152
+ <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose} statusBarTranslucent>
153
+ <View style={lightboxStyles.root}>
154
+ <View style={lightboxStyles.toolbar}>
155
+ <Pressable accessibilityRole="button" accessibilityLabel={closeLabel} style={lightboxStyles.toolbarButton} onPress={onClose}>
156
+ <Text style={lightboxStyles.closeIcon}>‹</Text>
157
+ </Pressable>
158
+ <Pressable accessibilityRole="button" accessibilityLabel={resetLabel} style={lightboxStyles.resetButton} onPress={() => resetZoom(true)}>
159
+ <Text style={lightboxStyles.resetText}>1:1</Text>
160
+ </Pressable>
161
+ </View>
162
+ <View style={lightboxStyles.stage} {...panResponder.panHandlers}>
163
+ {uri ? (
164
+ <Animated.Image
165
+ source={{ uri }}
166
+ resizeMode="contain"
167
+ style={[
168
+ lightboxStyles.image,
169
+ { transform: [{ translateX }, { translateY }, { scale }] },
170
+ ]}
171
+ />
172
+ ) : null}
173
+ </View>
174
+ </View>
175
+ </Modal>
176
+ );
177
+ }
178
+
179
+ const lightboxStyles = StyleSheet.create({
180
+ root: { flex: 1, backgroundColor: 'rgba(0,0,0,0.96)' },
181
+ toolbar: { position: 'absolute', zIndex: 20, top: Platform.OS === 'ios' ? 48 : 18, left: 14, right: 14, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
182
+ toolbarButton: { width: 44, height: 44, borderRadius: 22, backgroundColor: 'rgba(255,255,255,0.14)', alignItems: 'center', justifyContent: 'center' },
183
+ closeIcon: { color: '#FFFFFF', fontSize: 39, lineHeight: 40, marginTop: -6 },
184
+ resetButton: { minWidth: 50, height: 40, paddingHorizontal: 12, borderRadius: 20, backgroundColor: 'rgba(255,255,255,0.14)', alignItems: 'center', justifyContent: 'center' },
185
+ resetText: { color: '#FFFFFF', fontSize: 13, fontWeight: '700' },
186
+ stage: { flex: 1, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' },
187
+ image: { width: '100%', height: '100%' },
188
+ });
189
+
24
190
  function colorWithOpacity(hex, opacity) {
25
191
  const value = String(hex || '#F5F7FB').replace('#', '');
26
192
  if (!/^[0-9a-f]{6}$/i.test(value)) return hex;
@@ -81,6 +247,7 @@ function RegantisChat(props) {
81
247
  style,
82
248
  initialView = 'home',
83
249
  onClose,
250
+ onBack,
84
251
  onViewChange,
85
252
  onMessage,
86
253
  onUnreadChange,
@@ -119,6 +286,7 @@ function RegantisChat(props) {
119
286
  const [ratingComment, setRatingComment] = React.useState('');
120
287
  const [ratingStatus, setRatingStatus] = React.useState('');
121
288
  const [busyAction, setBusyAction] = React.useState('');
289
+ const [lightboxUri, setLightboxUri] = React.useState('');
122
290
  const listRef = React.useRef(null);
123
291
 
124
292
  React.useEffect(() => {
@@ -146,7 +314,7 @@ function RegantisChat(props) {
146
314
  }
147
315
  }, [activeView, state.messages.length]);
148
316
 
149
- const t = key => state.texts[key] || key;
317
+ const t = key => state.texts[key] || localText(language, key) || key;
150
318
  const palette = makePalette(state.settings);
151
319
  const styles = React.useMemo(() => buildStyles(palette), [
152
320
  palette.primary,
@@ -336,7 +504,7 @@ function RegantisChat(props) {
336
504
  <View style={[styles.messageRow, visitor ? styles.messageRowRight : styles.messageRowLeft]}>
337
505
  <View style={[styles.bubble, bubbleStyle]}>
338
506
  {isImage ? (
339
- <Pressable onPress={() => Linking.openURL(attachment.url)}>
507
+ <Pressable onPress={() => setLightboxUri(attachment.url)}>
340
508
  <Image source={{ uri: attachment.url }} style={styles.attachmentImage} resizeMode="cover" />
341
509
  </Pressable>
342
510
  ) : attachment ? (
@@ -368,7 +536,14 @@ function RegantisChat(props) {
368
536
  <View pointerEvents="none" style={styles.heroDecorationC} />
369
537
 
370
538
  <View style={styles.homeTop}>
371
- <View>{renderLogo(42, 0)}</View>
539
+ <View style={styles.homeTopLeft}>
540
+ {typeof onBack === 'function' || typeof onClose === 'function' ? (
541
+ <Pressable accessibilityRole="button" accessibilityLabel={t('back')} style={styles.iconButton} onPress={typeof onBack === 'function' ? onBack : onClose}>
542
+ <Text style={styles.homeBackIcon}>‹</Text>
543
+ </Pressable>
544
+ ) : null}
545
+ <View>{renderLogo(42, 0)}</View>
546
+ </View>
372
547
  {typeof onClose === 'function' ? (
373
548
  <Pressable accessibilityRole="button" accessibilityLabel={t('minimize')} style={styles.iconButton} onPress={onClose}>
374
549
  <Text style={styles.minimizeIcon}>−</Text>
@@ -517,9 +692,11 @@ function RegantisChat(props) {
517
692
 
518
693
  {attachOpen ? (
519
694
  <View style={styles.attachmentMenu}>
695
+ {Platform.OS === 'android' || Platform.OS === 'ios' ? (
696
+ <Pressable style={styles.attachmentAction} onPress={() => pick('camera')}><Text style={styles.attachmentActionIcon}>◉</Text><Text style={styles.attachmentActionText}>{t('attach_camera')}</Text></Pressable>
697
+ ) : null}
520
698
  <Pressable style={styles.attachmentAction} onPress={() => pick('image')}><Text style={styles.attachmentActionIcon}>▣</Text><Text style={styles.attachmentActionText}>{t('attach_image')}</Text></Pressable>
521
699
  <Pressable style={styles.attachmentAction} onPress={() => pick('file')}><Text style={styles.attachmentActionIcon}>↥</Text><Text style={styles.attachmentActionText}>{t('attach_file')}</Text></Pressable>
522
- <Pressable style={styles.attachmentAction} onPress={() => pick('screenshot')}><Text style={styles.attachmentActionIcon}>▧</Text><Text style={styles.attachmentActionText}>{t('attach_screenshot')}</Text></Pressable>
523
700
  </View>
524
701
  ) : null}
525
702
 
@@ -663,6 +840,14 @@ function RegantisChat(props) {
663
840
  </View>
664
841
  </View>
665
842
  </Modal>
843
+
844
+ <ImageLightbox
845
+ visible={!!lightboxUri}
846
+ uri={lightboxUri}
847
+ onClose={() => setLightboxUri('')}
848
+ closeLabel={t('lightbox_close')}
849
+ resetLabel={t('lightbox_reset')}
850
+ />
666
851
  </KeyboardAvoidingView>
667
852
  );
668
853
  }
@@ -688,6 +873,8 @@ function buildStyles(p) {
688
873
  heroDecorationB: { position: 'absolute', width: 230, height: 230, borderRadius: 115, top: 80, right: -92, backgroundColor: p.heroBlobB },
689
874
  heroDecorationC: { position: 'absolute', width: 210, height: 210, borderRadius: 105, right: -82, bottom: -120, backgroundColor: p.heroBlobC },
690
875
  homeTop: { minHeight: 42, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', zIndex: 2 },
876
+ homeTopLeft: { flexDirection: 'row', alignItems: 'center', gap: 9 },
877
+ homeBackIcon: { color: p.text, fontSize: 37, lineHeight: 38, marginTop: -5 },
691
878
  homeTopPlaceholder: { width: 32, height: 32 },
692
879
  logoImage: { backgroundColor: p.surface },
693
880
  logoFallback: { backgroundColor: p.primary, alignItems: 'center', justifyContent: 'center', shadowColor: p.primary, shadowOffset: { width: 0, height: 6 }, shadowOpacity: 0.22, shadowRadius: 10, elevation: 4 },
package/src/client.js CHANGED
@@ -438,10 +438,10 @@ class RegantisChatClient {
438
438
  }
439
439
 
440
440
  async pickAndUpload(kind) {
441
- const type = kind === 'image' ? 'image' : 'file';
442
- const file = kind === 'screenshot' ? await native.captureScreenshot() : await native.pickAttachment(type);
441
+ const type = kind === 'image' || kind === 'camera' ? 'image' : 'file';
442
+ const file = kind === 'camera' ? await native.takePhoto() : await native.pickAttachment(type);
443
443
  if (!file) return null;
444
- return this.uploadAttachment(file, kind === 'screenshot' ? 'image' : type);
444
+ return this.uploadAttachment(file, type);
445
445
  }
446
446
 
447
447
  async setTranslationLanguage(languageCode) {
package/src/defaults.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  module.exports = {
4
4
  DEFAULT_SERVER_URL: 'https://www.crm.regantis.technology',
5
- SDK_VERSION: '0.1.2',
5
+ SDK_VERSION: '0.1.3',
6
6
  DEFAULT_SETTINGS: {
7
7
  operator_name: 'Operator',
8
8
  use_profile_names: 0,
package/src/native.js CHANGED
@@ -32,11 +32,11 @@ async function pickAttachment(kind) {
32
32
  return native.pickAttachment(kind === 'image' ? 'image' : 'file');
33
33
  }
34
34
 
35
- async function captureScreenshot() {
36
- if (!native || !native.captureScreenshot) {
37
- throw new Error('NATIVE_SCREENSHOT_UNAVAILABLE');
35
+ async function takePhoto() {
36
+ if (!native || !native.takePhoto) {
37
+ throw new Error('NATIVE_CAMERA_UNAVAILABLE');
38
38
  }
39
- return native.captureScreenshot();
39
+ return native.takePhoto();
40
40
  }
41
41
 
42
42
  function playIncomingSound() {
@@ -51,6 +51,6 @@ module.exports = {
51
51
  removeItem,
52
52
  getAppInfo,
53
53
  pickAttachment,
54
- captureScreenshot,
54
+ takePhoto,
55
55
  playIncomingSound,
56
56
  };