connect-x-reactnative-livechat-sdk 1.5.0-legacy → 1.6.0-legacy

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
@@ -9,7 +9,8 @@ An SDK for easily integrating the Connect-X Live Chat into your React Native app
9
9
  * Displays chat history with date partitions.
10
10
  * Customizable styles and colors from from [Connect-X](https://app.connect-x.tech/) by navigating to: Organization Settings → Connector → Live Chat → Style.
11
11
  * Supports custom header components.
12
- * Image preview with zoom capabilities.
12
+ * Image preview with zoom capabilities and download (Save to Files on iOS, save-as on Android).
13
+ * Supports DNG, HEIC and JFIF images.
13
14
 
14
15
  ## 🔧 Installation
15
16
 
@@ -33,7 +34,7 @@ yarn connect-x-reactnative-livechat-sdk@1.4.7-legacy
33
34
  You need to install the following peer dependencies manually:
34
35
 
35
36
  ```bash
36
- npm install @react-native-documents/picker react-native-svg@13.4.0 react-native-image-zoom-viewer react-native-device-info @react-native-community/netinfo
37
+ npm install @react-native-documents/picker react-native-image-picker react-native-svg@13.4.0 react-native-image-zoom-viewer react-native-device-info react-native-blob-util @react-native-community/netinfo
37
38
  ```
38
39
 
39
40
  If you have a problem with @react-native-documents/picker,
@@ -59,11 +60,30 @@ https://github.com/react-native-documents/document-picker/issues/777#issuecommen
59
60
  This library requires some peer dependencies. You need to install these libraries in your project as well:
60
61
 
61
62
  ```bash
62
- npm install socket.io-client @react-native-documents/picker react-native-svg react-native-image-zoom-viewer react-native-device-info @react-native-community/netinfo
63
+ npm install socket.io-client @react-native-documents/picker react-native-image-picker react-native-svg react-native-image-zoom-viewer react-native-device-info react-native-blob-util @react-native-community/netinfo
63
64
 
64
65
  #or
65
66
 
66
- yarn add socket.io-client @react-native-documents/picker react-native-svg react-native-image-zoom-viewer react-native-device-info @react-native-community/netinfo
67
+ yarn add socket.io-client @react-native-documents/picker react-native-image-picker react-native-svg react-native-image-zoom-viewer react-native-device-info react-native-blob-util @react-native-community/netinfo
68
+ ```
69
+
70
+ ### Camera & Photo Library Permissions
71
+
72
+ The chat input has **File**, **Camera** and **Gallery** buttons (via `react-native-image-picker`). Add these to your app:
73
+
74
+ **iOS** (`Info.plist`):
75
+
76
+ ```xml
77
+ <key>NSCameraUsageDescription</key>
78
+ <string>Take a photo to send in live chat</string>
79
+ <key>NSPhotoLibraryUsageDescription</key>
80
+ <string>Choose a photo to send in live chat</string>
81
+ ```
82
+
83
+ **Android** (`AndroidManifest.xml`):
84
+
85
+ ```xml
86
+ <uses-permission android:name="android.permission.CAMERA" />
67
87
  ```
68
88
 
69
89
  ## 🚀 Usage
@@ -89,8 +109,15 @@ const AppContent = () => {
89
109
  token: 'YOUR_AUTH_TOKEN',
90
110
  orgId: 'YOUR_ORGANIZATION_ID',
91
111
  channelId: 'YOUR_CHANNEL_ID',
92
- externalValue: 'YOUR_EXTERNAL_VALUE',
93
- });
112
+ customer: {
113
+ cx_Name: 'CUSTOMER_NAME',
114
+ cx_firstName: 'CUSTOMER_FIRST_NAME',
115
+ cx_lastName: 'CUSTOMER_LAST_NAME',
116
+ // You can add any customer field here
117
+ // Note: The customer object must contain at least one field configured as an Upsert Key in Connect-X.
118
+ },
119
+ customHeader: <YourCustomHeaderComponent />, // Optional
120
+ });
94
121
  }}
95
122
  />
96
123
  </View>
@@ -167,6 +194,18 @@ A method to open the chat window. It accepts a `config` object with the followin
167
194
 
168
195
  ---
169
196
 
197
+ ## 📝 Changelog
198
+
199
+ ### 1.6.0-legacy
200
+
201
+ * **Attach buttons:** the chat input has separate **File** (+ icon), **Camera** and **Gallery** buttons.
202
+ * **DNG / HEIC / JFIF:** these images can be sent, viewed and downloaded. Images the device can't decode fall back to a download link.
203
+ * **Correct file types:** DNG and HEIC picked from the iOS gallery keep their real extension instead of becoming `.jpg`.
204
+ * **Image preview:** the close button sits below the notch, and a new download button saves the image through the system Save to Files / save-as sheet.
205
+ * **New dependency:** `react-native-blob-util`. Install it and run `pod install` after upgrading.
206
+
207
+ ---
208
+
170
209
  ## 📄 License
171
210
 
172
211
  [![Apache License](https://img.shields.io/badge/License-Apache-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0)
@@ -2,16 +2,45 @@
2
2
 
3
3
  // src/ChatScreen.tsx
4
4
  import React, { useState, useEffect, useRef, useMemo } from 'react';
5
- import { View, Text, TextInput, TouchableOpacity, SectionList, StyleSheet, SafeAreaView, KeyboardAvoidingView, Platform, Image, ActivityIndicator, Alert, Linking, Dimensions, Modal } from 'react-native';
5
+ import { View, Text, TextInput, TouchableOpacity, SectionList, StyleSheet, SafeAreaView, KeyboardAvoidingView, Platform, StatusBar, Image, ActivityIndicator, Alert, Linking, Dimensions, Modal } from 'react-native';
6
6
  import io from 'socket.io-client';
7
- import { pick, types } from '@react-native-documents/picker';
7
+ import { errorCodes, pick, saveDocuments, types } from '@react-native-documents/picker';
8
+ import ReactNativeBlobUtil from 'react-native-blob-util';
9
+ import { launchCamera, launchImageLibrary } from 'react-native-image-picker';
8
10
  import { ConnectXMobileSdk } from 'connect-x-react-native-sdk';
9
- import { apiGet, uploadFile, BASE_URL_LIVE_CHAT, apiPost } from "./api.js";
11
+ import { apiGet, uploadFile, BASE_URL_LIVE_CHAT, apiPost, buildConnectorBody, fileMimeType, sniffImageExt } from "./api.js";
10
12
  import Svg, { ClipPath, Defs, G, Path, Rect } from 'react-native-svg';
11
13
  import hexToRgba from 'hex-to-rgba';
12
14
  import ImageViewer from 'react-native-image-zoom-viewer';
13
15
  import { mergeIncomingMessage, mergeHistory, messageSig } from "./mergeMessages.js";
14
16
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
17
+ // ponytail: ไอคอน SVG ในไฟล์ แทน vector-icons เพราะไม่ต้องให้แอปที่ใช้ SDK link font เพิ่ม
18
+ const ATTACH_ICONS = {
19
+ file: 'M12 5v14M5 12h14',
20
+ camera: 'M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2zM12 17a4 4 0 1 0 0-8 4 4 0 0 0 0 8z',
21
+ gallery: 'M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zM8.5 10a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zM21 15l-5-5L5 21',
22
+ download: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3'
23
+ };
24
+
25
+ // ระยะจากขอบบนของ preview ให้พ้น status bar / notch
26
+ // ponytail: ค่าคงที่ ไม่ใช้ safe-area lib; iOS 60 พอสำหรับ Dynamic Island
27
+ const VIEWER_TOP = Platform.OS === 'ios' ? 60 : (StatusBar.currentHeight ?? 0) + 16;
28
+ const AttachIcon = ({
29
+ name,
30
+ color = '#525E62'
31
+ }) => /*#__PURE__*/_jsx(Svg, {
32
+ width: 22,
33
+ height: 22,
34
+ viewBox: "0 0 24 24",
35
+ fill: "none",
36
+ children: /*#__PURE__*/_jsx(Path, {
37
+ d: ATTACH_ICONS[name],
38
+ stroke: color,
39
+ strokeWidth: 2,
40
+ strokeLinecap: "round",
41
+ strokeLinejoin: "round"
42
+ })
43
+ });
15
44
  const SendIcon = ({
16
45
  size,
17
46
  backgroundColor,
@@ -52,6 +81,7 @@ export const ChatScreen = ({
52
81
  token,
53
82
  orgId,
54
83
  channelId,
84
+ externalValue,
55
85
  customHeader,
56
86
  customer
57
87
  }) => {
@@ -63,6 +93,8 @@ export const ChatScreen = ({
63
93
  const socketRef = useRef(null);
64
94
  const hasConnectedRef = useRef(false);
65
95
  const [userId, setUserId] = useState(null);
96
+ // ponytail: url ที่ <Image> decode ไม่ผ่าน (dng/heic บน Android ฯลฯ) → ตกไปเป็นลิงก์ดาวน์โหลด
97
+ const [undecodableUrls, setUndecodableUrls] = useState({});
66
98
  const [selectedFile, setSelectedFile] = useState(null);
67
99
  const [settings, setSettings] = useState(null);
68
100
  const [styles, setStyles] = useState({});
@@ -72,14 +104,15 @@ export const ChatScreen = ({
72
104
  const [viewerImages, setViewerImages] = useState([]);
73
105
  const [viewerIndex, setViewerIndex] = useState(0);
74
106
  useEffect(() => {
107
+ // ponytail: ยังรับ unknownId ไว้เฉย ๆ ไม่ได้ส่งเข้า body แล้ว แต่ getUnknownId() ฝั่ง native ยังต้องเรียก
75
108
  const findCustomer = async _unknownId => {
76
109
  try {
77
- console.log('customer:', customer);
78
- const res = await apiPost(`liveChat/${orgId}/${channelId}/setLiveChatConnector`, {
79
- customers: {
80
- ...customer
81
- }
82
- }, token);
110
+ const body = buildConnectorBody({
111
+ externalValue,
112
+ customer
113
+ });
114
+ console.log('setLiveChatConnector body:', body);
115
+ const res = await apiPost(`liveChat/${orgId}/${channelId}/setLiveChatConnector`, body, token);
83
116
  console.log('Found customer:', res);
84
117
  if (res) {
85
118
  setUserId(res?.userId);
@@ -337,10 +370,19 @@ export const ChatScreen = ({
337
370
  const handleSelectFile = async () => {
338
371
  try {
339
372
  const result = await pick({
340
- type: [types.images, types.doc, types.pdf],
373
+ // ponytail: allFiles เพราะ iOS ไม่รู้จักนามสกุลแปลก ๆ (.jfif) เลย grey out
374
+ // ถ้าปล่อยเป็น public.image; ชนิดไฟล์คัดตอนส่งด้วย mime อยู่แล้ว
375
+ type: [types.allFiles],
341
376
  allowMultiSelection: false
342
377
  });
343
- setSelectedFile(result[0]);
378
+ const file = result[0];
379
+ if (file) {
380
+ setSelectedFile({
381
+ uri: file.uri,
382
+ name: file.name || 'file',
383
+ type: file.type
384
+ });
385
+ }
344
386
  } catch (err) {
345
387
  if (err?.code !== 'DOCUMENT_PICKER_CANCELED') {
346
388
  // Alert.alert(
@@ -350,6 +392,51 @@ export const ChatScreen = ({
350
392
  }
351
393
  }
352
394
  };
395
+
396
+ // อ่านแค่ 12 byte แรกของไฟล์ ไว้เช็คชนิดจริง
397
+ const readFileHead = async uri => {
398
+ try {
399
+ const head = `${ReactNativeBlobUtil.fs.dirs.CacheDir}/head_${Date.now()}`;
400
+ await ReactNativeBlobUtil.fs.slice(uri.replace('file://', ''), head, 0, 12);
401
+ const bytes = await ReactNativeBlobUtil.fs.readFile(head, 'ascii');
402
+ ReactNativeBlobUtil.fs.unlink(head).catch(() => {});
403
+ return bytes;
404
+ } catch {
405
+ return [];
406
+ }
407
+ };
408
+ const handleImagePicked = async res => {
409
+ if (res.didCancel) return;
410
+ const asset = res.assets?.[0];
411
+ if (res.errorCode || !asset?.uri) {
412
+ if (res.errorCode === 'permission') {
413
+ Alert.alert('Permission required', 'Please allow access in Settings.');
414
+ }
415
+ return;
416
+ }
417
+ let name = asset.fileName || `image_${Date.now()}.jpg`;
418
+ let type = asset.type || 'image/jpeg';
419
+ const realExt = sniffImageExt(await readFileHead(asset.uri));
420
+ if (realExt) {
421
+ name = `${name.replace(/\.[^.]+$/, '')}.${realExt}`;
422
+ type = fileMimeType({
423
+ name
424
+ });
425
+ }
426
+ setSelectedFile({
427
+ uri: asset.uri,
428
+ name,
429
+ type
430
+ });
431
+ };
432
+ const handleOpenCamera = () => launchCamera({
433
+ mediaType: 'photo',
434
+ saveToPhotos: false
435
+ }).then(handleImagePicked);
436
+ const handleOpenGallery = () => launchImageLibrary({
437
+ mediaType: 'photo',
438
+ selectionLimit: 1
439
+ }).then(handleImagePicked);
353
440
  const MessageWithLinks = ({
354
441
  text,
355
442
  textStyle,
@@ -373,8 +460,9 @@ export const ChatScreen = ({
373
460
  })
374
461
  });
375
462
  };
463
+ const isDisplayableImage = item => item.type === 'image' && !!item.url && !undecodableUrls[item.url];
376
464
  const openImageViewer = tappedImageUrl => {
377
- const allImages = messages.filter(msg => msg.type === 'image' && msg.url).map(msg => ({
465
+ const allImages = messages.filter(isDisplayableImage).map(msg => ({
378
466
  url: msg.url
379
467
  }));
380
468
  const tappedIndex = allImages.findIndex(img => img.url === tappedImageUrl);
@@ -384,6 +472,30 @@ export const ChatScreen = ({
384
472
  setImageViewerVisible(true);
385
473
  }
386
474
  };
475
+
476
+ // โหลดรูปลง cache ก่อน แล้วเปิดหน้า "Save to Files" (iOS) / "บันทึกเป็น" (Android)
477
+ // เพราะ saveDocuments รับแค่ไฟล์ในเครื่อง
478
+ const handleDownloadImage = async url => {
479
+ try {
480
+ // ใช้ชื่อไฟล์ตอนส่ง (เช่น .dng / .jfif) ก่อน ชื่อใน url อาจถูก storage เปลี่ยนนามสกุล
481
+ const name = messages.find(m => m.url === url)?.fileName || decodeURIComponent(url.split('?')[0].split('/').pop() || 'image');
482
+ const res = await ReactNativeBlobUtil.config({
483
+ path: `${ReactNativeBlobUtil.fs.dirs.CacheDir}/${name}`
484
+ }).fetch('GET', url);
485
+ await saveDocuments({
486
+ sourceUris: [`file://${res.path()}`],
487
+ fileName: name,
488
+ mimeType: fileMimeType({
489
+ name
490
+ })
491
+ });
492
+ } catch (err) {
493
+ if (err?.code !== errorCodes.OPERATION_CANCELED) {
494
+ Alert.alert('Download Error', 'Failed to save the image.');
495
+ console.error('Download failed', err);
496
+ }
497
+ }
498
+ };
387
499
  const handleCancelUpload = () => {
388
500
  if (isUploading) return;
389
501
  setSelectedFile(null);
@@ -393,7 +505,7 @@ export const ChatScreen = ({
393
505
  setIsUploading(true);
394
506
  try {
395
507
  const fileUrl = await uploadFile(selectedFile, orgId, channelId);
396
- const type = selectedFile.type?.startsWith('image/') ? 'image' : 'file';
508
+ const type = fileMimeType(selectedFile).startsWith('image/') ? 'image' : 'file';
397
509
  const messageBody = {
398
510
  type,
399
511
  fileName: selectedFile.name || 'file',
@@ -461,16 +573,20 @@ export const ChatScreen = ({
461
573
  text: item.text || '',
462
574
  textStyle: item.sender === 'user' ? styles.customerChatText : styles.agentChatText,
463
575
  linkStyle: styles.linkTextStyle
464
- }), item.type === 'image' && /*#__PURE__*/_jsx(TouchableOpacity, {
576
+ }), isDisplayableImage(item) && /*#__PURE__*/_jsx(TouchableOpacity, {
465
577
  onPress: () => openImageViewer(item.url),
466
578
  children: /*#__PURE__*/_jsx(Image, {
467
579
  source: {
468
580
  uri: item.url
469
581
  },
470
582
  style: styles.imageMessage,
471
- resizeMode: "cover"
583
+ resizeMode: "cover",
584
+ onError: () => setUndecodableUrls(prev => ({
585
+ ...prev,
586
+ [item.url]: true
587
+ }))
472
588
  })
473
- }), item.type === 'file' && /*#__PURE__*/_jsx(TouchableOpacity, {
589
+ }), (item.type === 'file' || item.type === 'image' && !isDisplayableImage(item)) && /*#__PURE__*/_jsx(TouchableOpacity, {
474
590
  onPress: handleFilePress,
475
591
  children: /*#__PURE__*/_jsx(Text, {
476
592
  style: [styles.fileLink, item.sender === 'user' ? styles.customerChatText : styles.agentChatText],
@@ -510,7 +626,9 @@ export const ChatScreen = ({
510
626
  renderIndicator: (currentIndex, allSize) => /*#__PURE__*/_jsx(View, {
511
627
  style: {
512
628
  position: 'absolute',
513
- top: 45,
629
+ top: VIEWER_TOP,
630
+ height: 42,
631
+ justifyContent: 'center',
514
632
  width: '100%',
515
633
  alignItems: 'center',
516
634
  zIndex: 1
@@ -524,30 +642,41 @@ export const ChatScreen = ({
524
642
  children: [currentIndex, " / ", allSize]
525
643
  })
526
644
  }),
527
- renderHeader: () => /*#__PURE__*/_jsx(TouchableOpacity
528
- // FIX 1: Add dynamic top spacing and zIndex
529
- , {
530
- style: [styles.imageViewerCloseButton, {
531
- top: 30,
532
- zIndex: 9999,
533
- // Force it to be the top-most layer
534
- position: 'absolute',
535
- // Ensure it floats correctly
536
- right: 20
537
- }]
538
- // FIX 2: Add hitSlop so users don't have to hit the "X" pixel-perfectly
539
- ,
540
- hitSlop: {
541
- top: 30,
542
- bottom: 30,
543
- left: 30,
544
- right: 30
545
- },
546
- onPress: () => setImageViewerVisible(false),
547
- children: /*#__PURE__*/_jsx(Text, {
548
- style: styles.imageViewerCloseButtonText,
549
- children: "\u2715"
550
- })
645
+ renderHeader: currentIndex => /*#__PURE__*/_jsxs(View, {
646
+ pointerEvents: "box-none",
647
+ style: [styles.imageViewerHeader, {
648
+ top: VIEWER_TOP
649
+ }],
650
+ children: [/*#__PURE__*/_jsx(TouchableOpacity, {
651
+ style: styles.imageViewerCloseButton,
652
+ hitSlop: {
653
+ top: 20,
654
+ bottom: 20,
655
+ left: 20,
656
+ right: 20
657
+ },
658
+ onPress: () => {
659
+ const url = viewerImages[currentIndex ?? 0]?.url;
660
+ if (url) handleDownloadImage(url);
661
+ },
662
+ children: /*#__PURE__*/_jsx(AttachIcon, {
663
+ name: "download",
664
+ color: "white"
665
+ })
666
+ }), /*#__PURE__*/_jsx(TouchableOpacity, {
667
+ style: styles.imageViewerCloseButton,
668
+ hitSlop: {
669
+ top: 20,
670
+ bottom: 20,
671
+ left: 20,
672
+ right: 20
673
+ },
674
+ onPress: () => setImageViewerVisible(false),
675
+ children: /*#__PURE__*/_jsx(Text, {
676
+ style: styles.imageViewerCloseButtonText,
677
+ children: "\u2715"
678
+ })
679
+ })]
551
680
  })
552
681
  })
553
682
  }), customHeader !== undefined ? customHeader : /*#__PURE__*/_jsxs(View, {
@@ -648,13 +777,23 @@ export const ChatScreen = ({
648
777
  children: [/*#__PURE__*/_jsx(TouchableOpacity, {
649
778
  onPress: handleSelectFile,
650
779
  style: styles.attachmentButton,
651
- children: /*#__PURE__*/_jsxs(View, {
652
- style: styles.plusIconContainer,
653
- children: [/*#__PURE__*/_jsx(View, {
654
- style: [styles.plusIconBar, styles.plusIconBarHorizontal]
655
- }), /*#__PURE__*/_jsx(View, {
656
- style: [styles.plusIconBar, styles.plusIconBarVertical]
657
- })]
780
+ accessibilityLabel: "Attach file",
781
+ children: /*#__PURE__*/_jsx(AttachIcon, {
782
+ name: "file"
783
+ })
784
+ }), /*#__PURE__*/_jsx(TouchableOpacity, {
785
+ onPress: handleOpenCamera,
786
+ style: styles.attachmentButton,
787
+ accessibilityLabel: "Take photo",
788
+ children: /*#__PURE__*/_jsx(AttachIcon, {
789
+ name: "camera"
790
+ })
791
+ }), /*#__PURE__*/_jsx(TouchableOpacity, {
792
+ onPress: handleOpenGallery,
793
+ style: styles.attachmentButton,
794
+ accessibilityLabel: "Choose from gallery",
795
+ children: /*#__PURE__*/_jsx(AttachIcon, {
796
+ name: "gallery"
658
797
  })
659
798
  }), /*#__PURE__*/_jsx(TextInput, {
660
799
  style: styles.input,
@@ -846,13 +985,8 @@ const getDynamicStyles = (settingStyle = {}) => {
846
985
  attachmentButton: {
847
986
  padding: 5
848
987
  },
988
+ // ponytail: วงกลม+สีพื้นอยู่ใน SendIcon (SVG) แล้ว ตรงนี้เลยไม่ต้องมีอีกวง
849
989
  sendButton: {
850
- paddingRight: 3,
851
- paddingTop: 2,
852
- width: 42,
853
- height: 42,
854
- borderRadius: 24,
855
- backgroundColor: settingStyle?.styleHeadColor || '#3761DF',
856
990
  justifyContent: 'center',
857
991
  alignItems: 'center'
858
992
  },
@@ -910,26 +1044,6 @@ const getDynamicStyles = (settingStyle = {}) => {
910
1044
  fontWeight: 'bold',
911
1045
  fontSize: 16
912
1046
  },
913
- plusIconContainer: {
914
- width: 10,
915
- height: 18,
916
- justifyContent: 'center',
917
- alignItems: 'center'
918
- },
919
- plusIconBar: {
920
- position: 'absolute',
921
- backgroundColor: '#525E62'
922
- },
923
- plusIconBarHorizontal: {
924
- width: 18,
925
- height: 2,
926
- borderRadius: 1
927
- },
928
- plusIconBarVertical: {
929
- width: 2,
930
- height: 18,
931
- borderRadius: 1
932
- },
933
1047
  imageMessage: {
934
1048
  width: 200,
935
1049
  height: 200,
@@ -1017,11 +1131,16 @@ const getDynamicStyles = (settingStyle = {}) => {
1017
1131
  fontSize: 12,
1018
1132
  fontWeight: 'bold'
1019
1133
  },
1020
- imageViewerCloseButton: {
1134
+ imageViewerHeader: {
1021
1135
  position: 'absolute',
1022
- top: 20,
1023
- right: 20,
1024
- zIndex: 20,
1136
+ left: 10,
1137
+ right: 10,
1138
+ zIndex: 9999,
1139
+ flexDirection: 'row',
1140
+ justifyContent: 'space-between',
1141
+ alignItems: 'center'
1142
+ },
1143
+ imageViewerCloseButton: {
1025
1144
  padding: 10
1026
1145
  },
1027
1146
  imageViewerCloseButtonText: {