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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,7 +34,7 @@ To use the built-in camera action, add a camera usage description to the host ap
34
34
  <string>Take photos to attach to support chat messages.</string>
35
35
  ```
36
36
 
37
- Android opens the system camera app and does not require an additional app permission for this SDK flow.
37
+ Android declares `CAMERA` in the SDK manifest and requests the permission only when the visitor chooses **Take photo**. If permission was denied permanently, the SDK offers a shortcut to the app settings.
38
38
 
39
39
  ## Basic usage
40
40
 
@@ -123,19 +123,20 @@ Server configuration remains the default. Per-app overrides are optional:
123
123
  - WebSocket realtime updates with HTTP polling fallback
124
124
  - typing state
125
125
  - customer-side translation selector
126
- - image gallery, file picker and direct camera capture
127
- - in-chat image lightbox with pinch zoom and pan
128
- - native incoming-message sound
126
+ - image gallery, file picker and direct camera capture with runtime permission handling
127
+ - in-chat image lightbox with pinch zoom, pan, reset and +/- zoom controls
128
+ - native incoming-message sound with persisted user preference
129
129
  - close/reopen conversation
130
130
  - rating and rating comment
131
131
  - transcript email flow
132
132
  - read state and unread callback
133
133
  - light/dark mode
134
- - basic/advanced color configuration
135
- - logo, agent photo toggle and white-label behavior
134
+ - basic/advanced color configuration including background opacity
135
+ - custom logo with fallback, agent photo toggle, profile-name display and white-label behavior
136
+ - mobile welcome screen with the same soft blurred-glow treatment as the web widget
136
137
  - CRM-managed localized UI texts
137
138
 
138
- The web-only launcher settings such as desktop/mobile corner placement and minimized bubble/bar mode are still returned in the settings object for configuration parity, but they do not change a full-page React Native component because there is no browser launcher to position.
139
+ The web-only launcher settings such as desktop/mobile corner placement, launcher visibility and minimized bubble/bar mode are still returned in the settings object for configuration parity, but they do not change a full-page React Native component because there is no browser launcher to position. `before_you_go_enabled` is also intentionally not used in native because that feature is a desktop mouse-exit prompt in the web loader.
139
140
 
140
141
  ## Headless client
141
142
 
@@ -12,6 +12,6 @@ Pod::Spec.new do |s|
12
12
  s.platforms = { :ios => "15.1" }
13
13
  s.source = { :git => "https://github.com/regantis/react-native-chat.git", :tag => "#{s.version}" }
14
14
  s.source_files = "ios/**/*.{h,m,mm}"
15
- s.frameworks = "AudioToolbox", "UniformTypeIdentifiers"
15
+ s.frameworks = "AudioToolbox", "AVFoundation", "UniformTypeIdentifiers"
16
16
  s.dependency "React-Core"
17
17
  end
@@ -1,4 +1,13 @@
1
1
  <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ <uses-permission android:name="android.permission.CAMERA" />
3
+ <uses-feature android:name="android.hardware.camera" android:required="false" />
4
+
5
+ <queries>
6
+ <intent>
7
+ <action android:name="android.media.action.IMAGE_CAPTURE" />
8
+ </intent>
9
+ </queries>
10
+
2
11
  <application>
3
12
  <provider
4
13
  android:name="androidx.core.content.FileProvider"
@@ -1,10 +1,12 @@
1
1
  package technology.regantis.chat;
2
2
 
3
+ import android.Manifest;
3
4
  import android.app.Activity;
4
5
  import android.content.ClipData;
5
6
  import android.content.Context;
6
7
  import android.content.Intent;
7
8
  import android.content.SharedPreferences;
9
+ import android.content.pm.PackageManager;
8
10
  import android.content.pm.ResolveInfo;
9
11
  import android.database.Cursor;
10
12
  import android.media.Ringtone;
@@ -16,6 +18,7 @@ import android.webkit.MimeTypeMap;
16
18
 
17
19
  import androidx.annotation.NonNull;
18
20
  import androidx.annotation.Nullable;
21
+ import androidx.core.content.ContextCompat;
19
22
  import androidx.core.content.FileProvider;
20
23
 
21
24
  import com.facebook.react.bridge.ActivityEventListener;
@@ -26,6 +29,8 @@ import com.facebook.react.bridge.ReactApplicationContext;
26
29
  import com.facebook.react.bridge.ReactContextBaseJavaModule;
27
30
  import com.facebook.react.bridge.ReactMethod;
28
31
  import com.facebook.react.bridge.WritableMap;
32
+ import com.facebook.react.modules.core.PermissionAwareActivity;
33
+ import com.facebook.react.modules.core.PermissionListener;
29
34
 
30
35
  import java.io.File;
31
36
  import java.util.List;
@@ -35,10 +40,12 @@ public class RegantisChatNativeModule extends ReactContextBaseJavaModule {
35
40
  private static final String STORAGE_NAME = "regantis_react_native_chat";
36
41
  private static final int PICK_ATTACHMENT_REQUEST = 17841;
37
42
  private static final int TAKE_PHOTO_REQUEST = 17842;
43
+ private static final int CAMERA_PERMISSION_REQUEST = 17843;
38
44
 
39
45
  private final ReactApplicationContext reactContext;
40
46
  private Promise pickerPromise;
41
47
  private Promise cameraPromise;
48
+ private Promise cameraPermissionPromise;
42
49
  private File cameraFile;
43
50
  private Uri cameraOutputUri;
44
51
 
@@ -178,18 +185,58 @@ public class RegantisChatNativeModule extends ReactContextBaseJavaModule {
178
185
  promise.reject("NO_ACTIVITY", "No active Android Activity");
179
186
  return;
180
187
  }
181
- if (cameraPromise != null || pickerPromise != null) {
188
+ if (cameraPromise != null || cameraPermissionPromise != null || pickerPromise != null) {
182
189
  promise.reject("PICKER_BUSY", "An attachment picker is already open");
183
190
  return;
184
191
  }
185
192
 
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");
193
+ if (ContextCompat.checkSelfPermission(reactContext, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
194
+ if (!(activity instanceof PermissionAwareActivity)) {
195
+ promise.reject("CAMERA_PERMISSION_UNAVAILABLE", "The current Activity cannot request camera permission");
190
196
  return;
191
197
  }
192
198
 
199
+ cameraPermissionPromise = promise;
200
+ PermissionAwareActivity permissionActivity = (PermissionAwareActivity) activity;
201
+ permissionActivity.requestPermissions(
202
+ new String[]{Manifest.permission.CAMERA},
203
+ CAMERA_PERMISSION_REQUEST,
204
+ new PermissionListener() {
205
+ @Override
206
+ public boolean onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
207
+ if (requestCode != CAMERA_PERMISSION_REQUEST) return false;
208
+
209
+ Promise pendingPromise = cameraPermissionPromise;
210
+ cameraPermissionPromise = null;
211
+ if (pendingPromise == null) return true;
212
+
213
+ boolean granted = grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED;
214
+ if (!granted) {
215
+ pendingPromise.reject("CAMERA_PERMISSION_DENIED", "Camera permission is required to take a photo");
216
+ return true;
217
+ }
218
+
219
+ Activity currentActivity = getCurrentActivity();
220
+ if (currentActivity == null) {
221
+ pendingPromise.reject("NO_ACTIVITY", "No active Android Activity");
222
+ return true;
223
+ }
224
+
225
+ launchCamera(currentActivity, pendingPromise);
226
+ return true;
227
+ }
228
+ }
229
+ );
230
+ return;
231
+ }
232
+
233
+ launchCamera(activity, promise);
234
+ }
235
+
236
+ private void launchCamera(Activity activity, Promise promise) {
237
+ try {
238
+ Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
239
+
193
240
  File directory = new File(reactContext.getCacheDir(), "regantis-chat/camera");
194
241
  if (!directory.exists() && !directory.mkdirs()) {
195
242
  promise.reject("CAMERA_FAILED", "Unable to create camera directory");
@@ -217,6 +264,12 @@ public class RegantisChatNativeModule extends ReactContextBaseJavaModule {
217
264
  cameraFile = file;
218
265
  cameraOutputUri = outputUri;
219
266
  activity.startActivityForResult(intent, TAKE_PHOTO_REQUEST);
267
+ } catch (android.content.ActivityNotFoundException error) {
268
+ cameraPromise = null;
269
+ if (cameraFile != null) cameraFile.delete();
270
+ cameraFile = null;
271
+ cameraOutputUri = null;
272
+ promise.reject("CAMERA_UNAVAILABLE", "No camera application is available", error);
220
273
  } catch (Exception error) {
221
274
  cameraPromise = null;
222
275
  if (cameraFile != null) cameraFile.delete();
@@ -233,7 +286,7 @@ public class RegantisChatNativeModule extends ReactContextBaseJavaModule {
233
286
  promise.reject("NO_ACTIVITY", "No active Android Activity");
234
287
  return;
235
288
  }
236
- if (pickerPromise != null || cameraPromise != null) {
289
+ if (pickerPromise != null || cameraPromise != null || cameraPermissionPromise != null) {
237
290
  promise.reject("PICKER_BUSY", "An attachment picker is already open");
238
291
  return;
239
292
  }
Binary file
Binary file
@@ -1,5 +1,6 @@
1
1
  #import "RegantisChatNative.h"
2
2
  #import <AudioToolbox/AudioToolbox.h>
3
+ #import <AVFoundation/AVFoundation.h>
3
4
  #import <React/RCTUtils.h>
4
5
  #import <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
5
6
 
@@ -66,35 +67,65 @@ RCT_EXPORT_METHOD(playIncomingSound)
66
67
  AudioServicesPlaySystemSound(1007);
67
68
  }
68
69
 
70
+ - (void)presentCameraWithResolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject
71
+ {
72
+ if (self.pickerResolve != nil) {
73
+ reject(@"PICKER_BUSY", @"An attachment picker is already open", nil);
74
+ return;
75
+ }
76
+
77
+ if (![UIImagePickerController isSourceTypeAvailable:UIImagePickerControllerSourceTypeCamera]) {
78
+ reject(@"CAMERA_UNAVAILABLE", @"Camera is not available on this device", nil);
79
+ return;
80
+ }
81
+
82
+ UIViewController *controller = RCTPresentedViewController();
83
+ if (controller == nil) {
84
+ reject(@"NO_VIEW_CONTROLLER", @"No active iOS view controller", nil);
85
+ return;
86
+ }
87
+
88
+ UIImagePickerController *picker = [[UIImagePickerController alloc] init];
89
+ picker.sourceType = UIImagePickerControllerSourceTypeCamera;
90
+ picker.delegate = self;
91
+ picker.allowsEditing = NO;
92
+
93
+ self.pickerResolve = resolve;
94
+ self.pickerReject = reject;
95
+ [controller presentViewController:picker animated:YES completion:nil];
96
+ }
97
+
69
98
  RCT_REMAP_METHOD(takePhoto,
70
99
  takePhotoWithResolver:(RCTPromiseResolveBlock)resolve
71
100
  rejecter:(RCTPromiseRejectBlock)reject)
72
101
  {
73
102
  dispatch_async(dispatch_get_main_queue(), ^{
74
- if (self.pickerResolve != nil) {
75
- reject(@"PICKER_BUSY", @"An attachment picker is already open", nil);
103
+ NSString *usageDescription = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"NSCameraUsageDescription"];
104
+ if (![usageDescription isKindOfClass:[NSString class]] || usageDescription.length == 0) {
105
+ reject(@"CAMERA_USAGE_DESCRIPTION_MISSING", @"NSCameraUsageDescription is required in the host app Info.plist", nil);
76
106
  return;
77
107
  }
78
108
 
79
- if (![UIImagePickerController isSourceTypeAvailable:UIImagePickerControllerSourceTypeCamera]) {
80
- reject(@"CAMERA_UNAVAILABLE", @"Camera is not available on this device", nil);
109
+ AVAuthorizationStatus status = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo];
110
+ if (status == AVAuthorizationStatusAuthorized) {
111
+ [self presentCameraWithResolver:resolve rejecter:reject];
81
112
  return;
82
113
  }
83
114
 
84
- UIViewController *controller = RCTPresentedViewController();
85
- if (controller == nil) {
86
- reject(@"NO_VIEW_CONTROLLER", @"No active iOS view controller", nil);
115
+ if (status == AVAuthorizationStatusDenied || status == AVAuthorizationStatusRestricted) {
116
+ reject(@"CAMERA_PERMISSION_DENIED", @"Camera permission is required to take a photo", nil);
87
117
  return;
88
118
  }
89
119
 
90
- UIImagePickerController *picker = [[UIImagePickerController alloc] init];
91
- picker.sourceType = UIImagePickerControllerSourceTypeCamera;
92
- picker.delegate = self;
93
- picker.allowsEditing = NO;
94
-
95
- self.pickerResolve = resolve;
96
- self.pickerReject = reject;
97
- [controller presentViewController:picker animated:YES completion:nil];
120
+ [AVCaptureDevice requestAccessForMediaType:AVMediaTypeVideo completionHandler:^(BOOL granted) {
121
+ dispatch_async(dispatch_get_main_queue(), ^{
122
+ if (!granted) {
123
+ reject(@"CAMERA_PERMISSION_DENIED", @"Camera permission is required to take a photo", nil);
124
+ return;
125
+ }
126
+ [self presentCameraWithResolver:resolve rejecter:reject];
127
+ });
128
+ }];
98
129
  });
99
130
  }
100
131
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@regantis-sdk/react-native-chat",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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",
@@ -11,6 +11,7 @@
11
11
  "src",
12
12
  "android",
13
13
  "ios",
14
+ "assets",
14
15
  "RegantisReactNativeChat.podspec",
15
16
  "README.md"
16
17
  ],
@@ -13,7 +13,9 @@ const {
13
13
  PanResponder,
14
14
  Platform,
15
15
  Pressable,
16
+ SafeAreaView,
16
17
  ScrollView,
18
+ StatusBar,
17
19
  StyleSheet,
18
20
  Text,
19
21
  TextInput,
@@ -21,16 +23,21 @@ const {
21
23
  } = require('react-native');
22
24
  const { RegantisChatClient } = require('./client');
23
25
 
26
+ const REGANTIS_LOGO_BLACK = require('../assets/regantis_logo_black.png');
27
+ const REGANTIS_LOGO_WHITE = require('../assets/regantis_logo_white.png');
28
+ const WELCOME_BACKGROUND_LIGHT = require('../assets/welcome_background_light.png');
29
+ const WELCOME_BACKGROUND_DARK = require('../assets/welcome_background_dark.png');
30
+
24
31
  const EMOJIS = ['🙂', '😁', '😂', '😊', '😍', '😐', '🤔', '😞', '😢', '😭', '🎉', '❤️', '👌', '👍', '👎', '🙏'];
25
32
 
26
33
 
27
34
  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' },
35
+ en: { attach_camera: 'Take photo', lightbox_close: 'Close', lightbox_reset: 'Reset zoom', lightbox_zoom_in: 'Zoom in', lightbox_zoom_out: 'Zoom out', back: 'Back', camera_permission_denied: 'Camera permission is required to take a photo.', camera_unavailable: 'Camera is not available on this device.', camera_setup_required: 'Camera access is not configured for this app.', open_settings: 'Open settings' },
36
+ ro: { attach_camera: 'Fă o fotografie', lightbox_close: 'Închide', lightbox_reset: 'Resetează zoom', lightbox_zoom_in: 'Mărește', lightbox_zoom_out: 'Micșorează', back: 'Înapoi', camera_permission_denied: 'Permisiunea pentru cameră este necesară pentru a face o fotografie.', camera_unavailable: 'Camera nu este disponibilă pe acest dispozitiv.', camera_setup_required: 'Accesul la cameră nu este configurat pentru această aplicație.', open_settings: 'Deschide setările' },
37
+ de: { attach_camera: 'Foto aufnehmen', lightbox_close: 'Schließen', lightbox_reset: 'Zoom zurücksetzen', lightbox_zoom_in: 'Vergrößern', lightbox_zoom_out: 'Verkleinern', back: 'Zurück', camera_permission_denied: 'Die Kameraberechtigung ist erforderlich, um ein Foto aufzunehmen.', camera_unavailable: 'Die Kamera ist auf diesem Gerät nicht verfügbar.', camera_setup_required: 'Der Kamerazugriff ist für diese App nicht konfiguriert.', open_settings: 'Einstellungen öffnen' },
38
+ fr: { attach_camera: 'Prendre une photo', lightbox_close: 'Fermer', lightbox_reset: 'Réinitialiser le zoom', lightbox_zoom_in: 'Agrandir', lightbox_zoom_out: 'Réduire', back: 'Retour', camera_permission_denied: 'L’autorisation de la caméra est nécessaire pour prendre une photo.', camera_unavailable: 'La caméra n’est pas disponible sur cet appareil.', camera_setup_required: 'L’accès à la caméra n’est pas configuré pour cette application.', open_settings: 'Ouvrir les réglages' },
39
+ es: { attach_camera: 'Tomar una foto', lightbox_close: 'Cerrar', lightbox_reset: 'Restablecer zoom', lightbox_zoom_in: 'Acercar', lightbox_zoom_out: 'Alejar', back: 'Atrás', camera_permission_denied: 'Se requiere permiso de cámara para tomar una foto.', camera_unavailable: 'La cámara no está disponible en este dispositivo.', camera_setup_required: 'El acceso a la cámara no está configurado para esta aplicación.', open_settings: 'Abrir ajustes' },
40
+ it: { attach_camera: 'Scatta una foto', lightbox_close: 'Chiudi', lightbox_reset: 'Reimposta zoom', lightbox_zoom_in: 'Ingrandisci', lightbox_zoom_out: 'Riduci', back: 'Indietro', camera_permission_denied: 'È necessaria l’autorizzazione della fotocamera per scattare una foto.', camera_unavailable: 'La fotocamera non è disponibile su questo dispositivo.', camera_setup_required: 'L’accesso alla fotocamera non è configurato per questa app.', open_settings: 'Apri impostazioni' },
34
41
  };
35
42
 
36
43
  function localText(language, key) {
@@ -45,29 +52,54 @@ function touchDistance(touches) {
45
52
  return Math.sqrt(dx * dx + dy * dy);
46
53
  }
47
54
 
48
- function ImageLightbox({ visible, uri, onClose, closeLabel, resetLabel }) {
55
+ function ImageLightbox({ visible, uri, onClose, closeLabel, resetLabel, zoomInLabel, zoomOutLabel }) {
49
56
  const scale = React.useRef(new Animated.Value(1)).current;
50
57
  const translateX = React.useRef(new Animated.Value(0)).current;
51
58
  const translateY = React.useRef(new Animated.Value(0)).current;
52
59
  const current = React.useRef({ scale: 1, x: 0, y: 0 });
53
60
  const gesture = React.useRef({ mode: '', distance: 0, scale: 1, x: 0, y: 0, startX: 0, startY: 0 });
54
61
 
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
+ const applyZoom = React.useCallback((value, animated = true) => {
63
+ const next = Math.max(1, Math.min(5, Number(value) || 1));
64
+ current.current.scale = next;
65
+
66
+ if (next <= 1.01) {
67
+ current.current.x = 0;
68
+ current.current.y = 0;
69
+ }
70
+
71
+ if (!animated) {
72
+ scale.setValue(next);
73
+ if (next <= 1.01) {
74
+ translateX.setValue(0);
75
+ translateY.setValue(0);
76
+ }
77
+ return;
78
+ }
79
+
80
+ const animations = [
81
+ Animated.spring(scale, { toValue: next, useNativeDriver: true, friction: 8, tension: 70 }),
62
82
  ];
63
- if (animated) Animated.parallel(values).start();
64
- else {
65
- scale.setValue(1);
66
- translateX.setValue(0);
67
- translateY.setValue(0);
83
+
84
+ if (next <= 1.01) {
85
+ animations.push(
86
+ Animated.spring(translateX, { toValue: 0, useNativeDriver: true, friction: 8, tension: 70 }),
87
+ Animated.spring(translateY, { toValue: 0, useNativeDriver: true, friction: 8, tension: 70 })
88
+ );
68
89
  }
90
+
91
+ Animated.parallel(animations).start();
69
92
  }, [scale, translateX, translateY]);
70
93
 
94
+ const resetZoom = React.useCallback((animated = true) => {
95
+ gesture.current.mode = '';
96
+ applyZoom(1, animated);
97
+ }, [applyZoom]);
98
+
99
+ const changeZoom = React.useCallback(delta => {
100
+ applyZoom(current.current.scale + delta, true);
101
+ }, [applyZoom]);
102
+
71
103
  React.useEffect(() => {
72
104
  if (visible) resetZoom(false);
73
105
  }, [visible, uri, resetZoom]);
@@ -151,14 +183,30 @@ function ImageLightbox({ visible, uri, onClose, closeLabel, resetLabel }) {
151
183
  return (
152
184
  <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose} statusBarTranslucent>
153
185
  <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>
186
+ <SafeAreaView
187
+ pointerEvents="box-none"
188
+ style={[
189
+ lightboxStyles.safeArea,
190
+ Platform.OS === 'android' && { paddingTop: Math.max(10, Number(StatusBar.currentHeight || 24)) },
191
+ ]}
192
+ >
193
+ <View style={lightboxStyles.toolbar}>
194
+ <Pressable accessibilityRole="button" accessibilityLabel={closeLabel} style={lightboxStyles.toolbarButton} onPress={onClose}>
195
+ <Text style={lightboxStyles.closeIcon}>‹</Text>
196
+ </Pressable>
197
+ <View style={lightboxStyles.zoomControls}>
198
+ <Pressable accessibilityRole="button" accessibilityLabel={zoomOutLabel} style={lightboxStyles.zoomButton} onPress={() => changeZoom(-0.5)}>
199
+ <Text style={lightboxStyles.zoomText}>−</Text>
200
+ </Pressable>
201
+ <Pressable accessibilityRole="button" accessibilityLabel={resetLabel} style={lightboxStyles.resetButton} onPress={() => resetZoom(true)}>
202
+ <Text style={lightboxStyles.resetText}>1:1</Text>
203
+ </Pressable>
204
+ <Pressable accessibilityRole="button" accessibilityLabel={zoomInLabel} style={lightboxStyles.zoomButton} onPress={() => changeZoom(0.5)}>
205
+ <Text style={lightboxStyles.zoomText}>+</Text>
206
+ </Pressable>
207
+ </View>
208
+ </View>
209
+ </SafeAreaView>
162
210
  <View style={lightboxStyles.stage} {...panResponder.panHandlers}>
163
211
  {uri ? (
164
212
  <Animated.Image
@@ -178,10 +226,14 @@ function ImageLightbox({ visible, uri, onClose, closeLabel, resetLabel }) {
178
226
 
179
227
  const lightboxStyles = StyleSheet.create({
180
228
  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' },
229
+ safeArea: { position: 'absolute', zIndex: 20, top: 0, left: 0, right: 0 },
230
+ toolbar: { minHeight: 64, paddingHorizontal: 14, paddingTop: 14, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
182
231
  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' },
232
+ closeIcon: { color: '#FFFFFF', fontSize: 39, lineHeight: 40, transform: [{ translateY: -2 }] },
233
+ zoomControls: { flexDirection: 'row', alignItems: 'center', gap: 8 },
234
+ zoomButton: { width: 44, height: 44, borderRadius: 22, backgroundColor: 'rgba(255,255,255,0.14)', alignItems: 'center', justifyContent: 'center' },
235
+ zoomText: { color: '#FFFFFF', fontSize: 27, lineHeight: 30, fontWeight: '500' },
236
+ resetButton: { minWidth: 52, height: 44, paddingHorizontal: 12, borderRadius: 22, backgroundColor: 'rgba(255,255,255,0.14)', alignItems: 'center', justifyContent: 'center' },
185
237
  resetText: { color: '#FFFFFF', fontSize: 13, fontWeight: '700' },
186
238
  stage: { flex: 1, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' },
187
239
  image: { width: '100%', height: '100%' },
@@ -199,26 +251,26 @@ function colorWithOpacity(hex, opacity) {
199
251
 
200
252
  function makePalette(settings) {
201
253
  const dark = settings.theme_mode === 'dark';
202
- const primary = settings.color_mode === 'advanced' ? settings.primary_color : settings.theme_color;
254
+ const advanced = settings.color_mode === 'advanced';
255
+ const primary = advanced ? settings.primary_color : settings.theme_color;
256
+ const backgroundHex = advanced ? settings.chat_background : (dark ? '#111316' : '#F5F7FB');
257
+ const backgroundOpacity = advanced ? settings.chat_background_opacity : 100;
203
258
  return {
204
259
  dark,
205
260
  primary,
206
- background: colorWithOpacity(settings.chat_background, settings.chat_background_opacity),
261
+ background: colorWithOpacity(backgroundHex, backgroundOpacity),
207
262
  surface: dark ? '#1D2127' : '#FFFFFF',
208
263
  surfaceAlt: dark ? '#252A31' : '#F5F7FB',
209
264
  header: dark ? '#181B20' : '#F8F9FC',
210
265
  border: dark ? 'rgba(255,255,255,0.10)' : 'rgba(20,32,54,0.10)',
211
266
  text: dark ? '#F7F8FA' : '#17181B',
212
- muted: settings.system_text || (dark ? '#A7ADB7' : '#6F7480'),
213
- customerBubble: settings.color_mode === 'advanced' ? settings.customer_bubble : primary,
214
- customerText: settings.color_mode === 'advanced' ? settings.customer_text : '#FFFFFF',
215
- agentBubble: settings.color_mode === 'advanced' ? settings.agent_bubble : (dark ? '#22252A' : '#FFFFFF'),
216
- agentText: settings.color_mode === 'advanced' ? settings.agent_text : (dark ? '#F7F8FA' : '#17181B'),
267
+ muted: advanced ? settings.system_text : (dark ? '#AEB4BE' : '#6F7480'),
268
+ customerBubble: advanced ? settings.customer_bubble : primary,
269
+ customerText: advanced ? settings.customer_text : '#FFFFFF',
270
+ agentBubble: advanced ? settings.agent_bubble : (dark ? '#22262C' : '#FFFFFF'),
271
+ agentText: advanced ? settings.agent_text : (dark ? '#F5F6F7' : '#17181B'),
217
272
  danger: '#DF2917',
218
- heroBase: dark ? '#151B21' : '#EEF7FC',
219
- heroBlobA: dark ? 'rgba(73,83,152,0.25)' : 'rgba(118,137,255,0.30)',
220
- heroBlobB: dark ? 'rgba(34,126,168,0.22)' : 'rgba(96,222,215,0.28)',
221
- heroBlobC: dark ? 'rgba(255,255,255,0.035)' : 'rgba(255,255,255,0.52)',
273
+ heroBase: dark ? '#151B21' : '#EEF5FC',
222
274
  };
223
275
  }
224
276
 
@@ -234,6 +286,140 @@ function isSupportMessage(message) {
234
286
  return sender === 'agent' || sender === 'admin' || sender === 'chatbot';
235
287
  }
236
288
 
289
+
290
+ function humanSize(bytes) {
291
+ const size = Number(bytes || 0);
292
+ if (!size) return '0 B';
293
+ const units = ['B', 'KB', 'MB', 'GB'];
294
+ const index = Math.min(units.length - 1, Math.max(0, Math.floor(Math.log(size) / Math.log(1024))));
295
+ const value = size / Math.pow(1024, index);
296
+ return `${value.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
297
+ }
298
+
299
+ function normalizeMarkdownSource(source) {
300
+ return String(source == null ? '' : source)
301
+ .replace(/\\([*_`#>\[\]()~+-])/g, '$1')
302
+ .replace(/\*{3,}([^*\n]+?)\*{3,}/g, '**$1**')
303
+ .replace(/^(\s*[-+*]\s+)\*([^*\n].*?)\*\*\s*$/gm, '$1**$2**');
304
+ }
305
+
306
+ function renderInlineMarkdown(value, textStyle, styles, keyPrefix) {
307
+ const text = String(value || '');
308
+ const pattern = /(\*\*[^*\n]+\*\*|__[^_\n]+__|`[^`\n]+`|\[[^\]\n]+\]\((?:https?:\/\/|mailto:|tel:)[^)\s]+\)|\*[^*\n]+\*|_[^_\n]+_)/g;
309
+ const nodes = [];
310
+ let last = 0;
311
+ let match;
312
+ let index = 0;
313
+
314
+ while ((match = pattern.exec(text)) !== null) {
315
+ if (match.index > last) nodes.push(text.slice(last, match.index));
316
+ const token = match[0];
317
+ const key = `${keyPrefix}-${index++}`;
318
+
319
+ if ((token.startsWith('**') && token.endsWith('**')) || (token.startsWith('__') && token.endsWith('__'))) {
320
+ nodes.push(<Text key={key} style={[textStyle, styles.markdownStrong]}>{token.slice(2, -2)}</Text>);
321
+ } else if (token.startsWith('`')) {
322
+ nodes.push(<Text key={key} style={[textStyle, styles.markdownInlineCode]}>{token.slice(1, -1)}</Text>);
323
+ } else if (token.startsWith('[')) {
324
+ const linkMatch = token.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
325
+ if (linkMatch) {
326
+ const url = linkMatch[2];
327
+ nodes.push(
328
+ <Text key={key} style={[textStyle, styles.markdownLink]} onPress={() => Linking.openURL(url).catch(() => {})}>
329
+ {linkMatch[1]}
330
+ </Text>
331
+ );
332
+ } else {
333
+ nodes.push(token);
334
+ }
335
+ } else {
336
+ nodes.push(<Text key={key} style={[textStyle, styles.markdownEm]}>{token.slice(1, -1)}</Text>);
337
+ }
338
+ last = pattern.lastIndex;
339
+ }
340
+
341
+ if (last < text.length) nodes.push(text.slice(last));
342
+ return nodes;
343
+ }
344
+
345
+ function SafeMarkdown({ source, textStyle, styles }) {
346
+ const lines = normalizeMarkdownSource(source).replace(/\r\n?/g, '\n').split('\n');
347
+ const blocks = [];
348
+ let codeLines = [];
349
+ let inCode = false;
350
+
351
+ const flushCode = key => {
352
+ if (!codeLines.length) return;
353
+ blocks.push(
354
+ <View key={key} style={styles.markdownCodeBlock}>
355
+ <Text style={[textStyle, styles.markdownCodeText]}>{codeLines.join('\n')}</Text>
356
+ </View>
357
+ );
358
+ codeLines = [];
359
+ };
360
+
361
+ lines.forEach((line, index) => {
362
+ if (/^```/.test(line.trim())) {
363
+ if (inCode) flushCode(`code-${index}`);
364
+ inCode = !inCode;
365
+ return;
366
+ }
367
+
368
+ if (inCode) {
369
+ codeLines.push(line);
370
+ return;
371
+ }
372
+
373
+ if (!line.trim()) {
374
+ blocks.push(<View key={`space-${index}`} style={styles.markdownSpacer} />);
375
+ return;
376
+ }
377
+
378
+ const heading = line.match(/^\s*(#{1,6})\s+(.+)$/);
379
+ if (heading) {
380
+ blocks.push(
381
+ <Text key={`h-${index}`} style={[textStyle, styles.markdownHeading]}>
382
+ {renderInlineMarkdown(heading[2], textStyle, styles, `h-${index}`)}
383
+ </Text>
384
+ );
385
+ return;
386
+ }
387
+
388
+ const quote = line.match(/^\s*>\s?(.*)$/);
389
+ if (quote) {
390
+ blocks.push(
391
+ <View key={`q-${index}`} style={styles.markdownQuote}>
392
+ <Text style={textStyle}>{renderInlineMarkdown(quote[1], textStyle, styles, `q-${index}`)}</Text>
393
+ </View>
394
+ );
395
+ return;
396
+ }
397
+
398
+ const unordered = line.match(/^\s*[-+*]\s+(.+)$/);
399
+ const ordered = line.match(/^\s*(\d+)[.)]\s+(.+)$/);
400
+ if (unordered || ordered) {
401
+ const prefix = ordered ? `${ordered[1]}.` : '•';
402
+ const content = ordered ? ordered[2] : unordered[1];
403
+ blocks.push(
404
+ <View key={`li-${index}`} style={styles.markdownListRow}>
405
+ <Text style={[textStyle, styles.markdownListPrefix]}>{prefix}</Text>
406
+ <Text style={[textStyle, styles.markdownListText]}>{renderInlineMarkdown(content, textStyle, styles, `li-${index}`)}</Text>
407
+ </View>
408
+ );
409
+ return;
410
+ }
411
+
412
+ blocks.push(
413
+ <Text key={`p-${index}`} style={[textStyle, styles.markdownParagraph]}>
414
+ {renderInlineMarkdown(line, textStyle, styles, `p-${index}`)}
415
+ </Text>
416
+ );
417
+ });
418
+
419
+ if (codeLines.length) flushCode('code-last');
420
+ return <View style={styles.markdownRoot}>{blocks}</View>;
421
+ }
422
+
237
423
  function RegantisChat(props) {
238
424
  const {
239
425
  apiKey,
@@ -286,13 +472,18 @@ function RegantisChat(props) {
286
472
  const [ratingComment, setRatingComment] = React.useState('');
287
473
  const [ratingStatus, setRatingStatus] = React.useState('');
288
474
  const [busyAction, setBusyAction] = React.useState('');
475
+ const [attachmentError, setAttachmentError] = React.useState('');
476
+ const [cameraSettingsNeeded, setCameraSettingsNeeded] = React.useState(false);
289
477
  const [lightboxUri, setLightboxUri] = React.useState('');
478
+ const [logoFailed, setLogoFailed] = React.useState(false);
290
479
  const listRef = React.useRef(null);
480
+ const previousStatusRef = React.useRef(null);
481
+ const activeViewRef = React.useRef(initialView === 'chat' ? 'chat' : 'home');
291
482
 
292
483
  React.useEffect(() => {
293
484
  const unsubscribe = client.subscribe(next => setState(next));
294
485
  client.start().catch(() => {});
295
- const sub = AppState.addEventListener('change', value => client.setVisible(value === 'active'));
486
+ const sub = AppState.addEventListener('change', value => client.setVisible(value === 'active' && activeViewRef.current === 'chat'));
296
487
  return () => {
297
488
  if (sub && sub.remove) sub.remove();
298
489
  unsubscribe();
@@ -300,6 +491,15 @@ function RegantisChat(props) {
300
491
  };
301
492
  }, [client]);
302
493
 
494
+ React.useEffect(() => {
495
+ activeViewRef.current = activeView;
496
+ client.setVisible(AppState.currentState === 'active' && activeView === 'chat');
497
+ }, [activeView, client]);
498
+
499
+ React.useEffect(() => {
500
+ setLogoFailed(false);
501
+ }, [state.settings.logo_url]);
502
+
303
503
  React.useEffect(() => {
304
504
  const c = state.conversation || {};
305
505
  if (c.contact_name && !contactName) setContactName(c.contact_name);
@@ -341,9 +541,36 @@ function RegantisChat(props) {
341
541
  const homePreview = lastSupport
342
542
  ? String(lastSupport.message || '').trim() || (lastSupport.attachment ? `[${String(lastSupport.message_type || 'file')}]` : t('empty'))
343
543
  : t('empty');
544
+ const latestNamedAgent = Number(state.settings.use_profile_names || 0) === 1
545
+ ? [...state.messages].reverse().find(message => {
546
+ const sender = String(message && message.sender_type || '').toLowerCase();
547
+ return (sender === 'agent' || sender === 'admin') && String(message && message.sender_name || '').trim();
548
+ })
549
+ : null;
550
+ const operatorDisplayName = latestNamedAgent
551
+ ? String(latestNamedAgent.sender_name || '').trim()
552
+ : (state.settings.operator_name || t('support_name'));
553
+
554
+ React.useEffect(() => {
555
+ const nextStatus = Number(c.status || 0);
556
+ const previousStatus = previousStatusRef.current;
557
+ previousStatusRef.current = nextStatus;
558
+
559
+ if (
560
+ previousStatus === 1 &&
561
+ nextStatus === 0 &&
562
+ Number(state.settings.rating_enabled || 0) === 1 &&
563
+ contactConfirmed &&
564
+ !c.feedback_submitted
565
+ ) {
566
+ const timer = setTimeout(() => setRatingOpen(true), 120);
567
+ return () => clearTimeout(timer);
568
+ }
569
+ }, [c.status, c.feedback_submitted, contactConfirmed, state.settings.rating_enabled]);
344
570
 
345
571
  function showView(next) {
346
572
  const view = next === 'chat' ? 'chat' : 'home';
573
+ activeViewRef.current = view;
347
574
  setActiveView(view);
348
575
  setOptionsOpen(false);
349
576
  setLanguageOpen(false);
@@ -382,10 +609,23 @@ function RegantisChat(props) {
382
609
 
383
610
  async function pick(kind) {
384
611
  setAttachOpen(false);
612
+ setAttachmentError('');
613
+ setCameraSettingsNeeded(false);
385
614
  setBusyAction('upload');
386
615
  try {
387
616
  await client.pickAndUpload(kind);
388
- } catch (_) {
617
+ setAttachmentError('');
618
+ setCameraSettingsNeeded(false);
619
+ } catch (error) {
620
+ const code = String(error && (error.code || error.message) || '');
621
+ if (code.includes('CAMERA_PERMISSION_DENIED')) {
622
+ setAttachmentError(t('camera_permission_denied'));
623
+ setCameraSettingsNeeded(true);
624
+ } else if (code.includes('CAMERA_USAGE_DESCRIPTION_MISSING') || code.includes('CAMERA_PERMISSION_UNAVAILABLE')) {
625
+ setAttachmentError(t('camera_setup_required'));
626
+ } else if (code.includes('CAMERA_UNAVAILABLE')) setAttachmentError(t('camera_unavailable'));
627
+ else setAttachmentError(error && error.message ? error.message : t('send_error'));
628
+ if (typeof onError === 'function') onError(error);
389
629
  } finally {
390
630
  setBusyAction('');
391
631
  }
@@ -463,8 +703,8 @@ function RegantisChat(props) {
463
703
 
464
704
  function renderLogo(size, marginRight) {
465
705
  if (!state.settings.show_logo) return null;
466
- if (state.settings.logo_url) {
467
- return <Image source={{ uri: state.settings.logo_url }} style={[styles.logoImage, { width: size, height: size, borderRadius: size / 2, marginRight: marginRight || 0 }]} resizeMode="cover" />;
706
+ if (state.settings.logo_url && !logoFailed) {
707
+ return <Image source={{ uri: state.settings.logo_url }} onError={() => setLogoFailed(true)} style={[styles.logoImage, { width: size, height: size, borderRadius: size / 2, marginRight: marginRight || 0 }]} resizeMode="cover" />;
468
708
  }
469
709
  return (
470
710
  <View style={[styles.logoFallback, { width: size, height: size, borderRadius: size / 2, marginRight: marginRight || 0 }]}>
@@ -498,7 +738,11 @@ function RegantisChat(props) {
498
738
  const textStyle = visitor ? styles.customerText : styles.agentText;
499
739
  const attachment = item.attachment || null;
500
740
  const isImage = attachment && String(attachment.mime || '').startsWith('image/');
501
- const senderName = sender === 'chatbot' ? 'Chatbot' : (item.sender_name || state.settings.operator_name || t('support_name'));
741
+ const configuredName = state.settings.operator_name || t('support_name');
742
+ const senderName = sender === 'chatbot'
743
+ ? 'Chatbot'
744
+ : (Number(state.settings.use_profile_names || 0) === 1 ? (item.sender_name || configuredName) : configuredName);
745
+ const fileExt = String(attachment && attachment.ext || '').replace(/^\./, '').slice(0, 4).toUpperCase() || 'FILE';
502
746
 
503
747
  return (
504
748
  <View style={[styles.messageRow, visitor ? styles.messageRowRight : styles.messageRowLeft]}>
@@ -508,11 +752,21 @@ function RegantisChat(props) {
508
752
  <Image source={{ uri: attachment.url }} style={styles.attachmentImage} resizeMode="cover" />
509
753
  </Pressable>
510
754
  ) : attachment ? (
511
- <Pressable style={styles.fileAttachment} onPress={() => Linking.openURL(attachment.url)}>
512
- <Text style={[styles.fileAttachmentText, textStyle]}>📎 {attachment.name || t('attach_file')}</Text>
755
+ <Pressable style={styles.fileAttachment} onPress={() => Linking.openURL(attachment.url).catch(() => {})}>
756
+ <View style={[styles.fileAttachmentIcon, { borderColor: textStyle.color }]}>
757
+ <Text style={[styles.fileAttachmentIconText, textStyle]}>{fileExt}</Text>
758
+ </View>
759
+ <View style={styles.fileAttachmentCopy}>
760
+ <Text numberOfLines={1} style={[styles.fileAttachmentText, textStyle]}>{attachment.name || t('attach_file')}</Text>
761
+ <Text numberOfLines={1} style={[styles.fileAttachmentMeta, textStyle]}>{humanSize(attachment.size)}{attachment.ext ? ` · ${String(attachment.ext).toUpperCase()}` : ''}</Text>
762
+ </View>
513
763
  </Pressable>
514
764
  ) : null}
515
- {item.message ? <Text style={[styles.messageText, textStyle]}>{item.message}</Text> : null}
765
+ {item.message ? (
766
+ sender === 'chatbot'
767
+ ? <SafeMarkdown source={item.message} textStyle={[styles.messageText, textStyle]} styles={styles} />
768
+ : <Text style={[styles.messageText, textStyle]}>{item.message}</Text>
769
+ ) : null}
516
770
  <View style={styles.messageMeta}>
517
771
  {!visitor ? <Text numberOfLines={1} style={[styles.messageSender, textStyle]}>{senderName}</Text> : <View />}
518
772
  <Text style={[styles.timeText, textStyle]}>{formatTime(item.date_added)}</Text>
@@ -524,16 +778,24 @@ function RegantisChat(props) {
524
778
 
525
779
  function renderPoweredBy() {
526
780
  if (state.settings.white_label) return null;
527
- return <View style={styles.poweredBy}><Text style={styles.poweredByText}>{t('powered_by')} <Text style={styles.poweredByBrand}>Regantis</Text></Text></View>;
781
+ return (
782
+ <View style={styles.poweredBy}>
783
+ <Text style={styles.poweredByText}>{t('powered_by')}</Text>
784
+ <Image source={palette.dark ? REGANTIS_LOGO_WHITE : REGANTIS_LOGO_BLACK} style={styles.poweredByLogo} resizeMode="contain" />
785
+ </View>
786
+ );
528
787
  }
529
788
 
530
789
  function renderHome() {
531
790
  return (
532
791
  <View style={styles.screen}>
533
792
  <View style={styles.homeHero}>
534
- <View pointerEvents="none" style={styles.heroDecorationA} />
535
- <View pointerEvents="none" style={styles.heroDecorationB} />
536
- <View pointerEvents="none" style={styles.heroDecorationC} />
793
+ <Image
794
+ pointerEvents="none"
795
+ source={palette.dark ? WELCOME_BACKGROUND_DARK : WELCOME_BACKGROUND_LIGHT}
796
+ style={styles.homeHeroBackground}
797
+ resizeMode="cover"
798
+ />
537
799
 
538
800
  <View style={styles.homeTop}>
539
801
  <View style={styles.homeTopLeft}>
@@ -560,7 +822,7 @@ function RegantisChat(props) {
560
822
  <View style={styles.homeCardTop}>
561
823
  {state.settings.show_agent_photo ? <View style={styles.homeAvatar}><Text style={styles.homeAvatarText}>R</Text></View> : null}
562
824
  <View style={styles.homeCardCopy}>
563
- <Text numberOfLines={1} style={styles.homeCardAgent}>{state.settings.operator_name || t('support_name')}</Text>
825
+ <Text numberOfLines={1} style={styles.homeCardAgent}>{operatorDisplayName}</Text>
564
826
  <Text numberOfLines={1} style={styles.homeCardPreview}>{homePreview}</Text>
565
827
  </View>
566
828
  </View>
@@ -595,20 +857,22 @@ function RegantisChat(props) {
595
857
  <Text style={styles.backIcon}>‹</Text>
596
858
  </Pressable>
597
859
  <Pressable accessibilityRole="button" accessibilityLabel={t('options')} style={styles.headerIconButton} onPress={() => setOptionsOpen(value => !value)}>
598
- <Text style={styles.menuIcon}>•••</Text>
860
+ <View style={styles.menuDots}><View style={styles.menuDot} /><View style={styles.menuDot} /><View style={styles.menuDot} /></View>
599
861
  </Pressable>
600
862
  </View>
601
863
 
602
- <Pressable
603
- accessibilityRole="button"
604
- accessibilityLabel={state.settings.operator_name || t('support_name')}
605
- style={styles.agentTrigger}
606
- onPress={() => {
607
- if (state.settings.rating_enabled && contactConfirmed) setRatingOpen(true);
608
- }}
609
- >
610
- {renderAgentVisual()}
611
- </Pressable>
864
+ <View pointerEvents="box-none" style={styles.chatHeaderCenter}>
865
+ <Pressable
866
+ accessibilityRole="button"
867
+ accessibilityLabel={operatorDisplayName}
868
+ style={styles.agentTrigger}
869
+ onPress={() => {
870
+ if (state.settings.rating_enabled && contactConfirmed) setRatingOpen(true);
871
+ }}
872
+ >
873
+ {renderAgentVisual()}
874
+ </Pressable>
875
+ </View>
612
876
 
613
877
  <View style={styles.chatHeaderRight}>
614
878
  {typeof onClose === 'function' ? (
@@ -640,7 +904,7 @@ function RegantisChat(props) {
640
904
  <Text style={styles.popoverChevron}>›</Text>
641
905
  </Pressable>
642
906
  ) : null}
643
- {state.settings.sound_enabled !== undefined ? (
907
+ {state.soundAvailable ? (
644
908
  <Pressable style={styles.popoverRow} onPress={() => client.setSoundEnabled(!state.settings.sound_enabled)}>
645
909
  <Text style={styles.popoverIcon}>◖</Text>
646
910
  <Text style={styles.popoverText}>{t('sounds')}</Text>
@@ -674,6 +938,20 @@ function RegantisChat(props) {
674
938
  );
675
939
  }
676
940
 
941
+ function renderContactInfoCard() {
942
+ if (!contactConfirmed || (!c.contact_name && !c.contact_email)) return null;
943
+ return (
944
+ <View style={styles.contactInfoRow}>
945
+ <View style={styles.contactInfoAvatar}><Text style={styles.contactInfoAvatarText}>i</Text></View>
946
+ <View style={styles.contactInfoBubble}>
947
+ <Text style={styles.contactInfoTitle}>{t('personal_details')}</Text>
948
+ {c.contact_name ? <Text style={styles.contactInfoLine}><Text style={styles.contactInfoLabel}>{t('name')}: </Text>{c.contact_name}</Text> : null}
949
+ {c.contact_email ? <Text style={styles.contactInfoLine}><Text style={styles.contactInfoLabel}>{t('email')}: </Text>{c.contact_email}</Text> : null}
950
+ </View>
951
+ </View>
952
+ );
953
+ }
954
+
677
955
  function renderComposer() {
678
956
  if (!active) {
679
957
  return (
@@ -710,6 +988,17 @@ function RegantisChat(props) {
710
988
  </View>
711
989
  ) : null}
712
990
 
991
+ {attachmentError ? (
992
+ <View style={styles.composerErrorRow}>
993
+ <Text style={styles.composerError}>{attachmentError}</Text>
994
+ {cameraSettingsNeeded ? (
995
+ <Pressable style={styles.composerErrorAction} onPress={() => Linking.openSettings().catch(() => {})}>
996
+ <Text style={styles.composerErrorActionText}>{t('open_settings')}</Text>
997
+ </Pressable>
998
+ ) : null}
999
+ </View>
1000
+ ) : null}
1001
+
713
1002
  <View style={styles.composer}>
714
1003
  <Pressable disabled={!canUpload || state.uploading} style={[styles.composerButton, (!canUpload || state.uploading) && styles.disabled]} onPress={() => { setEmojiOpen(false); setAttachOpen(value => !value); }}>
715
1004
  <Text style={styles.composerPlus}>+</Text>
@@ -749,7 +1038,12 @@ function RegantisChat(props) {
749
1038
  style={styles.messageList}
750
1039
  contentContainerStyle={styles.messageListContent}
751
1040
  onScrollBeginDrag={() => state.hasMore && client.loadOlder().catch(() => {})}
752
- ListHeaderComponent={state.hasMore ? <View style={styles.historyLoader}><ActivityIndicator size="small" color={palette.primary} /></View> : null}
1041
+ ListHeaderComponent={(
1042
+ <>
1043
+ {state.hasMore ? <View style={styles.historyLoader}><ActivityIndicator size="small" color={palette.primary} /></View> : null}
1044
+ {renderContactInfoCard()}
1045
+ </>
1046
+ )}
753
1047
  ListEmptyComponent={<View style={styles.emptyState}><Text style={styles.emptyText}>{t('empty')}</Text></View>}
754
1048
  />
755
1049
  {state.remoteTyping ? <View style={styles.typingRow}><Text style={styles.typingText}>{state.remoteTyping === 'chatbot' ? t('chatbot_typing') : t('typing')}</Text></View> : null}
@@ -825,7 +1119,7 @@ function RegantisChat(props) {
825
1119
  <Modal transparent visible={ratingOpen} animationType="fade" onRequestClose={() => setRatingOpen(false)}>
826
1120
  <View style={styles.modalBackdropTop}>
827
1121
  <View style={styles.modalCard}>
828
- <Text style={styles.modalTitle}>{state.settings.operator_name || t('support_name')}</Text>
1122
+ <Text style={styles.modalTitle}>{operatorDisplayName}</Text>
829
1123
  <Text style={styles.ratingLabel}>{t('rating_agent_label')}</Text>
830
1124
  <View style={styles.ratingButtons}>
831
1125
  <Pressable style={styles.ratingButton} onPress={() => rate(1)}><Text style={styles.ratingIcon}>👍</Text></Pressable>
@@ -847,6 +1141,8 @@ function RegantisChat(props) {
847
1141
  onClose={() => setLightboxUri('')}
848
1142
  closeLabel={t('lightbox_close')}
849
1143
  resetLabel={t('lightbox_reset')}
1144
+ zoomInLabel={t('lightbox_zoom_in')}
1145
+ zoomOutLabel={t('lightbox_zoom_out')}
850
1146
  />
851
1147
  </KeyboardAvoidingView>
852
1148
  );
@@ -869,9 +1165,7 @@ function buildStyles(p) {
869
1165
  errorText: { color: p.text, fontSize: 14, textAlign: 'center', marginBottom: 12 },
870
1166
 
871
1167
  homeHero: { position: 'relative', overflow: 'hidden', paddingHorizontal: 18, paddingTop: 24, paddingBottom: 28, backgroundColor: p.heroBase },
872
- heroDecorationA: { position: 'absolute', width: 240, height: 240, borderRadius: 120, top: -138, left: -74, backgroundColor: p.heroBlobA },
873
- heroDecorationB: { position: 'absolute', width: 230, height: 230, borderRadius: 115, top: 80, right: -92, backgroundColor: p.heroBlobB },
874
- heroDecorationC: { position: 'absolute', width: 210, height: 210, borderRadius: 105, right: -82, bottom: -120, backgroundColor: p.heroBlobC },
1168
+ homeHeroBackground: { ...StyleSheet.absoluteFillObject, width: '100%', height: '100%' },
875
1169
  homeTop: { minHeight: 42, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', zIndex: 2 },
876
1170
  homeTopLeft: { flexDirection: 'row', alignItems: 'center', gap: 9 },
877
1171
  homeBackIcon: { color: p.text, fontSize: 37, lineHeight: 38, marginTop: -5 },
@@ -901,14 +1195,16 @@ function buildStyles(p) {
901
1195
  bottomNavText: { marginTop: 2, color: p.muted, fontSize: 10, lineHeight: 12 },
902
1196
  bottomNavTextActive: { color: p.text },
903
1197
 
904
- chatHeader: { position: 'relative', zIndex: 20, minHeight: 64, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 12, borderBottomWidth: 1, borderBottomColor: p.border, backgroundColor: p.header, overflow: 'visible' },
905
- chatHeaderLeft: { position: 'absolute', zIndex: 30, left: 12, top: 16, flexDirection: 'row', alignItems: 'center', gap: 5 },
906
- chatHeaderRight: { position: 'absolute', zIndex: 30, right: 12, top: 16, flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-end', gap: 5 },
1198
+ chatHeader: { position: 'relative', zIndex: 20, minHeight: 64, flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between', paddingHorizontal: 12, borderBottomWidth: 1, borderBottomColor: p.border, backgroundColor: p.header, overflow: 'visible' },
1199
+ chatHeaderLeft: { zIndex: 30, width: 76, height: 64, flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-start', gap: 5 },
1200
+ chatHeaderCenter: { position: 'absolute', zIndex: 18, top: 0, left: 0, right: 0, height: 64, alignItems: 'center', justifyContent: 'flex-start' },
1201
+ chatHeaderRight: { zIndex: 30, width: 76, height: 64, flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-end', gap: 5 },
907
1202
  headerIconButton: { width: 32, height: 32, borderRadius: 16, backgroundColor: p.dark ? 'rgba(255,255,255,0.08)' : '#FFFFFF', alignItems: 'center', justifyContent: 'center' },
908
- backIcon: { color: p.text, fontSize: 31, lineHeight: 31, marginTop: -4 },
909
- menuIcon: { color: p.text, fontSize: 17, lineHeight: 18, letterSpacing: -1.5, fontWeight: '700', marginTop: -4 },
910
- closeIcon: { color: p.text, fontSize: 23, lineHeight: 24, marginTop: -2 },
911
- agentTrigger: { position: 'absolute', zIndex: 18, top: 0, left: '50%', width: 218, height: 48, marginLeft: -109, borderWidth: 1, borderTopWidth: 0, borderColor: p.border, borderBottomLeftRadius: 24, borderBottomRightRadius: 24, backgroundColor: p.surface, alignItems: 'center', justifyContent: 'center', shadowColor: '#172236', shadowOffset: { width: 0, height: 5 }, shadowOpacity: p.dark ? 0.18 : 0.07, shadowRadius: 8, elevation: 2 },
1203
+ backIcon: { color: p.text, fontSize: 31, lineHeight: 31, transform: [{ translateY: -1 }] },
1204
+ menuDots: { width: 24, height: 24, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 3 },
1205
+ menuDot: { width: 3.5, height: 3.5, borderRadius: 2, backgroundColor: p.text },
1206
+ closeIcon: { color: p.text, fontSize: 23, lineHeight: 24, transform: [{ translateY: -1 }] },
1207
+ agentTrigger: { zIndex: 18, width: '100%', maxWidth: 218, height: 48, borderWidth: 1, borderTopWidth: 0, borderColor: p.border, borderBottomLeftRadius: 24, borderBottomRightRadius: 24, backgroundColor: p.surface, alignItems: 'center', justifyContent: 'center', shadowColor: '#172236', shadowOffset: { width: 0, height: 5 }, shadowOpacity: p.dark ? 0.18 : 0.07, shadowRadius: 8, elevation: 2 },
912
1208
  agentVisual: { height: 30, flexDirection: 'row', alignItems: 'center', justifyContent: 'center' },
913
1209
  agentAvatar: { width: 28, height: 28, borderRadius: 14, backgroundColor: p.primary, alignItems: 'center', justifyContent: 'center' },
914
1210
  agentAvatarOverlap: { marginLeft: -8, borderWidth: 2, borderColor: p.surface },
@@ -942,6 +1238,13 @@ function buildStyles(p) {
942
1238
  dangerButton: { flex: 1, minHeight: 40, borderRadius: 9, backgroundColor: p.danger, paddingHorizontal: 14, alignItems: 'center', justifyContent: 'center' },
943
1239
  disabled: { opacity: 0.55 },
944
1240
 
1241
+ contactInfoRow: { width: '100%', flexDirection: 'row', alignItems: 'flex-start', marginBottom: 12, paddingRight: 44 },
1242
+ contactInfoAvatar: { width: 28, height: 28, borderRadius: 14, marginRight: 8, marginTop: 2, backgroundColor: p.surfaceAlt, alignItems: 'center', justifyContent: 'center' },
1243
+ contactInfoAvatarText: { color: p.muted, fontSize: 13, fontWeight: '800' },
1244
+ contactInfoBubble: { flex: 1, minWidth: 0, paddingHorizontal: 12, paddingVertical: 10, borderRadius: 14, borderBottomLeftRadius: 5, backgroundColor: p.agentBubble },
1245
+ contactInfoTitle: { color: p.agentText, fontSize: 12, lineHeight: 16, fontWeight: '800', marginBottom: 5 },
1246
+ contactInfoLine: { color: p.agentText, fontSize: 11, lineHeight: 16 },
1247
+ contactInfoLabel: { fontWeight: '700' },
945
1248
  messageList: { flex: 1, minHeight: 0 },
946
1249
  messageListContent: { paddingHorizontal: 16, paddingTop: 18, paddingBottom: 12, flexGrow: 1 },
947
1250
  historyLoader: { minHeight: 34, alignItems: 'center', justifyContent: 'center', paddingBottom: 10 },
@@ -962,8 +1265,26 @@ function buildStyles(p) {
962
1265
  systemRow: { alignSelf: 'center', paddingHorizontal: 16, paddingVertical: 7, marginVertical: 3 },
963
1266
  systemText: { color: p.muted, fontSize: 11, lineHeight: 15, textAlign: 'center' },
964
1267
  attachmentImage: { width: 220, maxWidth: '100%', height: 180, borderRadius: 10, marginBottom: 4, backgroundColor: p.surfaceAlt },
965
- fileAttachment: { paddingVertical: 4 },
1268
+ fileAttachment: { minWidth: 180, maxWidth: 260, flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 4 },
1269
+ fileAttachmentIcon: { width: 34, height: 34, borderWidth: 1, borderRadius: 9, alignItems: 'center', justifyContent: 'center', opacity: 0.85 },
1270
+ fileAttachmentIconText: { fontSize: 10, fontWeight: '800' },
1271
+ fileAttachmentCopy: { flex: 1, minWidth: 0 },
966
1272
  fileAttachmentText: { fontSize: 13, fontWeight: '600' },
1273
+ fileAttachmentMeta: { marginTop: 2, fontSize: 10, opacity: 0.72 },
1274
+ markdownRoot: { flexShrink: 1 },
1275
+ markdownParagraph: { marginBottom: 2 },
1276
+ markdownSpacer: { height: 7 },
1277
+ markdownStrong: { fontWeight: '800' },
1278
+ markdownEm: { fontStyle: 'italic' },
1279
+ markdownInlineCode: { fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', fontSize: 12, backgroundColor: p.dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)' },
1280
+ markdownLink: { textDecorationLine: 'underline' },
1281
+ markdownHeading: { marginTop: 2, marginBottom: 4, fontSize: 15, lineHeight: 20, fontWeight: '800' },
1282
+ markdownQuote: { marginVertical: 3, paddingLeft: 9, borderLeftWidth: 3, borderLeftColor: p.muted },
1283
+ markdownListRow: { flexDirection: 'row', alignItems: 'flex-start', marginVertical: 1 },
1284
+ markdownListPrefix: { width: 20, fontWeight: '700' },
1285
+ markdownListText: { flex: 1, minWidth: 0 },
1286
+ markdownCodeBlock: { marginVertical: 4, paddingHorizontal: 9, paddingVertical: 8, borderRadius: 8, backgroundColor: p.dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)' },
1287
+ markdownCodeText: { fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', fontSize: 12, lineHeight: 17 },
967
1288
  typingRow: { flex: 0, paddingHorizontal: 18, paddingTop: 8, paddingBottom: 7 },
968
1289
  typingText: { color: p.muted, fontSize: 11, lineHeight: 15 },
969
1290
 
@@ -978,6 +1299,10 @@ function buildStyles(p) {
978
1299
  sendButton: { width: 38, height: 38, borderRadius: 19, backgroundColor: p.primary, alignItems: 'center', justifyContent: 'center' },
979
1300
  sendIcon: { color: '#FFFFFF', fontSize: 17, lineHeight: 18, transform: [{ rotate: '-3deg' }] },
980
1301
  aiCompliance: { color: p.muted, fontSize: 9, lineHeight: 13, textAlign: 'center', paddingHorizontal: 5, paddingBottom: 8 },
1302
+ composerErrorRow: { alignItems: 'center', justifyContent: 'center', paddingHorizontal: 8, paddingBottom: 7 },
1303
+ composerError: { color: '#B42318', fontSize: 11, lineHeight: 15, textAlign: 'center' },
1304
+ composerErrorAction: { marginTop: 5, minHeight: 28, paddingHorizontal: 10, borderRadius: 14, backgroundColor: p.surfaceAlt, alignItems: 'center', justifyContent: 'center' },
1305
+ composerErrorActionText: { color: p.text, fontSize: 10, lineHeight: 13, fontWeight: '700' },
981
1306
  attachmentMenu: { position: 'absolute', zIndex: 12, left: 12, bottom: 72, width: 210, padding: 7, borderWidth: 1, borderColor: p.border, borderRadius: 14, backgroundColor: p.surface, ...shadow },
982
1307
  attachmentAction: { minHeight: 40, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 10, borderRadius: 9 },
983
1308
  attachmentActionIcon: { width: 26, color: p.text, fontSize: 15 },
@@ -986,9 +1311,9 @@ function buildStyles(p) {
986
1311
  emojiButton: { width: '11%', aspectRatio: 1, minHeight: 34, borderRadius: 8, alignItems: 'center', justifyContent: 'center' },
987
1312
  emojiText: { fontSize: 20, lineHeight: 22 },
988
1313
 
989
- poweredBy: { flex: 0, minHeight: 17, alignItems: 'center', justifyContent: 'center', paddingBottom: 5, backgroundColor: p.background },
1314
+ poweredBy: { flex: 0, minHeight: 22, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 4, paddingBottom: 5, backgroundColor: p.background },
990
1315
  poweredByText: { color: '#9398A2', fontSize: 9, lineHeight: 11, textAlign: 'center' },
991
- poweredByBrand: { color: p.text, fontWeight: '700' },
1316
+ poweredByLogo: { width: 48, height: 15 },
992
1317
 
993
1318
  modalBackdrop: { flex: 1, backgroundColor: 'rgba(16,20,27,0.55)', justifyContent: 'flex-end', padding: 16 },
994
1319
  modalBackdropTop: { flex: 1, backgroundColor: 'rgba(16,20,27,0.55)', justifyContent: 'flex-start', padding: 16 },
package/src/client.js CHANGED
@@ -119,6 +119,7 @@ class RegantisChatClient {
119
119
  upload: null,
120
120
  realtime: null,
121
121
  realtimeConnected: false,
122
+ soundAvailable: false,
122
123
  remoteTyping: '',
123
124
  sending: false,
124
125
  uploading: false,
@@ -153,6 +154,10 @@ class RegantisChatClient {
153
154
  return `regantis_chat_visitor_${this.options.apiKey}`;
154
155
  }
155
156
 
157
+ soundStorageKey() {
158
+ return `regantis_chat_sound_${this.options.apiKey}`;
159
+ }
160
+
156
161
  async start() {
157
162
  this.destroyed = false;
158
163
  this.setState({ loading: true, error: '' });
@@ -190,7 +195,9 @@ class RegantisChatClient {
190
195
  }
191
196
 
192
197
  setSoundEnabled(enabled) {
193
- this.setState({ settings: { ...this.state.settings, sound_enabled: enabled ? 1 : 0 } });
198
+ const value = this.state.soundAvailable && enabled ? 1 : 0;
199
+ this.setState({ settings: { ...this.state.settings, sound_enabled: value } });
200
+ if (this.state.soundAvailable) native.setItem(this.soundStorageKey(), value ? '1' : '0').catch(() => {});
194
201
  }
195
202
 
196
203
  async loadConfig() {
@@ -201,12 +208,20 @@ class RegantisChatClient {
201
208
 
202
209
  const settings = normalizeSettings({ ...(data.settings || {}), ...(this.options.theme || {}) });
203
210
  const texts = { ...(data.texts || {}), ...(this.options.texts || {}) };
211
+ const soundAvailable = Number(settings.sound_enabled || 0) === 1;
212
+ if (soundAvailable) {
213
+ const storedSound = await native.getItem(this.soundStorageKey());
214
+ if (storedSound !== null && storedSound !== undefined) settings.sound_enabled = String(storedSound) === '0' ? 0 : 1;
215
+ } else {
216
+ settings.sound_enabled = 0;
217
+ }
204
218
  this.setState({
205
219
  config: data,
206
220
  settings,
207
221
  texts,
208
222
  languages: Array.isArray(data.languages) ? data.languages : [],
209
223
  translationCustomerEnabled: Number(data.translation_customer_enabled || 0) === 1,
224
+ soundAvailable,
210
225
  });
211
226
  return data;
212
227
  }
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.3',
5
+ SDK_VERSION: '0.1.4',
6
6
  DEFAULT_SETTINGS: {
7
7
  operator_name: 'Operator',
8
8
  use_profile_names: 0,