@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 +22 -3
- package/android/build.gradle +1 -0
- package/android/src/main/AndroidManifest.xml +13 -1
- package/android/src/main/java/technology/regantis/chat/RegantisChatNativeModule.java +109 -56
- package/android/src/main/res/xml/regantis_chat_file_paths.xml +4 -0
- package/index.d.ts +2 -1
- package/ios/RegantisChatNative.h +1 -1
- package/ios/RegantisChatNative.m +81 -31
- package/package.json +1 -1
- package/src/RegantisChat.js +191 -4
- package/src/client.js +3 -3
- package/src/defaults.js +1 -1
- package/src/native.js +5 -5
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
|
-
-
|
|
116
|
-
-
|
|
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
|
+
```
|
package/android/build.gradle
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
48
|
+
if (requestCode == PICK_ATTACHMENT_REQUEST && pickerPromise != null) {
|
|
49
|
+
Promise promise = pickerPromise;
|
|
50
|
+
pickerPromise = null;
|
|
44
51
|
|
|
45
|
-
|
|
46
|
-
|
|
52
|
+
if (resultCode != Activity.RESULT_OK || data == null || data.getData() == null) {
|
|
53
|
+
promise.resolve(null);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
47
56
|
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
}
|
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' | '
|
|
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>;
|
package/ios/RegantisChatNative.h
CHANGED
|
@@ -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
|
package/ios/RegantisChatNative.m
CHANGED
|
@@ -66,50 +66,35 @@ RCT_EXPORT_METHOD(playIncomingSound)
|
|
|
66
66
|
AudioServicesPlaySystemSound(1007);
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
RCT_REMAP_METHOD(
|
|
70
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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
package/src/RegantisChat.js
CHANGED
|
@@ -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={() =>
|
|
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
|
|
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 === '
|
|
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,
|
|
444
|
+
return this.uploadAttachment(file, type);
|
|
445
445
|
}
|
|
446
446
|
|
|
447
447
|
async setTranslationLanguage(languageCode) {
|
package/src/defaults.js
CHANGED
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
|
|
36
|
-
if (!native || !native.
|
|
37
|
-
throw new Error('
|
|
35
|
+
async function takePhoto() {
|
|
36
|
+
if (!native || !native.takePhoto) {
|
|
37
|
+
throw new Error('NATIVE_CAMERA_UNAVAILABLE');
|
|
38
38
|
}
|
|
39
|
-
return native.
|
|
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
|
-
|
|
54
|
+
takePhoto,
|
|
55
55
|
playIncomingSound,
|
|
56
56
|
};
|