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

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
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.4",
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,20 @@ 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 SOFT_GLOW = require('../assets/soft_glow.png');
29
+
24
30
  const EMOJIS = ['🙂', '😁', '😂', '😊', '😍', '😐', '🤔', '😞', '😢', '😭', '🎉', '❤️', '👌', '👍', '👎', '🙏'];
25
31
 
26
32
 
27
33
  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
+ 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' },
35
+ 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' },
36
+ 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' },
37
+ 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' },
38
+ 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' },
39
+ 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
40
  };
35
41
 
36
42
  function localText(language, key) {
@@ -45,29 +51,54 @@ function touchDistance(touches) {
45
51
  return Math.sqrt(dx * dx + dy * dy);
46
52
  }
47
53
 
48
- function ImageLightbox({ visible, uri, onClose, closeLabel, resetLabel }) {
54
+ function ImageLightbox({ visible, uri, onClose, closeLabel, resetLabel, zoomInLabel, zoomOutLabel }) {
49
55
  const scale = React.useRef(new Animated.Value(1)).current;
50
56
  const translateX = React.useRef(new Animated.Value(0)).current;
51
57
  const translateY = React.useRef(new Animated.Value(0)).current;
52
58
  const current = React.useRef({ scale: 1, x: 0, y: 0 });
53
59
  const gesture = React.useRef({ mode: '', distance: 0, scale: 1, x: 0, y: 0, startX: 0, startY: 0 });
54
60
 
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 }),
61
+ const applyZoom = React.useCallback((value, animated = true) => {
62
+ const next = Math.max(1, Math.min(5, Number(value) || 1));
63
+ current.current.scale = next;
64
+
65
+ if (next <= 1.01) {
66
+ current.current.x = 0;
67
+ current.current.y = 0;
68
+ }
69
+
70
+ if (!animated) {
71
+ scale.setValue(next);
72
+ if (next <= 1.01) {
73
+ translateX.setValue(0);
74
+ translateY.setValue(0);
75
+ }
76
+ return;
77
+ }
78
+
79
+ const animations = [
80
+ Animated.spring(scale, { toValue: next, useNativeDriver: true, friction: 8, tension: 70 }),
62
81
  ];
63
- if (animated) Animated.parallel(values).start();
64
- else {
65
- scale.setValue(1);
66
- translateX.setValue(0);
67
- translateY.setValue(0);
82
+
83
+ if (next <= 1.01) {
84
+ animations.push(
85
+ Animated.spring(translateX, { toValue: 0, useNativeDriver: true, friction: 8, tension: 70 }),
86
+ Animated.spring(translateY, { toValue: 0, useNativeDriver: true, friction: 8, tension: 70 })
87
+ );
68
88
  }
89
+
90
+ Animated.parallel(animations).start();
69
91
  }, [scale, translateX, translateY]);
70
92
 
93
+ const resetZoom = React.useCallback((animated = true) => {
94
+ gesture.current.mode = '';
95
+ applyZoom(1, animated);
96
+ }, [applyZoom]);
97
+
98
+ const changeZoom = React.useCallback(delta => {
99
+ applyZoom(current.current.scale + delta, true);
100
+ }, [applyZoom]);
101
+
71
102
  React.useEffect(() => {
72
103
  if (visible) resetZoom(false);
73
104
  }, [visible, uri, resetZoom]);
@@ -151,14 +182,30 @@ function ImageLightbox({ visible, uri, onClose, closeLabel, resetLabel }) {
151
182
  return (
152
183
  <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose} statusBarTranslucent>
153
184
  <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>
185
+ <SafeAreaView
186
+ pointerEvents="box-none"
187
+ style={[
188
+ lightboxStyles.safeArea,
189
+ Platform.OS === 'android' && { paddingTop: Math.max(10, Number(StatusBar.currentHeight || 24)) },
190
+ ]}
191
+ >
192
+ <View style={lightboxStyles.toolbar}>
193
+ <Pressable accessibilityRole="button" accessibilityLabel={closeLabel} style={lightboxStyles.toolbarButton} onPress={onClose}>
194
+ <Text style={lightboxStyles.closeIcon}>‹</Text>
195
+ </Pressable>
196
+ <View style={lightboxStyles.zoomControls}>
197
+ <Pressable accessibilityRole="button" accessibilityLabel={zoomOutLabel} style={lightboxStyles.zoomButton} onPress={() => changeZoom(-0.5)}>
198
+ <Text style={lightboxStyles.zoomText}>−</Text>
199
+ </Pressable>
200
+ <Pressable accessibilityRole="button" accessibilityLabel={resetLabel} style={lightboxStyles.resetButton} onPress={() => resetZoom(true)}>
201
+ <Text style={lightboxStyles.resetText}>1:1</Text>
202
+ </Pressable>
203
+ <Pressable accessibilityRole="button" accessibilityLabel={zoomInLabel} style={lightboxStyles.zoomButton} onPress={() => changeZoom(0.5)}>
204
+ <Text style={lightboxStyles.zoomText}>+</Text>
205
+ </Pressable>
206
+ </View>
207
+ </View>
208
+ </SafeAreaView>
162
209
  <View style={lightboxStyles.stage} {...panResponder.panHandlers}>
163
210
  {uri ? (
164
211
  <Animated.Image
@@ -178,10 +225,14 @@ function ImageLightbox({ visible, uri, onClose, closeLabel, resetLabel }) {
178
225
 
179
226
  const lightboxStyles = StyleSheet.create({
180
227
  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' },
228
+ safeArea: { position: 'absolute', zIndex: 20, top: 0, left: 0, right: 0 },
229
+ toolbar: { minHeight: 64, paddingHorizontal: 14, paddingTop: 14, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
182
230
  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' },
231
+ closeIcon: { color: '#FFFFFF', fontSize: 39, lineHeight: 40, transform: [{ translateY: -2 }] },
232
+ zoomControls: { flexDirection: 'row', alignItems: 'center', gap: 8 },
233
+ zoomButton: { width: 44, height: 44, borderRadius: 22, backgroundColor: 'rgba(255,255,255,0.14)', alignItems: 'center', justifyContent: 'center' },
234
+ zoomText: { color: '#FFFFFF', fontSize: 27, lineHeight: 30, fontWeight: '500' },
235
+ resetButton: { minWidth: 52, height: 44, paddingHorizontal: 12, borderRadius: 22, backgroundColor: 'rgba(255,255,255,0.14)', alignItems: 'center', justifyContent: 'center' },
185
236
  resetText: { color: '#FFFFFF', fontSize: 13, fontWeight: '700' },
186
237
  stage: { flex: 1, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' },
187
238
  image: { width: '100%', height: '100%' },
@@ -199,26 +250,29 @@ function colorWithOpacity(hex, opacity) {
199
250
 
200
251
  function makePalette(settings) {
201
252
  const dark = settings.theme_mode === 'dark';
202
- const primary = settings.color_mode === 'advanced' ? settings.primary_color : settings.theme_color;
253
+ const advanced = settings.color_mode === 'advanced';
254
+ const primary = advanced ? settings.primary_color : settings.theme_color;
255
+ const backgroundHex = advanced ? settings.chat_background : (dark ? '#111316' : '#F5F7FB');
256
+ const backgroundOpacity = advanced ? settings.chat_background_opacity : 100;
203
257
  return {
204
258
  dark,
205
259
  primary,
206
- background: colorWithOpacity(settings.chat_background, settings.chat_background_opacity),
260
+ background: colorWithOpacity(backgroundHex, backgroundOpacity),
207
261
  surface: dark ? '#1D2127' : '#FFFFFF',
208
262
  surfaceAlt: dark ? '#252A31' : '#F5F7FB',
209
263
  header: dark ? '#181B20' : '#F8F9FC',
210
264
  border: dark ? 'rgba(255,255,255,0.10)' : 'rgba(20,32,54,0.10)',
211
265
  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'),
266
+ muted: advanced ? settings.system_text : (dark ? '#AEB4BE' : '#6F7480'),
267
+ customerBubble: advanced ? settings.customer_bubble : primary,
268
+ customerText: advanced ? settings.customer_text : '#FFFFFF',
269
+ agentBubble: advanced ? settings.agent_bubble : (dark ? '#22262C' : '#FFFFFF'),
270
+ agentText: advanced ? settings.agent_text : (dark ? '#F5F6F7' : '#17181B'),
217
271
  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)',
272
+ heroBase: dark ? '#151B21' : '#EEF5FC',
273
+ heroGlowA: dark ? '#495398' : '#7689FF',
274
+ heroGlowB: dark ? '#227EA8' : '#60DED7',
275
+ heroGlowC: dark ? '#293038' : '#FFFFFF',
222
276
  };
223
277
  }
224
278
 
@@ -234,6 +288,140 @@ function isSupportMessage(message) {
234
288
  return sender === 'agent' || sender === 'admin' || sender === 'chatbot';
235
289
  }
236
290
 
291
+
292
+ function humanSize(bytes) {
293
+ const size = Number(bytes || 0);
294
+ if (!size) return '0 B';
295
+ const units = ['B', 'KB', 'MB', 'GB'];
296
+ const index = Math.min(units.length - 1, Math.max(0, Math.floor(Math.log(size) / Math.log(1024))));
297
+ const value = size / Math.pow(1024, index);
298
+ return `${value.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
299
+ }
300
+
301
+ function normalizeMarkdownSource(source) {
302
+ return String(source == null ? '' : source)
303
+ .replace(/\\([*_`#>\[\]()~+-])/g, '$1')
304
+ .replace(/\*{3,}([^*\n]+?)\*{3,}/g, '**$1**')
305
+ .replace(/^(\s*[-+*]\s+)\*([^*\n].*?)\*\*\s*$/gm, '$1**$2**');
306
+ }
307
+
308
+ function renderInlineMarkdown(value, textStyle, styles, keyPrefix) {
309
+ const text = String(value || '');
310
+ const pattern = /(\*\*[^*\n]+\*\*|__[^_\n]+__|`[^`\n]+`|\[[^\]\n]+\]\((?:https?:\/\/|mailto:|tel:)[^)\s]+\)|\*[^*\n]+\*|_[^_\n]+_)/g;
311
+ const nodes = [];
312
+ let last = 0;
313
+ let match;
314
+ let index = 0;
315
+
316
+ while ((match = pattern.exec(text)) !== null) {
317
+ if (match.index > last) nodes.push(text.slice(last, match.index));
318
+ const token = match[0];
319
+ const key = `${keyPrefix}-${index++}`;
320
+
321
+ if ((token.startsWith('**') && token.endsWith('**')) || (token.startsWith('__') && token.endsWith('__'))) {
322
+ nodes.push(<Text key={key} style={[textStyle, styles.markdownStrong]}>{token.slice(2, -2)}</Text>);
323
+ } else if (token.startsWith('`')) {
324
+ nodes.push(<Text key={key} style={[textStyle, styles.markdownInlineCode]}>{token.slice(1, -1)}</Text>);
325
+ } else if (token.startsWith('[')) {
326
+ const linkMatch = token.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
327
+ if (linkMatch) {
328
+ const url = linkMatch[2];
329
+ nodes.push(
330
+ <Text key={key} style={[textStyle, styles.markdownLink]} onPress={() => Linking.openURL(url).catch(() => {})}>
331
+ {linkMatch[1]}
332
+ </Text>
333
+ );
334
+ } else {
335
+ nodes.push(token);
336
+ }
337
+ } else {
338
+ nodes.push(<Text key={key} style={[textStyle, styles.markdownEm]}>{token.slice(1, -1)}</Text>);
339
+ }
340
+ last = pattern.lastIndex;
341
+ }
342
+
343
+ if (last < text.length) nodes.push(text.slice(last));
344
+ return nodes;
345
+ }
346
+
347
+ function SafeMarkdown({ source, textStyle, styles }) {
348
+ const lines = normalizeMarkdownSource(source).replace(/\r\n?/g, '\n').split('\n');
349
+ const blocks = [];
350
+ let codeLines = [];
351
+ let inCode = false;
352
+
353
+ const flushCode = key => {
354
+ if (!codeLines.length) return;
355
+ blocks.push(
356
+ <View key={key} style={styles.markdownCodeBlock}>
357
+ <Text style={[textStyle, styles.markdownCodeText]}>{codeLines.join('\n')}</Text>
358
+ </View>
359
+ );
360
+ codeLines = [];
361
+ };
362
+
363
+ lines.forEach((line, index) => {
364
+ if (/^```/.test(line.trim())) {
365
+ if (inCode) flushCode(`code-${index}`);
366
+ inCode = !inCode;
367
+ return;
368
+ }
369
+
370
+ if (inCode) {
371
+ codeLines.push(line);
372
+ return;
373
+ }
374
+
375
+ if (!line.trim()) {
376
+ blocks.push(<View key={`space-${index}`} style={styles.markdownSpacer} />);
377
+ return;
378
+ }
379
+
380
+ const heading = line.match(/^\s*(#{1,6})\s+(.+)$/);
381
+ if (heading) {
382
+ blocks.push(
383
+ <Text key={`h-${index}`} style={[textStyle, styles.markdownHeading]}>
384
+ {renderInlineMarkdown(heading[2], textStyle, styles, `h-${index}`)}
385
+ </Text>
386
+ );
387
+ return;
388
+ }
389
+
390
+ const quote = line.match(/^\s*>\s?(.*)$/);
391
+ if (quote) {
392
+ blocks.push(
393
+ <View key={`q-${index}`} style={styles.markdownQuote}>
394
+ <Text style={textStyle}>{renderInlineMarkdown(quote[1], textStyle, styles, `q-${index}`)}</Text>
395
+ </View>
396
+ );
397
+ return;
398
+ }
399
+
400
+ const unordered = line.match(/^\s*[-+*]\s+(.+)$/);
401
+ const ordered = line.match(/^\s*(\d+)[.)]\s+(.+)$/);
402
+ if (unordered || ordered) {
403
+ const prefix = ordered ? `${ordered[1]}.` : '•';
404
+ const content = ordered ? ordered[2] : unordered[1];
405
+ blocks.push(
406
+ <View key={`li-${index}`} style={styles.markdownListRow}>
407
+ <Text style={[textStyle, styles.markdownListPrefix]}>{prefix}</Text>
408
+ <Text style={[textStyle, styles.markdownListText]}>{renderInlineMarkdown(content, textStyle, styles, `li-${index}`)}</Text>
409
+ </View>
410
+ );
411
+ return;
412
+ }
413
+
414
+ blocks.push(
415
+ <Text key={`p-${index}`} style={[textStyle, styles.markdownParagraph]}>
416
+ {renderInlineMarkdown(line, textStyle, styles, `p-${index}`)}
417
+ </Text>
418
+ );
419
+ });
420
+
421
+ if (codeLines.length) flushCode('code-last');
422
+ return <View style={styles.markdownRoot}>{blocks}</View>;
423
+ }
424
+
237
425
  function RegantisChat(props) {
238
426
  const {
239
427
  apiKey,
@@ -286,13 +474,18 @@ function RegantisChat(props) {
286
474
  const [ratingComment, setRatingComment] = React.useState('');
287
475
  const [ratingStatus, setRatingStatus] = React.useState('');
288
476
  const [busyAction, setBusyAction] = React.useState('');
477
+ const [attachmentError, setAttachmentError] = React.useState('');
478
+ const [cameraSettingsNeeded, setCameraSettingsNeeded] = React.useState(false);
289
479
  const [lightboxUri, setLightboxUri] = React.useState('');
480
+ const [logoFailed, setLogoFailed] = React.useState(false);
290
481
  const listRef = React.useRef(null);
482
+ const previousStatusRef = React.useRef(null);
483
+ const activeViewRef = React.useRef(initialView === 'chat' ? 'chat' : 'home');
291
484
 
292
485
  React.useEffect(() => {
293
486
  const unsubscribe = client.subscribe(next => setState(next));
294
487
  client.start().catch(() => {});
295
- const sub = AppState.addEventListener('change', value => client.setVisible(value === 'active'));
488
+ const sub = AppState.addEventListener('change', value => client.setVisible(value === 'active' && activeViewRef.current === 'chat'));
296
489
  return () => {
297
490
  if (sub && sub.remove) sub.remove();
298
491
  unsubscribe();
@@ -300,6 +493,15 @@ function RegantisChat(props) {
300
493
  };
301
494
  }, [client]);
302
495
 
496
+ React.useEffect(() => {
497
+ activeViewRef.current = activeView;
498
+ client.setVisible(AppState.currentState === 'active' && activeView === 'chat');
499
+ }, [activeView, client]);
500
+
501
+ React.useEffect(() => {
502
+ setLogoFailed(false);
503
+ }, [state.settings.logo_url]);
504
+
303
505
  React.useEffect(() => {
304
506
  const c = state.conversation || {};
305
507
  if (c.contact_name && !contactName) setContactName(c.contact_name);
@@ -330,6 +532,9 @@ function RegantisChat(props) {
330
532
  palette.agentBubble,
331
533
  palette.agentText,
332
534
  palette.heroBase,
535
+ palette.heroGlowA,
536
+ palette.heroGlowB,
537
+ palette.heroGlowC,
333
538
  ]);
334
539
 
335
540
  const c = state.conversation || {};
@@ -341,9 +546,36 @@ function RegantisChat(props) {
341
546
  const homePreview = lastSupport
342
547
  ? String(lastSupport.message || '').trim() || (lastSupport.attachment ? `[${String(lastSupport.message_type || 'file')}]` : t('empty'))
343
548
  : t('empty');
549
+ const latestNamedAgent = Number(state.settings.use_profile_names || 0) === 1
550
+ ? [...state.messages].reverse().find(message => {
551
+ const sender = String(message && message.sender_type || '').toLowerCase();
552
+ return (sender === 'agent' || sender === 'admin') && String(message && message.sender_name || '').trim();
553
+ })
554
+ : null;
555
+ const operatorDisplayName = latestNamedAgent
556
+ ? String(latestNamedAgent.sender_name || '').trim()
557
+ : (state.settings.operator_name || t('support_name'));
558
+
559
+ React.useEffect(() => {
560
+ const nextStatus = Number(c.status || 0);
561
+ const previousStatus = previousStatusRef.current;
562
+ previousStatusRef.current = nextStatus;
563
+
564
+ if (
565
+ previousStatus === 1 &&
566
+ nextStatus === 0 &&
567
+ Number(state.settings.rating_enabled || 0) === 1 &&
568
+ contactConfirmed &&
569
+ !c.feedback_submitted
570
+ ) {
571
+ const timer = setTimeout(() => setRatingOpen(true), 120);
572
+ return () => clearTimeout(timer);
573
+ }
574
+ }, [c.status, c.feedback_submitted, contactConfirmed, state.settings.rating_enabled]);
344
575
 
345
576
  function showView(next) {
346
577
  const view = next === 'chat' ? 'chat' : 'home';
578
+ activeViewRef.current = view;
347
579
  setActiveView(view);
348
580
  setOptionsOpen(false);
349
581
  setLanguageOpen(false);
@@ -382,10 +614,23 @@ function RegantisChat(props) {
382
614
 
383
615
  async function pick(kind) {
384
616
  setAttachOpen(false);
617
+ setAttachmentError('');
618
+ setCameraSettingsNeeded(false);
385
619
  setBusyAction('upload');
386
620
  try {
387
621
  await client.pickAndUpload(kind);
388
- } catch (_) {
622
+ setAttachmentError('');
623
+ setCameraSettingsNeeded(false);
624
+ } catch (error) {
625
+ const code = String(error && (error.code || error.message) || '');
626
+ if (code.includes('CAMERA_PERMISSION_DENIED')) {
627
+ setAttachmentError(t('camera_permission_denied'));
628
+ setCameraSettingsNeeded(true);
629
+ } else if (code.includes('CAMERA_USAGE_DESCRIPTION_MISSING') || code.includes('CAMERA_PERMISSION_UNAVAILABLE')) {
630
+ setAttachmentError(t('camera_setup_required'));
631
+ } else if (code.includes('CAMERA_UNAVAILABLE')) setAttachmentError(t('camera_unavailable'));
632
+ else setAttachmentError(error && error.message ? error.message : t('send_error'));
633
+ if (typeof onError === 'function') onError(error);
389
634
  } finally {
390
635
  setBusyAction('');
391
636
  }
@@ -463,8 +708,8 @@ function RegantisChat(props) {
463
708
 
464
709
  function renderLogo(size, marginRight) {
465
710
  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" />;
711
+ if (state.settings.logo_url && !logoFailed) {
712
+ 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
713
  }
469
714
  return (
470
715
  <View style={[styles.logoFallback, { width: size, height: size, borderRadius: size / 2, marginRight: marginRight || 0 }]}>
@@ -498,7 +743,11 @@ function RegantisChat(props) {
498
743
  const textStyle = visitor ? styles.customerText : styles.agentText;
499
744
  const attachment = item.attachment || null;
500
745
  const isImage = attachment && String(attachment.mime || '').startsWith('image/');
501
- const senderName = sender === 'chatbot' ? 'Chatbot' : (item.sender_name || state.settings.operator_name || t('support_name'));
746
+ const configuredName = state.settings.operator_name || t('support_name');
747
+ const senderName = sender === 'chatbot'
748
+ ? 'Chatbot'
749
+ : (Number(state.settings.use_profile_names || 0) === 1 ? (item.sender_name || configuredName) : configuredName);
750
+ const fileExt = String(attachment && attachment.ext || '').replace(/^\./, '').slice(0, 4).toUpperCase() || 'FILE';
502
751
 
503
752
  return (
504
753
  <View style={[styles.messageRow, visitor ? styles.messageRowRight : styles.messageRowLeft]}>
@@ -508,11 +757,21 @@ function RegantisChat(props) {
508
757
  <Image source={{ uri: attachment.url }} style={styles.attachmentImage} resizeMode="cover" />
509
758
  </Pressable>
510
759
  ) : attachment ? (
511
- <Pressable style={styles.fileAttachment} onPress={() => Linking.openURL(attachment.url)}>
512
- <Text style={[styles.fileAttachmentText, textStyle]}>📎 {attachment.name || t('attach_file')}</Text>
760
+ <Pressable style={styles.fileAttachment} onPress={() => Linking.openURL(attachment.url).catch(() => {})}>
761
+ <View style={[styles.fileAttachmentIcon, { borderColor: textStyle.color }]}>
762
+ <Text style={[styles.fileAttachmentIconText, textStyle]}>{fileExt}</Text>
763
+ </View>
764
+ <View style={styles.fileAttachmentCopy}>
765
+ <Text numberOfLines={1} style={[styles.fileAttachmentText, textStyle]}>{attachment.name || t('attach_file')}</Text>
766
+ <Text numberOfLines={1} style={[styles.fileAttachmentMeta, textStyle]}>{humanSize(attachment.size)}{attachment.ext ? ` · ${String(attachment.ext).toUpperCase()}` : ''}</Text>
767
+ </View>
513
768
  </Pressable>
514
769
  ) : null}
515
- {item.message ? <Text style={[styles.messageText, textStyle]}>{item.message}</Text> : null}
770
+ {item.message ? (
771
+ sender === 'chatbot'
772
+ ? <SafeMarkdown source={item.message} textStyle={[styles.messageText, textStyle]} styles={styles} />
773
+ : <Text style={[styles.messageText, textStyle]}>{item.message}</Text>
774
+ ) : null}
516
775
  <View style={styles.messageMeta}>
517
776
  {!visitor ? <Text numberOfLines={1} style={[styles.messageSender, textStyle]}>{senderName}</Text> : <View />}
518
777
  <Text style={[styles.timeText, textStyle]}>{formatTime(item.date_added)}</Text>
@@ -524,16 +783,21 @@ function RegantisChat(props) {
524
783
 
525
784
  function renderPoweredBy() {
526
785
  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>;
786
+ return (
787
+ <View style={styles.poweredBy}>
788
+ <Text style={styles.poweredByText}>{t('powered_by')}</Text>
789
+ <Image source={palette.dark ? REGANTIS_LOGO_WHITE : REGANTIS_LOGO_BLACK} style={styles.poweredByLogo} resizeMode="contain" />
790
+ </View>
791
+ );
528
792
  }
529
793
 
530
794
  function renderHome() {
531
795
  return (
532
796
  <View style={styles.screen}>
533
797
  <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} />
798
+ <Image pointerEvents="none" source={SOFT_GLOW} style={[styles.heroGlow, styles.heroGlowA, { tintColor: palette.heroGlowA }]} resizeMode="stretch" />
799
+ <Image pointerEvents="none" source={SOFT_GLOW} style={[styles.heroGlow, styles.heroGlowB, { tintColor: palette.heroGlowB }]} resizeMode="stretch" />
800
+ <Image pointerEvents="none" source={SOFT_GLOW} style={[styles.heroGlow, styles.heroGlowC, { tintColor: palette.heroGlowC }]} resizeMode="stretch" />
537
801
 
538
802
  <View style={styles.homeTop}>
539
803
  <View style={styles.homeTopLeft}>
@@ -560,7 +824,7 @@ function RegantisChat(props) {
560
824
  <View style={styles.homeCardTop}>
561
825
  {state.settings.show_agent_photo ? <View style={styles.homeAvatar}><Text style={styles.homeAvatarText}>R</Text></View> : null}
562
826
  <View style={styles.homeCardCopy}>
563
- <Text numberOfLines={1} style={styles.homeCardAgent}>{state.settings.operator_name || t('support_name')}</Text>
827
+ <Text numberOfLines={1} style={styles.homeCardAgent}>{operatorDisplayName}</Text>
564
828
  <Text numberOfLines={1} style={styles.homeCardPreview}>{homePreview}</Text>
565
829
  </View>
566
830
  </View>
@@ -595,20 +859,22 @@ function RegantisChat(props) {
595
859
  <Text style={styles.backIcon}>‹</Text>
596
860
  </Pressable>
597
861
  <Pressable accessibilityRole="button" accessibilityLabel={t('options')} style={styles.headerIconButton} onPress={() => setOptionsOpen(value => !value)}>
598
- <Text style={styles.menuIcon}>•••</Text>
862
+ <View style={styles.menuDots}><View style={styles.menuDot} /><View style={styles.menuDot} /><View style={styles.menuDot} /></View>
599
863
  </Pressable>
600
864
  </View>
601
865
 
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>
866
+ <View pointerEvents="box-none" style={styles.chatHeaderCenter}>
867
+ <Pressable
868
+ accessibilityRole="button"
869
+ accessibilityLabel={operatorDisplayName}
870
+ style={styles.agentTrigger}
871
+ onPress={() => {
872
+ if (state.settings.rating_enabled && contactConfirmed) setRatingOpen(true);
873
+ }}
874
+ >
875
+ {renderAgentVisual()}
876
+ </Pressable>
877
+ </View>
612
878
 
613
879
  <View style={styles.chatHeaderRight}>
614
880
  {typeof onClose === 'function' ? (
@@ -640,7 +906,7 @@ function RegantisChat(props) {
640
906
  <Text style={styles.popoverChevron}>›</Text>
641
907
  </Pressable>
642
908
  ) : null}
643
- {state.settings.sound_enabled !== undefined ? (
909
+ {state.soundAvailable ? (
644
910
  <Pressable style={styles.popoverRow} onPress={() => client.setSoundEnabled(!state.settings.sound_enabled)}>
645
911
  <Text style={styles.popoverIcon}>◖</Text>
646
912
  <Text style={styles.popoverText}>{t('sounds')}</Text>
@@ -674,6 +940,20 @@ function RegantisChat(props) {
674
940
  );
675
941
  }
676
942
 
943
+ function renderContactInfoCard() {
944
+ if (!contactConfirmed || (!c.contact_name && !c.contact_email)) return null;
945
+ return (
946
+ <View style={styles.contactInfoRow}>
947
+ <View style={styles.contactInfoAvatar}><Text style={styles.contactInfoAvatarText}>i</Text></View>
948
+ <View style={styles.contactInfoBubble}>
949
+ <Text style={styles.contactInfoTitle}>{t('personal_details')}</Text>
950
+ {c.contact_name ? <Text style={styles.contactInfoLine}><Text style={styles.contactInfoLabel}>{t('name')}: </Text>{c.contact_name}</Text> : null}
951
+ {c.contact_email ? <Text style={styles.contactInfoLine}><Text style={styles.contactInfoLabel}>{t('email')}: </Text>{c.contact_email}</Text> : null}
952
+ </View>
953
+ </View>
954
+ );
955
+ }
956
+
677
957
  function renderComposer() {
678
958
  if (!active) {
679
959
  return (
@@ -710,6 +990,17 @@ function RegantisChat(props) {
710
990
  </View>
711
991
  ) : null}
712
992
 
993
+ {attachmentError ? (
994
+ <View style={styles.composerErrorRow}>
995
+ <Text style={styles.composerError}>{attachmentError}</Text>
996
+ {cameraSettingsNeeded ? (
997
+ <Pressable style={styles.composerErrorAction} onPress={() => Linking.openSettings().catch(() => {})}>
998
+ <Text style={styles.composerErrorActionText}>{t('open_settings')}</Text>
999
+ </Pressable>
1000
+ ) : null}
1001
+ </View>
1002
+ ) : null}
1003
+
713
1004
  <View style={styles.composer}>
714
1005
  <Pressable disabled={!canUpload || state.uploading} style={[styles.composerButton, (!canUpload || state.uploading) && styles.disabled]} onPress={() => { setEmojiOpen(false); setAttachOpen(value => !value); }}>
715
1006
  <Text style={styles.composerPlus}>+</Text>
@@ -749,7 +1040,12 @@ function RegantisChat(props) {
749
1040
  style={styles.messageList}
750
1041
  contentContainerStyle={styles.messageListContent}
751
1042
  onScrollBeginDrag={() => state.hasMore && client.loadOlder().catch(() => {})}
752
- ListHeaderComponent={state.hasMore ? <View style={styles.historyLoader}><ActivityIndicator size="small" color={palette.primary} /></View> : null}
1043
+ ListHeaderComponent={(
1044
+ <>
1045
+ {state.hasMore ? <View style={styles.historyLoader}><ActivityIndicator size="small" color={palette.primary} /></View> : null}
1046
+ {renderContactInfoCard()}
1047
+ </>
1048
+ )}
753
1049
  ListEmptyComponent={<View style={styles.emptyState}><Text style={styles.emptyText}>{t('empty')}</Text></View>}
754
1050
  />
755
1051
  {state.remoteTyping ? <View style={styles.typingRow}><Text style={styles.typingText}>{state.remoteTyping === 'chatbot' ? t('chatbot_typing') : t('typing')}</Text></View> : null}
@@ -825,7 +1121,7 @@ function RegantisChat(props) {
825
1121
  <Modal transparent visible={ratingOpen} animationType="fade" onRequestClose={() => setRatingOpen(false)}>
826
1122
  <View style={styles.modalBackdropTop}>
827
1123
  <View style={styles.modalCard}>
828
- <Text style={styles.modalTitle}>{state.settings.operator_name || t('support_name')}</Text>
1124
+ <Text style={styles.modalTitle}>{operatorDisplayName}</Text>
829
1125
  <Text style={styles.ratingLabel}>{t('rating_agent_label')}</Text>
830
1126
  <View style={styles.ratingButtons}>
831
1127
  <Pressable style={styles.ratingButton} onPress={() => rate(1)}><Text style={styles.ratingIcon}>👍</Text></Pressable>
@@ -847,6 +1143,8 @@ function RegantisChat(props) {
847
1143
  onClose={() => setLightboxUri('')}
848
1144
  closeLabel={t('lightbox_close')}
849
1145
  resetLabel={t('lightbox_reset')}
1146
+ zoomInLabel={t('lightbox_zoom_in')}
1147
+ zoomOutLabel={t('lightbox_zoom_out')}
850
1148
  />
851
1149
  </KeyboardAvoidingView>
852
1150
  );
@@ -869,9 +1167,10 @@ function buildStyles(p) {
869
1167
  errorText: { color: p.text, fontSize: 14, textAlign: 'center', marginBottom: 12 },
870
1168
 
871
1169
  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 },
1170
+ heroGlow: { position: 'absolute', opacity: p.dark ? 0.34 : 0.72 },
1171
+ heroGlowA: { width: 390, height: 390, top: -235, left: -165 },
1172
+ heroGlowB: { width: 360, height: 360, top: 20, right: -170, opacity: p.dark ? 0.26 : 0.62 },
1173
+ heroGlowC: { width: 300, height: 300, right: -135, bottom: -185, opacity: p.dark ? 0.09 : 0.34 },
875
1174
  homeTop: { minHeight: 42, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', zIndex: 2 },
876
1175
  homeTopLeft: { flexDirection: 'row', alignItems: 'center', gap: 9 },
877
1176
  homeBackIcon: { color: p.text, fontSize: 37, lineHeight: 38, marginTop: -5 },
@@ -901,14 +1200,16 @@ function buildStyles(p) {
901
1200
  bottomNavText: { marginTop: 2, color: p.muted, fontSize: 10, lineHeight: 12 },
902
1201
  bottomNavTextActive: { color: p.text },
903
1202
 
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 },
1203
+ 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' },
1204
+ chatHeaderLeft: { zIndex: 30, width: 76, height: 64, flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-start', gap: 5 },
1205
+ chatHeaderCenter: { position: 'absolute', zIndex: 18, top: 0, left: 0, right: 0, height: 64, alignItems: 'center', justifyContent: 'flex-start' },
1206
+ chatHeaderRight: { zIndex: 30, width: 76, height: 64, flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-end', gap: 5 },
907
1207
  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 },
1208
+ backIcon: { color: p.text, fontSize: 31, lineHeight: 31, transform: [{ translateY: -1 }] },
1209
+ menuDots: { width: 24, height: 24, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 3 },
1210
+ menuDot: { width: 3.5, height: 3.5, borderRadius: 2, backgroundColor: p.text },
1211
+ closeIcon: { color: p.text, fontSize: 23, lineHeight: 24, transform: [{ translateY: -1 }] },
1212
+ 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
1213
  agentVisual: { height: 30, flexDirection: 'row', alignItems: 'center', justifyContent: 'center' },
913
1214
  agentAvatar: { width: 28, height: 28, borderRadius: 14, backgroundColor: p.primary, alignItems: 'center', justifyContent: 'center' },
914
1215
  agentAvatarOverlap: { marginLeft: -8, borderWidth: 2, borderColor: p.surface },
@@ -942,6 +1243,13 @@ function buildStyles(p) {
942
1243
  dangerButton: { flex: 1, minHeight: 40, borderRadius: 9, backgroundColor: p.danger, paddingHorizontal: 14, alignItems: 'center', justifyContent: 'center' },
943
1244
  disabled: { opacity: 0.55 },
944
1245
 
1246
+ contactInfoRow: { width: '100%', flexDirection: 'row', alignItems: 'flex-start', marginBottom: 12, paddingRight: 44 },
1247
+ contactInfoAvatar: { width: 28, height: 28, borderRadius: 14, marginRight: 8, marginTop: 2, backgroundColor: p.surfaceAlt, alignItems: 'center', justifyContent: 'center' },
1248
+ contactInfoAvatarText: { color: p.muted, fontSize: 13, fontWeight: '800' },
1249
+ contactInfoBubble: { flex: 1, minWidth: 0, paddingHorizontal: 12, paddingVertical: 10, borderRadius: 14, borderBottomLeftRadius: 5, backgroundColor: p.agentBubble },
1250
+ contactInfoTitle: { color: p.agentText, fontSize: 12, lineHeight: 16, fontWeight: '800', marginBottom: 5 },
1251
+ contactInfoLine: { color: p.agentText, fontSize: 11, lineHeight: 16 },
1252
+ contactInfoLabel: { fontWeight: '700' },
945
1253
  messageList: { flex: 1, minHeight: 0 },
946
1254
  messageListContent: { paddingHorizontal: 16, paddingTop: 18, paddingBottom: 12, flexGrow: 1 },
947
1255
  historyLoader: { minHeight: 34, alignItems: 'center', justifyContent: 'center', paddingBottom: 10 },
@@ -962,8 +1270,26 @@ function buildStyles(p) {
962
1270
  systemRow: { alignSelf: 'center', paddingHorizontal: 16, paddingVertical: 7, marginVertical: 3 },
963
1271
  systemText: { color: p.muted, fontSize: 11, lineHeight: 15, textAlign: 'center' },
964
1272
  attachmentImage: { width: 220, maxWidth: '100%', height: 180, borderRadius: 10, marginBottom: 4, backgroundColor: p.surfaceAlt },
965
- fileAttachment: { paddingVertical: 4 },
1273
+ fileAttachment: { minWidth: 180, maxWidth: 260, flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 4 },
1274
+ fileAttachmentIcon: { width: 34, height: 34, borderWidth: 1, borderRadius: 9, alignItems: 'center', justifyContent: 'center', opacity: 0.85 },
1275
+ fileAttachmentIconText: { fontSize: 10, fontWeight: '800' },
1276
+ fileAttachmentCopy: { flex: 1, minWidth: 0 },
966
1277
  fileAttachmentText: { fontSize: 13, fontWeight: '600' },
1278
+ fileAttachmentMeta: { marginTop: 2, fontSize: 10, opacity: 0.72 },
1279
+ markdownRoot: { flexShrink: 1 },
1280
+ markdownParagraph: { marginBottom: 2 },
1281
+ markdownSpacer: { height: 7 },
1282
+ markdownStrong: { fontWeight: '800' },
1283
+ markdownEm: { fontStyle: 'italic' },
1284
+ markdownInlineCode: { fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', fontSize: 12, backgroundColor: p.dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)' },
1285
+ markdownLink: { textDecorationLine: 'underline' },
1286
+ markdownHeading: { marginTop: 2, marginBottom: 4, fontSize: 15, lineHeight: 20, fontWeight: '800' },
1287
+ markdownQuote: { marginVertical: 3, paddingLeft: 9, borderLeftWidth: 3, borderLeftColor: p.muted },
1288
+ markdownListRow: { flexDirection: 'row', alignItems: 'flex-start', marginVertical: 1 },
1289
+ markdownListPrefix: { width: 20, fontWeight: '700' },
1290
+ markdownListText: { flex: 1, minWidth: 0 },
1291
+ markdownCodeBlock: { marginVertical: 4, paddingHorizontal: 9, paddingVertical: 8, borderRadius: 8, backgroundColor: p.dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)' },
1292
+ markdownCodeText: { fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', fontSize: 12, lineHeight: 17 },
967
1293
  typingRow: { flex: 0, paddingHorizontal: 18, paddingTop: 8, paddingBottom: 7 },
968
1294
  typingText: { color: p.muted, fontSize: 11, lineHeight: 15 },
969
1295
 
@@ -978,6 +1304,10 @@ function buildStyles(p) {
978
1304
  sendButton: { width: 38, height: 38, borderRadius: 19, backgroundColor: p.primary, alignItems: 'center', justifyContent: 'center' },
979
1305
  sendIcon: { color: '#FFFFFF', fontSize: 17, lineHeight: 18, transform: [{ rotate: '-3deg' }] },
980
1306
  aiCompliance: { color: p.muted, fontSize: 9, lineHeight: 13, textAlign: 'center', paddingHorizontal: 5, paddingBottom: 8 },
1307
+ composerErrorRow: { alignItems: 'center', justifyContent: 'center', paddingHorizontal: 8, paddingBottom: 7 },
1308
+ composerError: { color: '#B42318', fontSize: 11, lineHeight: 15, textAlign: 'center' },
1309
+ composerErrorAction: { marginTop: 5, minHeight: 28, paddingHorizontal: 10, borderRadius: 14, backgroundColor: p.surfaceAlt, alignItems: 'center', justifyContent: 'center' },
1310
+ composerErrorActionText: { color: p.text, fontSize: 10, lineHeight: 13, fontWeight: '700' },
981
1311
  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
1312
  attachmentAction: { minHeight: 40, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 10, borderRadius: 9 },
983
1313
  attachmentActionIcon: { width: 26, color: p.text, fontSize: 15 },
@@ -986,9 +1316,9 @@ function buildStyles(p) {
986
1316
  emojiButton: { width: '11%', aspectRatio: 1, minHeight: 34, borderRadius: 8, alignItems: 'center', justifyContent: 'center' },
987
1317
  emojiText: { fontSize: 20, lineHeight: 22 },
988
1318
 
989
- poweredBy: { flex: 0, minHeight: 17, alignItems: 'center', justifyContent: 'center', paddingBottom: 5, backgroundColor: p.background },
1319
+ poweredBy: { flex: 0, minHeight: 22, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 4, paddingBottom: 5, backgroundColor: p.background },
990
1320
  poweredByText: { color: '#9398A2', fontSize: 9, lineHeight: 11, textAlign: 'center' },
991
- poweredByBrand: { color: p.text, fontWeight: '700' },
1321
+ poweredByLogo: { width: 48, height: 15 },
992
1322
 
993
1323
  modalBackdrop: { flex: 1, backgroundColor: 'rgba(16,20,27,0.55)', justifyContent: 'flex-end', padding: 16 },
994
1324
  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,