@webority/mobile-support 0.0.13 → 0.2.0
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/lib/commonjs/SupportProvider.js +1 -335
- package/lib/commonjs/client.js +1 -149
- package/lib/commonjs/clipboard.js +1 -10
- package/lib/commonjs/components/Attachments.js +1 -246
- package/lib/commonjs/components/NewSupportTicket.js +1 -210
- package/lib/commonjs/components/PhotoTray.js +1 -141
- package/lib/commonjs/components/SupportContact.js +1 -134
- package/lib/commonjs/components/SupportTicketDetail.js +1 -348
- package/lib/commonjs/components/SupportTicketList.js +1 -279
- package/lib/commonjs/device.js +1 -29
- package/lib/commonjs/files.js +1 -37
- package/lib/commonjs/format.js +1 -29
- package/lib/commonjs/index.js +1 -97
- package/lib/commonjs/photos.js +1 -67
- package/lib/commonjs/report/ReportIssueButton.js +1 -57
- package/lib/commonjs/report/captureAndReport.js +1 -34
- package/lib/commonjs/report/index.js +1 -20
- package/lib/commonjs/share/index.js +1 -44
- package/lib/commonjs/types.js +1 -2
- package/lib/commonjs/validation.js +1 -45
- package/lib/module/SupportProvider.js +1 -323
- package/lib/module/client.js +1 -143
- package/lib/module/clipboard.js +1 -5
- package/lib/module/components/Attachments.js +1 -240
- package/lib/module/components/NewSupportTicket.js +1 -205
- package/lib/module/components/PhotoTray.js +1 -136
- package/lib/module/components/SupportContact.js +1 -129
- package/lib/module/components/SupportTicketDetail.js +1 -343
- package/lib/module/components/SupportTicketList.js +1 -273
- package/lib/module/device.js +1 -24
- package/lib/module/files.js +1 -31
- package/lib/module/format.js +1 -23
- package/lib/module/index.js +1 -10
- package/lib/module/photos.js +1 -61
- package/lib/module/report/ReportIssueButton.js +1 -52
- package/lib/module/report/captureAndReport.js +1 -29
- package/lib/module/report/index.js +1 -12
- package/lib/module/share/index.js +1 -37
- package/lib/module/types.js +1 -2
- package/lib/module/validation.js +1 -38
- package/lib/typescript/commonjs/SupportProvider.d.ts +4 -0
- package/lib/typescript/commonjs/format.d.ts +6 -4
- package/lib/typescript/module/SupportProvider.d.ts +4 -0
- package/lib/typescript/module/format.d.ts +6 -4
- package/package.json +8 -6
|
@@ -1,273 +1 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
import { Button, Card, Chip, EmptyState, SearchBar, Spinner, StatusBadge, Text, useTheme } from '@webority/mobile-ui';
|
|
4
|
-
import { useMemo } from 'react';
|
|
5
|
-
import { FlatList, RefreshControl, ScrollView, StyleSheet, View } from 'react-native';
|
|
6
|
-
import { formatWhen } from "../format.js";
|
|
7
|
-
import { useSupportTickets } from "../SupportProvider.js";
|
|
8
|
-
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
9
|
-
export const STATUS_TONES = {
|
|
10
|
-
Open: 'warning',
|
|
11
|
-
InProgress: 'info',
|
|
12
|
-
Resolved: 'success',
|
|
13
|
-
Closed: 'neutral'
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
// The counts endpoint carries numbers only, so the chip wording lives here. It
|
|
17
|
-
// matches the server's own labels (Webority.Support SupportDisplay).
|
|
18
|
-
const STATUS_FILTERS = [{
|
|
19
|
-
value: 'Open',
|
|
20
|
-
label: 'Open',
|
|
21
|
-
count: 'open'
|
|
22
|
-
}, {
|
|
23
|
-
value: 'InProgress',
|
|
24
|
-
label: 'In progress',
|
|
25
|
-
count: 'inProgress'
|
|
26
|
-
}, {
|
|
27
|
-
value: 'Resolved',
|
|
28
|
-
label: 'Resolved',
|
|
29
|
-
count: 'resolved'
|
|
30
|
-
}, {
|
|
31
|
-
value: 'Closed',
|
|
32
|
-
label: 'Closed',
|
|
33
|
-
count: 'closed'
|
|
34
|
-
}];
|
|
35
|
-
const ALL = 'all';
|
|
36
|
-
/**
|
|
37
|
-
* All, then each status that has tickets. The selected status stays even at
|
|
38
|
-
* zero, so the chip the person is on never vanishes under them.
|
|
39
|
-
*/
|
|
40
|
-
export const statusFilterOptions = (counts, selected) => [{
|
|
41
|
-
value: ALL,
|
|
42
|
-
label: 'All',
|
|
43
|
-
count: counts.total
|
|
44
|
-
}, ...STATUS_FILTERS.filter(s => counts[s.count] > 0 || s.value === selected).map(s => ({
|
|
45
|
-
value: s.value,
|
|
46
|
-
label: s.label,
|
|
47
|
-
count: counts[s.count]
|
|
48
|
-
}))];
|
|
49
|
-
const SEARCH_DEBOUNCE_MS = 400;
|
|
50
|
-
export const SupportTicketList = ({
|
|
51
|
-
onOpenTicket,
|
|
52
|
-
onNewTicket,
|
|
53
|
-
header,
|
|
54
|
-
pageSize,
|
|
55
|
-
testID = 'support-ticket-list'
|
|
56
|
-
}) => {
|
|
57
|
-
const theme = useTheme();
|
|
58
|
-
const styles = useMemo(() => buildStyles(theme), [theme]);
|
|
59
|
-
const {
|
|
60
|
-
tickets,
|
|
61
|
-
counts,
|
|
62
|
-
status,
|
|
63
|
-
search,
|
|
64
|
-
setStatus,
|
|
65
|
-
setSearch,
|
|
66
|
-
loading,
|
|
67
|
-
refreshing,
|
|
68
|
-
loadingMore,
|
|
69
|
-
error,
|
|
70
|
-
loadMoreError,
|
|
71
|
-
refresh,
|
|
72
|
-
loadMore
|
|
73
|
-
} = useSupportTickets({
|
|
74
|
-
pageSize
|
|
75
|
-
});
|
|
76
|
-
const hasAnyTicket = (counts?.total ?? 0) > 0;
|
|
77
|
-
const filtered = status !== null || search.trim() !== '';
|
|
78
|
-
const listHeader = /*#__PURE__*/_jsxs(View, {
|
|
79
|
-
style: styles.header,
|
|
80
|
-
children: [header, onNewTicket && /*#__PURE__*/_jsx(Button, {
|
|
81
|
-
title: "New ticket",
|
|
82
|
-
leftIcon: "add",
|
|
83
|
-
onPress: onNewTicket,
|
|
84
|
-
fullWidth: true
|
|
85
|
-
}), error && /*#__PURE__*/_jsxs(View, {
|
|
86
|
-
style: styles.error,
|
|
87
|
-
accessibilityLiveRegion: "polite",
|
|
88
|
-
children: [/*#__PURE__*/_jsx(Text, {
|
|
89
|
-
variant: "bodySmall",
|
|
90
|
-
color: "error",
|
|
91
|
-
children: error.message
|
|
92
|
-
}), /*#__PURE__*/_jsx(Button, {
|
|
93
|
-
title: "Try again",
|
|
94
|
-
size: "sm",
|
|
95
|
-
variant: "ghost",
|
|
96
|
-
onPress: () => void refresh()
|
|
97
|
-
})]
|
|
98
|
-
}), counts && hasAnyTicket && /*#__PURE__*/_jsxs(_Fragment, {
|
|
99
|
-
children: [/*#__PURE__*/_jsx(SearchBar, {
|
|
100
|
-
value: search,
|
|
101
|
-
onChangeText: setSearch,
|
|
102
|
-
debounceMs: SEARCH_DEBOUNCE_MS,
|
|
103
|
-
placeholder: "Search by subject or ticket number",
|
|
104
|
-
accessibilityLabel: "Search tickets",
|
|
105
|
-
testID: "support-ticket-search"
|
|
106
|
-
}), /*#__PURE__*/_jsx(ScrollView, {
|
|
107
|
-
horizontal: true,
|
|
108
|
-
showsHorizontalScrollIndicator: false,
|
|
109
|
-
contentContainerStyle: styles.filters,
|
|
110
|
-
accessibilityRole: "tablist",
|
|
111
|
-
accessibilityLabel: "Filter by status",
|
|
112
|
-
children: statusFilterOptions(counts, status).map(o => /*#__PURE__*/_jsx(Chip, {
|
|
113
|
-
label: `${o.label} (${o.count})`,
|
|
114
|
-
selected: (status ?? ALL) === o.value,
|
|
115
|
-
onPress: () => setStatus(o.value === ALL ? null : o.value),
|
|
116
|
-
accessibilityLabel: `${o.label}, ${o.count} tickets`,
|
|
117
|
-
testID: `support-filter-${o.value}`
|
|
118
|
-
}, o.value))
|
|
119
|
-
})]
|
|
120
|
-
})]
|
|
121
|
-
});
|
|
122
|
-
if (loading && counts === null && !error) {
|
|
123
|
-
return /*#__PURE__*/_jsx(View, {
|
|
124
|
-
style: styles.center,
|
|
125
|
-
testID: testID,
|
|
126
|
-
children: /*#__PURE__*/_jsx(Spinner, {
|
|
127
|
-
accessibilityLabel: "Loading your tickets"
|
|
128
|
-
})
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
const empty = () => {
|
|
132
|
-
if (loading) {
|
|
133
|
-
return /*#__PURE__*/_jsx(View, {
|
|
134
|
-
style: styles.footer,
|
|
135
|
-
children: /*#__PURE__*/_jsx(Spinner, {
|
|
136
|
-
accessibilityLabel: "Loading your tickets"
|
|
137
|
-
})
|
|
138
|
-
});
|
|
139
|
-
}
|
|
140
|
-
if (error) {
|
|
141
|
-
return null;
|
|
142
|
-
}
|
|
143
|
-
if (!hasAnyTicket) {
|
|
144
|
-
return /*#__PURE__*/_jsx(EmptyState, {
|
|
145
|
-
title: "No tickets yet",
|
|
146
|
-
description: "Raise a ticket and it will show here with its status.",
|
|
147
|
-
action: onNewTicket ? {
|
|
148
|
-
label: 'New ticket',
|
|
149
|
-
onPress: onNewTicket
|
|
150
|
-
} : undefined
|
|
151
|
-
});
|
|
152
|
-
}
|
|
153
|
-
return /*#__PURE__*/_jsx(EmptyState, {
|
|
154
|
-
title: "No tickets match",
|
|
155
|
-
description: filtered ? 'Try another status or search.' : undefined
|
|
156
|
-
});
|
|
157
|
-
};
|
|
158
|
-
const footer = loadingMore ? /*#__PURE__*/_jsx(View, {
|
|
159
|
-
style: styles.footer,
|
|
160
|
-
children: /*#__PURE__*/_jsx(Spinner, {
|
|
161
|
-
size: "sm",
|
|
162
|
-
accessibilityLabel: "Loading more tickets"
|
|
163
|
-
})
|
|
164
|
-
}) : loadMoreError ? /*#__PURE__*/_jsxs(View, {
|
|
165
|
-
style: [styles.footer, styles.error],
|
|
166
|
-
accessibilityLiveRegion: "polite",
|
|
167
|
-
children: [/*#__PURE__*/_jsx(Text, {
|
|
168
|
-
variant: "bodySmall",
|
|
169
|
-
color: "error",
|
|
170
|
-
children: loadMoreError.message
|
|
171
|
-
}), /*#__PURE__*/_jsx(Button, {
|
|
172
|
-
title: "Load more",
|
|
173
|
-
size: "sm",
|
|
174
|
-
variant: "ghost",
|
|
175
|
-
onPress: () => void loadMore()
|
|
176
|
-
})]
|
|
177
|
-
}) : null;
|
|
178
|
-
return /*#__PURE__*/_jsx(FlatList, {
|
|
179
|
-
testID: testID,
|
|
180
|
-
data: tickets,
|
|
181
|
-
keyExtractor: t => t.id,
|
|
182
|
-
contentContainerStyle: styles.content,
|
|
183
|
-
ListHeaderComponent: listHeader,
|
|
184
|
-
ListFooterComponent: footer,
|
|
185
|
-
onEndReached: () => void loadMore(),
|
|
186
|
-
onEndReachedThreshold: 0.5,
|
|
187
|
-
refreshControl: /*#__PURE__*/_jsx(RefreshControl, {
|
|
188
|
-
refreshing: refreshing,
|
|
189
|
-
onRefresh: () => void refresh()
|
|
190
|
-
}),
|
|
191
|
-
ListEmptyComponent: empty(),
|
|
192
|
-
renderItem: ({
|
|
193
|
-
item
|
|
194
|
-
}) => /*#__PURE__*/_jsxs(Card, {
|
|
195
|
-
onPress: () => onOpenTicket(item),
|
|
196
|
-
accessibilityLabel: `${item.subject}, ${item.statusLabel}, ticket ${item.number}`,
|
|
197
|
-
bodyStyle: styles.card,
|
|
198
|
-
testID: `support-ticket-${item.id}`,
|
|
199
|
-
children: [/*#__PURE__*/_jsxs(View, {
|
|
200
|
-
style: styles.cardTop,
|
|
201
|
-
children: [/*#__PURE__*/_jsx(Text, {
|
|
202
|
-
variant: "caption",
|
|
203
|
-
color: "tertiary",
|
|
204
|
-
children: item.number
|
|
205
|
-
}), /*#__PURE__*/_jsx(StatusBadge, {
|
|
206
|
-
label: item.statusLabel,
|
|
207
|
-
tone: STATUS_TONES[item.status] ?? 'neutral',
|
|
208
|
-
size: "sm"
|
|
209
|
-
})]
|
|
210
|
-
}), /*#__PURE__*/_jsx(Text, {
|
|
211
|
-
variant: "title",
|
|
212
|
-
numberOfLines: 2,
|
|
213
|
-
children: item.subject
|
|
214
|
-
}), /*#__PURE__*/_jsx(Text, {
|
|
215
|
-
variant: "bodySmall",
|
|
216
|
-
color: "secondary",
|
|
217
|
-
children: item.category
|
|
218
|
-
}), /*#__PURE__*/_jsxs(View, {
|
|
219
|
-
style: styles.cardBottom,
|
|
220
|
-
children: [/*#__PURE__*/_jsx(Text, {
|
|
221
|
-
variant: "caption",
|
|
222
|
-
color: "tertiary",
|
|
223
|
-
children: `Updated ${formatWhen(item.updatedDateTimeUtc)}`
|
|
224
|
-
}), item.awaitingCustomer && /*#__PURE__*/_jsx(Text, {
|
|
225
|
-
variant: "caption",
|
|
226
|
-
color: "warning",
|
|
227
|
-
children: "Waiting for your reply"
|
|
228
|
-
})]
|
|
229
|
-
})]
|
|
230
|
-
})
|
|
231
|
-
});
|
|
232
|
-
};
|
|
233
|
-
const buildStyles = theme => StyleSheet.create({
|
|
234
|
-
content: {
|
|
235
|
-
padding: theme.spacing.md,
|
|
236
|
-
gap: theme.spacing.sm
|
|
237
|
-
},
|
|
238
|
-
header: {
|
|
239
|
-
gap: theme.spacing.sm,
|
|
240
|
-
marginBottom: theme.spacing.xs
|
|
241
|
-
},
|
|
242
|
-
filters: {
|
|
243
|
-
gap: theme.spacing.xs
|
|
244
|
-
},
|
|
245
|
-
error: {
|
|
246
|
-
gap: theme.spacing.xs,
|
|
247
|
-
alignItems: 'flex-start'
|
|
248
|
-
},
|
|
249
|
-
center: {
|
|
250
|
-
flex: 1,
|
|
251
|
-
alignItems: 'center',
|
|
252
|
-
justifyContent: 'center',
|
|
253
|
-
padding: theme.spacing.xl
|
|
254
|
-
},
|
|
255
|
-
footer: {
|
|
256
|
-
paddingVertical: theme.spacing.md,
|
|
257
|
-
alignItems: 'center'
|
|
258
|
-
},
|
|
259
|
-
card: {
|
|
260
|
-
gap: theme.spacing.xxs
|
|
261
|
-
},
|
|
262
|
-
cardTop: {
|
|
263
|
-
flexDirection: 'row',
|
|
264
|
-
justifyContent: 'space-between',
|
|
265
|
-
alignItems: 'center'
|
|
266
|
-
},
|
|
267
|
-
cardBottom: {
|
|
268
|
-
flexDirection: 'row',
|
|
269
|
-
justifyContent: 'space-between',
|
|
270
|
-
marginTop: theme.spacing.xxs
|
|
271
|
-
}
|
|
272
|
-
});
|
|
273
|
-
//# sourceMappingURL=SupportTicketList.js.map
|
|
1
|
+
"use strict";import{Button,Card,Chip,EmptyState,SearchBar,Spinner,StatusBadge,Text,useTheme}from"@webority/mobile-ui";import{useMemo}from"react";import{FlatList,RefreshControl,ScrollView,StyleSheet,View}from"react-native";import{formatWhen}from"../format.js";import{useSupportTickets,useSupportTimeZone}from"../SupportProvider.js";import{jsx as _jsx,jsxs as _jsxs,Fragment as _Fragment}from"react/jsx-runtime";export const STATUS_TONES={Open:"warning",InProgress:"info",Resolved:"success",Closed:"neutral"};const STATUS_FILTERS=[{value:"Open",label:"Open",count:"open"},{value:"InProgress",label:"In progress",count:"inProgress"},{value:"Resolved",label:"Resolved",count:"resolved"},{value:"Closed",label:"Closed",count:"closed"}],ALL="all";export const statusFilterOptions=(e,t)=>[{value:ALL,label:"All",count:e.total},...STATUS_FILTERS.filter(s=>e[s.count]>0||s.value===t).map(t=>({value:t.value,label:t.label,count:e[t.count]}))];const SEARCH_DEBOUNCE_MS=400;export const SupportTicketList=({onOpenTicket:e,onNewTicket:t,header:s,pageSize:r,testID:i="support-ticket-list"})=>{const o=useTheme(),a=useMemo(()=>buildStyles(o),[o]),{tickets:n,counts:l,status:c,search:u,setStatus:d,setSearch:p,loading:m,refreshing:h,loadingMore:x,error:g,loadMoreError:b,refresh:S,loadMore:y}=useSupportTickets({pageSize:r}),j=useSupportTimeZone(),_=(l?.total??0)>0,f=null!==c||""!==u.trim(),T=_jsxs(View,{style:a.header,children:[s,t&&_jsx(Button,{title:"New ticket",leftIcon:"add",onPress:t,fullWidth:!0}),g&&_jsxs(View,{style:a.error,accessibilityLiveRegion:"polite",children:[_jsx(Text,{variant:"bodySmall",color:"error",children:g.message}),_jsx(Button,{title:"Try again",size:"sm",variant:"ghost",onPress:()=>{S()}})]}),l&&_&&_jsxs(_Fragment,{children:[_jsx(SearchBar,{value:u,onChangeText:p,debounceMs:400,placeholder:"Search by subject or ticket number",accessibilityLabel:"Search tickets",testID:"support-ticket-search"}),_jsx(ScrollView,{horizontal:!0,showsHorizontalScrollIndicator:!1,contentContainerStyle:a.filters,accessibilityRole:"tablist",accessibilityLabel:"Filter by status",children:statusFilterOptions(l,c).map(e=>_jsx(Chip,{label:`${e.label} (${e.count})`,selected:(c??ALL)===e.value,onPress:()=>d(e.value===ALL?null:e.value),accessibilityLabel:`${e.label}, ${e.count} tickets`,testID:`support-filter-${e.value}`},e.value))})]})]});if(m&&null===l&&!g)return _jsx(View,{style:a.center,testID:i,children:_jsx(Spinner,{accessibilityLabel:"Loading your tickets"})});const v=x?_jsx(View,{style:a.footer,children:_jsx(Spinner,{size:"sm",accessibilityLabel:"Loading more tickets"})}):b?_jsxs(View,{style:[a.footer,a.error],accessibilityLiveRegion:"polite",children:[_jsx(Text,{variant:"bodySmall",color:"error",children:b.message}),_jsx(Button,{title:"Load more",size:"sm",variant:"ghost",onPress:()=>{y()}})]}):null;return _jsx(FlatList,{testID:i,data:n,keyExtractor:e=>e.id,contentContainerStyle:a.content,ListHeaderComponent:T,ListFooterComponent:v,onEndReached:()=>{y()},onEndReachedThreshold:.5,refreshControl:_jsx(RefreshControl,{refreshing:h,onRefresh:()=>{S()}}),ListEmptyComponent:m?_jsx(View,{style:a.footer,children:_jsx(Spinner,{accessibilityLabel:"Loading your tickets"})}):g?null:_jsx(EmptyState,_?{title:"No tickets match",description:f?"Try another status or search.":void 0}:{title:"No tickets yet",description:"Raise a ticket and it will show here with its status.",action:t?{label:"New ticket",onPress:t}:void 0}),renderItem:({item:t})=>_jsxs(Card,{onPress:()=>e(t),accessibilityLabel:`${t.subject}, ${t.statusLabel}, ticket ${t.number}`,bodyStyle:a.card,testID:`support-ticket-${t.id}`,children:[_jsxs(View,{style:a.cardTop,children:[_jsx(Text,{variant:"caption",color:"tertiary",children:t.number}),_jsx(StatusBadge,{label:t.statusLabel,tone:STATUS_TONES[t.status]??"neutral",size:"sm"})]}),_jsx(Text,{variant:"title",numberOfLines:2,children:t.subject}),_jsx(Text,{variant:"bodySmall",color:"secondary",children:t.category}),_jsxs(View,{style:a.cardBottom,children:[_jsx(Text,{variant:"caption",color:"tertiary",children:`Updated ${formatWhen(t.updatedDateTimeUtc,j)}`}),t.awaitingCustomer&&_jsx(Text,{variant:"caption",color:"warning",children:"Waiting for your reply"})]})]})})};const buildStyles=e=>StyleSheet.create({content:{padding:e.spacing.md,gap:e.spacing.sm},header:{gap:e.spacing.sm,marginBottom:e.spacing.xs},filters:{gap:e.spacing.xs},error:{gap:e.spacing.xs,alignItems:"flex-start"},center:{flex:1,alignItems:"center",justifyContent:"center",padding:e.spacing.xl},footer:{paddingVertical:e.spacing.md,alignItems:"center"},card:{gap:e.spacing.xxs},cardTop:{flexDirection:"row",justifyContent:"space-between",alignItems:"center"},cardBottom:{flexDirection:"row",justifyContent:"space-between",marginTop:e.spacing.xxs}});
|
package/lib/module/device.js
CHANGED
|
@@ -1,24 +1 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
import { getDeviceContext } from '@webority/mobile-core';
|
|
4
|
-
import { Platform } from 'react-native';
|
|
5
|
-
const PLATFORM_NAMES = {
|
|
6
|
-
ios: 'iOS',
|
|
7
|
-
android: 'Android'
|
|
8
|
-
};
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* The device line a ticket carries as its user agent: platform, OS version and,
|
|
12
|
-
* when the app registered one with mobile-core's `setDeviceContext`, the model.
|
|
13
|
-
*/
|
|
14
|
-
export const describeDevice = () => {
|
|
15
|
-
const context = getDeviceContext();
|
|
16
|
-
const platform = PLATFORM_NAMES[Platform.OS] ?? Platform.OS;
|
|
17
|
-
const osVersion = context?.osVersion ?? String(Platform.Version ?? '');
|
|
18
|
-
const parts = [`${platform} ${osVersion}`.trim()];
|
|
19
|
-
if (context?.deviceModel) {
|
|
20
|
-
parts.push(context.deviceModel);
|
|
21
|
-
}
|
|
22
|
-
return parts.join('; ');
|
|
23
|
-
};
|
|
24
|
-
//# sourceMappingURL=device.js.map
|
|
1
|
+
"use strict";import{getDeviceContext}from"@webority/mobile-core";import{Platform}from"react-native";const PLATFORM_NAMES={ios:"iOS",android:"Android"};export const describeDevice=()=>{const e=getDeviceContext(),o=[`${PLATFORM_NAMES[Platform.OS]??Platform.OS} ${e?.osVersion??String(Platform.Version??"")}`.trim()];return e?.deviceModel&&o.push(e.deviceModel),o.join("; ")};
|
package/lib/module/files.js
CHANGED
|
@@ -1,31 +1 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
import { File, Paths } from 'expo-file-system';
|
|
4
|
-
const FOLDER = 'support-attachments';
|
|
5
|
-
|
|
6
|
-
/** A server-supplied name reduced to one path segment, so it cannot leave the cache folder. */
|
|
7
|
-
export const safeFileName = name => {
|
|
8
|
-
const last = name.split(/[/\\]/).pop() ?? '';
|
|
9
|
-
const cleaned = Array.from(last).filter(char => (char.codePointAt(0) ?? 0) > 0x1f).join('').trim();
|
|
10
|
-
return cleaned === '' || cleaned === '.' || cleaned === '..' ? 'attachment' : cleaned;
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Writes an attachment to the app's cache and returns its `file://` URI. The id
|
|
15
|
-
* prefix keeps two tickets' `report.pdf` apart. Attachments never change once
|
|
16
|
-
* stored, so a file already on disk is reused rather than fetched again.
|
|
17
|
-
*/
|
|
18
|
-
export const saveAttachmentToCache = async (attachment, loadBytes) => {
|
|
19
|
-
const file = new File(Paths.cache, FOLDER, `${attachment.id}-${safeFileName(attachment.fileName)}`);
|
|
20
|
-
if (file.exists && file.size > 0) {
|
|
21
|
-
return file.uri;
|
|
22
|
-
}
|
|
23
|
-
const bytes = await loadBytes();
|
|
24
|
-
file.create({
|
|
25
|
-
intermediates: true,
|
|
26
|
-
overwrite: true
|
|
27
|
-
});
|
|
28
|
-
file.write(new Uint8Array(bytes));
|
|
29
|
-
return file.uri;
|
|
30
|
-
};
|
|
31
|
-
//# sourceMappingURL=files.js.map
|
|
1
|
+
"use strict";import{File,Paths}from"expo-file-system";const FOLDER="support-attachments";export const safeFileName=t=>{const e=t.split(/[/\\]/).pop()??"",r=Array.from(e).filter(t=>(t.codePointAt(0)??0)>31).join("").trim();return""===r||"."===r||".."===r?"attachment":r};export const saveAttachmentToCache=async(t,e)=>{const r=new File(Paths.cache,FOLDER,`${t.id}-${safeFileName(t.fileName)}`);if(r.exists&&r.size>0)return r.uri;const i=await e();return r.create({intermediates:!0,overwrite:!0}),r.write(new Uint8Array(i)),r.uri};
|
package/lib/module/format.js
CHANGED
|
@@ -1,23 +1 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
import { formatDateTime } from '@webority/mobile-core';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* A timestamp in the device's own time zone. The backend sends UTC; mobile-core's
|
|
7
|
-
* formatter goes through Intl without pinning a zone, so the reader sees their
|
|
8
|
-
* own clock.
|
|
9
|
-
*/
|
|
10
|
-
export const formatWhen = utc => utc ? formatDateTime(utc) || '-' : '-';
|
|
11
|
-
|
|
12
|
-
/** A byte count as a person reads it. */
|
|
13
|
-
export const formatBytes = bytes => {
|
|
14
|
-
if (bytes >= 1024 * 1024) {
|
|
15
|
-
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
16
|
-
}
|
|
17
|
-
if (bytes >= 1024) {
|
|
18
|
-
return `${Math.round(bytes / 1024)} KB`;
|
|
19
|
-
}
|
|
20
|
-
return `${bytes} bytes`;
|
|
21
|
-
};
|
|
22
|
-
export const isImage = contentType => contentType.startsWith('image/');
|
|
23
|
-
//# sourceMappingURL=format.js.map
|
|
1
|
+
"use strict";export const DEFAULT_TIME_ZONE="Asia/Kolkata";export const formatWhen=(t,e="Asia/Kolkata")=>{if(!t)return"-";const o=new Date(t);if(Number.isNaN(o.getTime()))return"-";const r={};for(const t of new Intl.DateTimeFormat("en-US",{timeZone:e,day:"2-digit",month:"short",year:"numeric",hour:"numeric",minute:"2-digit",hour12:!0}).formatToParts(o))r[t.type]=t.value;return`${r.day}-${r.month}-${r.year}, ${r.hour}:${r.minute} ${(r.dayPeriod??"").toUpperCase()}`};export const formatBytes=t=>t>=1048576?`${(t/1048576).toFixed(1)} MB`:t>=1024?`${Math.round(t/1024)} KB`:`${t} bytes`;export const isImage=t=>t.startsWith("image/");
|
package/lib/module/index.js
CHANGED
|
@@ -1,10 +1 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
export { createSupportClient, SupportApiError, toSupportApiError } from "./client.js";
|
|
4
|
-
export { NewSupportTicket } from "./components/NewSupportTicket.js";
|
|
5
|
-
export { SupportContact } from "./components/SupportContact.js";
|
|
6
|
-
export { SupportTicketDetail } from "./components/SupportTicketDetail.js";
|
|
7
|
-
export { SupportTicketList } from "./components/SupportTicketList.js";
|
|
8
|
-
export { describeDevice } from "./device.js";
|
|
9
|
-
export { SupportProvider, useCreateSupportTicket, useSupportClient, useSupportConfig, useSupportTicket, useSupportTickets } from "./SupportProvider.js";
|
|
10
|
-
//# sourceMappingURL=index.js.map
|
|
1
|
+
"use strict";export{createSupportClient,SupportApiError,toSupportApiError}from"./client.js";export{NewSupportTicket}from"./components/NewSupportTicket.js";export{SupportContact}from"./components/SupportContact.js";export{SupportTicketDetail}from"./components/SupportTicketDetail.js";export{SupportTicketList}from"./components/SupportTicketList.js";export{describeDevice}from"./device.js";export{SupportProvider,useCreateSupportTicket,useSupportClient,useSupportConfig,useSupportTicket,useSupportTickets}from"./SupportProvider.js";
|
package/lib/module/photos.js
CHANGED
|
@@ -1,61 +1 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
import { compressImage } from '@webority/mobile-core/compressor';
|
|
4
|
-
/** The one place `expo-image-picker` is named. */
|
|
5
|
-
const picker = () => require('expo-image-picker');
|
|
6
|
-
export class PhotoPermissionError extends Error {
|
|
7
|
-
constructor(source) {
|
|
8
|
-
super(source === 'camera' ? 'Allow camera access in Settings to take a photo.' : 'Allow photo access in Settings to attach a photo.');
|
|
9
|
-
this.name = 'PhotoPermissionError';
|
|
10
|
-
this.source = source;
|
|
11
|
-
}
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
// The fleet capture pass: quality 0.8, no editing, then bound to 1920 px.
|
|
15
|
-
const CAPTURE_OPTIONS = {
|
|
16
|
-
mediaTypes: ['images'],
|
|
17
|
-
quality: 0.8,
|
|
18
|
-
allowsEditing: false
|
|
19
|
-
};
|
|
20
|
-
const MAX_SIDE = 1920;
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Lets the person pick or take photos and returns them ready to upload,
|
|
24
|
-
* compressed to fit the product's per-file limit. An empty list means they
|
|
25
|
-
* cancelled.
|
|
26
|
-
*/
|
|
27
|
-
export const pickPhotos = async (source, options) => {
|
|
28
|
-
if (options.maxCount <= 0) {
|
|
29
|
-
return [];
|
|
30
|
-
}
|
|
31
|
-
const m = picker();
|
|
32
|
-
const permission = source === 'camera' ? await m.requestCameraPermissionsAsync() : await m.requestMediaLibraryPermissionsAsync();
|
|
33
|
-
if (!permission.granted) {
|
|
34
|
-
throw new PhotoPermissionError(source);
|
|
35
|
-
}
|
|
36
|
-
const result = source === 'camera' ? await m.launchCameraAsync(CAPTURE_OPTIONS) : await m.launchImageLibraryAsync({
|
|
37
|
-
...CAPTURE_OPTIONS,
|
|
38
|
-
allowsMultipleSelection: options.maxCount > 1,
|
|
39
|
-
selectionLimit: options.maxCount
|
|
40
|
-
});
|
|
41
|
-
if (result.canceled || !result.assets) {
|
|
42
|
-
return [];
|
|
43
|
-
}
|
|
44
|
-
const parts = [];
|
|
45
|
-
for (const asset of result.assets.slice(0, options.maxCount)) {
|
|
46
|
-
const compressed = await compressImage(asset.uri, {
|
|
47
|
-
maxSize: MAX_SIDE,
|
|
48
|
-
maxBytes: options.maxBytes,
|
|
49
|
-
fileSize: asset.fileSize ?? undefined,
|
|
50
|
-
name: asset.fileName ?? `photo-${Date.now()}-${parts.length + 1}`
|
|
51
|
-
});
|
|
52
|
-
parts.push({
|
|
53
|
-
uri: compressed.part.uri,
|
|
54
|
-
name: compressed.part.name,
|
|
55
|
-
type: compressed.part.type,
|
|
56
|
-
size: compressed.size ?? undefined
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
return parts;
|
|
60
|
-
};
|
|
61
|
-
//# sourceMappingURL=photos.js.map
|
|
1
|
+
"use strict";import{compressImage}from"@webority/mobile-core/compressor";const picker=()=>require("expo-image-picker");export class PhotoPermissionError extends Error{constructor(e){super("camera"===e?"Allow camera access in Settings to take a photo.":"Allow photo access in Settings to attach a photo."),this.name="PhotoPermissionError",this.source=e}}const CAPTURE_OPTIONS={mediaTypes:["images"],quality:.8,allowsEditing:!1},MAX_SIDE=1920;export const pickPhotos=async(e,t)=>{if(t.maxCount<=0)return[];const s=picker();if(!("camera"===e?await s.requestCameraPermissionsAsync():await s.requestMediaLibraryPermissionsAsync()).granted)throw new PhotoPermissionError(e);const o="camera"===e?await s.launchCameraAsync(CAPTURE_OPTIONS):await s.launchImageLibraryAsync({...CAPTURE_OPTIONS,allowsMultipleSelection:t.maxCount>1,selectionLimit:t.maxCount});if(o.canceled||!o.assets)return[];const a=[];for(const e of o.assets.slice(0,t.maxCount)){const s=await compressImage(e.uri,{maxSize:1920,maxBytes:t.maxBytes,fileSize:e.fileSize??void 0,name:e.fileName??`photo-${Date.now()}-${a.length+1}`});a.push({uri:s.part.uri,name:s.part.name,type:s.part.type,size:s.size??void 0})}return a};
|
|
@@ -1,52 +1 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
import { Button } from '@webority/mobile-ui';
|
|
4
|
-
import { useState } from 'react';
|
|
5
|
-
import { captureAndReport } from "./captureAndReport.js";
|
|
6
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
7
|
-
/** A button that screenshots the current screen and hands a prefilled ticket draft to the app. */
|
|
8
|
-
export const ReportIssueButton = ({
|
|
9
|
-
onReport,
|
|
10
|
-
onCaptureError,
|
|
11
|
-
screenName,
|
|
12
|
-
appVersion,
|
|
13
|
-
category,
|
|
14
|
-
subject,
|
|
15
|
-
title = 'Report an issue',
|
|
16
|
-
accessibilityLabel = 'Report an issue with this screen',
|
|
17
|
-
testID = 'support-report-issue',
|
|
18
|
-
...buttonProps
|
|
19
|
-
}) => {
|
|
20
|
-
const [capturing, setCapturing] = useState(false);
|
|
21
|
-
const press = async () => {
|
|
22
|
-
setCapturing(true);
|
|
23
|
-
try {
|
|
24
|
-
onReport(await captureAndReport({
|
|
25
|
-
screenName,
|
|
26
|
-
appVersion,
|
|
27
|
-
category,
|
|
28
|
-
subject
|
|
29
|
-
}));
|
|
30
|
-
} catch (error) {
|
|
31
|
-
onCaptureError?.(error);
|
|
32
|
-
onReport({
|
|
33
|
-
category,
|
|
34
|
-
subject,
|
|
35
|
-
pageUrl: screenName,
|
|
36
|
-
appVersion
|
|
37
|
-
});
|
|
38
|
-
} finally {
|
|
39
|
-
setCapturing(false);
|
|
40
|
-
}
|
|
41
|
-
};
|
|
42
|
-
return /*#__PURE__*/_jsx(Button, {
|
|
43
|
-
leftIcon: "message-question",
|
|
44
|
-
...buttonProps,
|
|
45
|
-
title: title,
|
|
46
|
-
accessibilityLabel: accessibilityLabel,
|
|
47
|
-
testID: testID,
|
|
48
|
-
loading: capturing,
|
|
49
|
-
onPress: () => void press()
|
|
50
|
-
});
|
|
51
|
-
};
|
|
52
|
-
//# sourceMappingURL=ReportIssueButton.js.map
|
|
1
|
+
"use strict";import{Button}from"@webority/mobile-ui";import{useState}from"react";import{captureAndReport}from"./captureAndReport.js";import{jsx as _jsx}from"react/jsx-runtime";export const ReportIssueButton=({onReport:t,onCaptureError:e,screenName:r,appVersion:s,category:o,subject:a,title:i="Report an issue",accessibilityLabel:n="Report an issue with this screen",testID:p="support-report-issue",...c})=>{const[u,m]=useState(!1);return _jsx(Button,{leftIcon:"message-question",...c,title:i,accessibilityLabel:n,testID:p,loading:u,onPress:()=>{(async()=>{m(!0);try{t(await captureAndReport({screenName:r,appVersion:s,category:o,subject:a}))}catch(i){e?.(i),t({category:o,subject:a,pageUrl:r,appVersion:s})}finally{m(!1)}})()}})};
|
|
@@ -1,29 +1 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
import { describeDevice } from "../device.js";
|
|
4
|
-
/** The one place `react-native-view-shot` is named, and only this subpath reaches it. */
|
|
5
|
-
const viewShot = () => require('react-native-view-shot');
|
|
6
|
-
/**
|
|
7
|
-
* Captures what is on screen right now and returns a draft for NewSupportTicket
|
|
8
|
-
* with the image under `screenshots`, plus the page, device and app version.
|
|
9
|
-
*/
|
|
10
|
-
export const captureAndReport = async options => {
|
|
11
|
-
const uri = await viewShot().captureScreen({
|
|
12
|
-
format: 'jpg',
|
|
13
|
-
quality: 0.8,
|
|
14
|
-
result: 'tmpfile'
|
|
15
|
-
});
|
|
16
|
-
return {
|
|
17
|
-
category: options.category,
|
|
18
|
-
subject: options.subject,
|
|
19
|
-
pageUrl: options.screenName,
|
|
20
|
-
userAgent: describeDevice(),
|
|
21
|
-
appVersion: options.appVersion,
|
|
22
|
-
screenshots: [{
|
|
23
|
-
uri,
|
|
24
|
-
name: `screenshot-${Date.now()}.jpg`,
|
|
25
|
-
type: 'image/jpeg'
|
|
26
|
-
}]
|
|
27
|
-
};
|
|
28
|
-
};
|
|
29
|
-
//# sourceMappingURL=captureAndReport.js.map
|
|
1
|
+
"use strict";import{describeDevice}from"../device.js";const viewShot=()=>require("react-native-view-shot");export const captureAndReport=async e=>{const t=await viewShot().captureScreen({format:"jpg",quality:.8,result:"tmpfile"});return{category:e.category,subject:e.subject,pageUrl:e.screenName,userAgent:describeDevice(),appVersion:e.appVersion,screenshots:[{uri:t,name:`screenshot-${Date.now()}.jpg`,type:"image/jpeg"}]}};
|
|
@@ -1,12 +1 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Report an issue. A separate entry point because it names react-native-view-shot,
|
|
5
|
-
* an optional peer: Metro resolves every require reachable from an import, so
|
|
6
|
-
* keeping it off the main barrel lets an app skip the package when it does not
|
|
7
|
-
* use this button.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
export { captureAndReport } from "./captureAndReport.js";
|
|
11
|
-
export { ReportIssueButton } from "./ReportIssueButton.js";
|
|
12
|
-
//# sourceMappingURL=index.js.map
|
|
1
|
+
"use strict";export{captureAndReport}from"./captureAndReport.js";export{ReportIssueButton}from"./ReportIssueButton.js";
|
|
@@ -1,37 +1 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Opens a support attachment through the system share sheet, by way of
|
|
5
|
-
* mobile-core's `/share`. A separate entry point because that reaches
|
|
6
|
-
* expo-sharing, an optional peer: Metro resolves every require reachable from an
|
|
7
|
-
* import, so keeping it off the main barrel lets an app skip the package when it
|
|
8
|
-
* does not open files.
|
|
9
|
-
*/
|
|
10
|
-
import { shareFile } from '@webority/mobile-core/share';
|
|
11
|
-
export class SupportShareUnavailableError extends Error {
|
|
12
|
-
constructor() {
|
|
13
|
-
super('Opening files is not available on this device.');
|
|
14
|
-
this.name = 'SupportShareUnavailableError';
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Hands a downloaded attachment to the OS share sheet, where the person can open
|
|
20
|
-
* it in a viewer or save it. Pass it to `SupportProvider` as `openFile`.
|
|
21
|
-
*/
|
|
22
|
-
export const shareSupportFile = async file => {
|
|
23
|
-
try {
|
|
24
|
-
await shareFile(file.uri, {
|
|
25
|
-
mimeType: file.contentType,
|
|
26
|
-
dialogTitle: file.name
|
|
27
|
-
});
|
|
28
|
-
} catch (error) {
|
|
29
|
-
// By name, not instanceof: a second copy of mobile-core would fail instanceof.
|
|
30
|
-
const e = error;
|
|
31
|
-
if (e?.name === 'ShareError' && e.code === 'unavailable') {
|
|
32
|
-
throw new SupportShareUnavailableError();
|
|
33
|
-
}
|
|
34
|
-
throw error;
|
|
35
|
-
}
|
|
36
|
-
};
|
|
37
|
-
//# sourceMappingURL=index.js.map
|
|
1
|
+
"use strict";import{shareFile}from"@webority/mobile-core/share";export class SupportShareUnavailableError extends Error{constructor(){super("Opening files is not available on this device."),this.name="SupportShareUnavailableError"}}export const shareSupportFile=async r=>{try{await shareFile(r.uri,{mimeType:r.contentType,dialogTitle:r.name})}catch(r){const e=r;if("ShareError"===e?.name&&"unavailable"===e.code)throw new SupportShareUnavailableError;throw r}};
|
package/lib/module/types.js
CHANGED
|
@@ -1,2 +1 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
//# sourceMappingURL=types.js.map
|
|
1
|
+
"use strict";
|
package/lib/module/validation.js
CHANGED
|
@@ -1,38 +1 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
export const validateCategory = (category, config) => config.categories.includes(category) ? undefined : 'Choose one of the listed categories.';
|
|
4
|
-
|
|
5
|
-
/** Limits come from GET /settings, so the product's own values are the only copy. */
|
|
6
|
-
|
|
7
|
-
export const validateSubject = (subject, limits) => {
|
|
8
|
-
const trimmed = subject.trim();
|
|
9
|
-
if (!trimmed) {
|
|
10
|
-
return 'Add a short subject.';
|
|
11
|
-
}
|
|
12
|
-
return trimmed.length > limits.subjectMaxLength ? `Keep the subject under ${limits.subjectMaxLength} characters.` : undefined;
|
|
13
|
-
};
|
|
14
|
-
export const validateBody = (body, limits) => {
|
|
15
|
-
const trimmed = body.trim();
|
|
16
|
-
if (!trimmed) {
|
|
17
|
-
return 'Write a message.';
|
|
18
|
-
}
|
|
19
|
-
return trimmed.length > limits.bodyMaxLength ? `Keep the message under ${limits.bodyMaxLength} characters.` : undefined;
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
/** Checks every file on one message against the product's limits. The first problem wins. */
|
|
23
|
-
export const validateFiles = (files, config) => {
|
|
24
|
-
if (files.length > config.maxAttachmentsPerMessage) {
|
|
25
|
-
return `Attach up to ${config.maxAttachmentsPerMessage} files to one message.`;
|
|
26
|
-
}
|
|
27
|
-
for (const file of files) {
|
|
28
|
-
if (config.allowedContentTypes.length > 0 && !config.allowedContentTypes.includes(file.type)) {
|
|
29
|
-
return `${file.name} can't be attached. Use an image, a PDF or a text file.`;
|
|
30
|
-
}
|
|
31
|
-
if (file.size !== undefined && file.size > config.maxAttachmentBytes) {
|
|
32
|
-
const mb = Math.floor(config.maxAttachmentBytes / (1024 * 1024));
|
|
33
|
-
return `${file.name} is too large. The limit is ${mb} MB per file.`;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
return undefined;
|
|
37
|
-
};
|
|
38
|
-
//# sourceMappingURL=validation.js.map
|
|
1
|
+
"use strict";export const validateCategory=(e,t)=>t.categories.includes(e)?void 0:"Choose one of the listed categories.";export const validateSubject=(e,t)=>{const a=e.trim();return a?a.length>t.subjectMaxLength?`Keep the subject under ${t.subjectMaxLength} characters.`:void 0:"Add a short subject."};export const validateBody=(e,t)=>{const a=e.trim();return a?a.length>t.bodyMaxLength?`Keep the message under ${t.bodyMaxLength} characters.`:void 0:"Write a message."};export const validateFiles=(e,t)=>{if(e.length>t.maxAttachmentsPerMessage)return`Attach up to ${t.maxAttachmentsPerMessage} files to one message.`;for(const a of e){if(t.allowedContentTypes.length>0&&!t.allowedContentTypes.includes(a.type))return`${a.name} can't be attached. Use an image, a PDF or a text file.`;if(void 0!==a.size&&a.size>t.maxAttachmentBytes){const e=Math.floor(t.maxAttachmentBytes/1048576);return`${a.name} is too large. The limit is ${e} MB per file.`}}};
|
|
@@ -18,6 +18,8 @@ export interface SupportProviderProps {
|
|
|
18
18
|
* attachments show their name and size only.
|
|
19
19
|
*/
|
|
20
20
|
openFile?: SupportFileOpener;
|
|
21
|
+
/** IANA time zone ticket times show in. Defaults to `Asia/Kolkata` (IST). */
|
|
22
|
+
timeZone?: string;
|
|
21
23
|
children: React.ReactNode;
|
|
22
24
|
}
|
|
23
25
|
export declare const SupportProvider: React.FC<SupportProviderProps>;
|
|
@@ -26,6 +28,8 @@ export declare const useSupportClient: () => SupportClient;
|
|
|
26
28
|
export declare const useAttachmentLoader: () => (attachment: Pick<SupportAttachmentView, "id" | "contentType">) => Promise<string>;
|
|
27
29
|
/** @internal Used by the file rows; null when the app gave no `openFile`. */
|
|
28
30
|
export declare const useAttachmentOpener: () => AttachmentOpener | null;
|
|
31
|
+
/** @internal Used by the ticket screens to format times. */
|
|
32
|
+
export declare const useSupportTimeZone: () => string;
|
|
29
33
|
export interface SupportConfigState {
|
|
30
34
|
config: SupportContactInfo | null;
|
|
31
35
|
loading: boolean;
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
/** The zone ticket times show in unless the app passes `timeZone` to `SupportProvider`. */
|
|
2
|
+
export declare const DEFAULT_TIME_ZONE = "Asia/Kolkata";
|
|
1
3
|
/**
|
|
2
|
-
* A timestamp in
|
|
3
|
-
*
|
|
4
|
-
*
|
|
4
|
+
* A timestamp as `07-Oct-2026, 2:30 PM` in an IANA time zone. The backend sends UTC.
|
|
5
|
+
* Built from `formatToParts`, because the assembled Intl string varies by engine
|
|
6
|
+
* (month spelling, a narrow no-break space before AM/PM).
|
|
5
7
|
*/
|
|
6
|
-
export declare const formatWhen: (utc: string | null | undefined) => string;
|
|
8
|
+
export declare const formatWhen: (utc: string | null | undefined, timeZone?: string) => string;
|
|
7
9
|
/** A byte count as a person reads it. */
|
|
8
10
|
export declare const formatBytes: (bytes: number) => string;
|
|
9
11
|
export declare const isImage: (contentType: string) => boolean;
|