@regantis-sdk/react-native-chat 0.1.1 → 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
@@ -155,3 +166,24 @@ chat.stop();
155
166
  npm login
156
167
  npm publish --access public
157
168
  ```
169
+
170
+ ## Mobile widget-style navigation
171
+
172
+ The React Native component now mirrors the mobile web widget flow: it opens on the welcome/home screen and the visitor enters the conversation using the chat card or Chat navigation item.
173
+
174
+ ```jsx
175
+ <RegantisChat
176
+ apiKey="YOUR_REACT_NATIVE_CHAT_API_KEY"
177
+ initialView="home"
178
+ />
179
+ ```
180
+
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
@@ -60,6 +60,10 @@ export type RegantisChatProps = {
60
60
  texts?: Record<string, string>;
61
61
  screen?: string;
62
62
  style?: StyleProp<ViewStyle>;
63
+ initialView?: 'home' | 'chat';
64
+ onClose?: () => void;
65
+ onBack?: () => void;
66
+ onViewChange?: (view: 'home' | 'chat') => void;
63
67
  keyboardVerticalOffset?: number;
64
68
  onMessage?: (message: any) => void;
65
69
  onUnreadChange?: (count: number) => void;
@@ -75,7 +79,7 @@ export class RegantisChatClient {
75
79
  sendMessage(message: string): Promise<any>;
76
80
  saveContact(name: string, email: string): Promise<any>;
77
81
  uploadAttachment(file: { uri: string; name?: string; type?: string }, kind: 'image' | 'file'): Promise<any>;
78
- pickAndUpload(kind: 'image' | 'file' | 'screenshot'): Promise<any>;
82
+ pickAndUpload(kind: 'image' | 'file' | 'camera'): Promise<any>;
79
83
  setTranslationLanguage(languageCode: string): Promise<void>;
80
84
  closeChat(): Promise<any>;
81
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.1",
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",