@webority/mobile-support 0.0.9
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/LICENSE +21 -0
- package/README.md +146 -0
- package/lib/commonjs/SupportProvider.js +335 -0
- package/lib/commonjs/client.js +164 -0
- package/lib/commonjs/clipboard.js +10 -0
- package/lib/commonjs/components/Attachments.js +246 -0
- package/lib/commonjs/components/NewSupportTicket.js +210 -0
- package/lib/commonjs/components/PhotoTray.js +141 -0
- package/lib/commonjs/components/SupportContact.js +134 -0
- package/lib/commonjs/components/SupportTicketDetail.js +348 -0
- package/lib/commonjs/components/SupportTicketList.js +279 -0
- package/lib/commonjs/device.js +29 -0
- package/lib/commonjs/files.js +37 -0
- package/lib/commonjs/format.js +29 -0
- package/lib/commonjs/index.js +97 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/photos.js +67 -0
- package/lib/commonjs/report/ReportIssueButton.js +57 -0
- package/lib/commonjs/report/captureAndReport.js +34 -0
- package/lib/commonjs/report/index.js +20 -0
- package/lib/commonjs/share/index.js +44 -0
- package/lib/commonjs/types.js +2 -0
- package/lib/commonjs/validation.js +45 -0
- package/lib/module/SupportProvider.js +323 -0
- package/lib/module/client.js +156 -0
- package/lib/module/clipboard.js +5 -0
- package/lib/module/components/Attachments.js +240 -0
- package/lib/module/components/NewSupportTicket.js +205 -0
- package/lib/module/components/PhotoTray.js +136 -0
- package/lib/module/components/SupportContact.js +129 -0
- package/lib/module/components/SupportTicketDetail.js +343 -0
- package/lib/module/components/SupportTicketList.js +273 -0
- package/lib/module/device.js +24 -0
- package/lib/module/files.js +31 -0
- package/lib/module/format.js +23 -0
- package/lib/module/index.js +10 -0
- package/lib/module/package.json +1 -0
- package/lib/module/photos.js +61 -0
- package/lib/module/report/ReportIssueButton.js +52 -0
- package/lib/module/report/captureAndReport.js +29 -0
- package/lib/module/report/index.js +12 -0
- package/lib/module/share/index.js +37 -0
- package/lib/module/types.js +2 -0
- package/lib/module/validation.js +38 -0
- package/lib/typescript/commonjs/SupportProvider.d.ts +82 -0
- package/lib/typescript/commonjs/client.d.ts +63 -0
- package/lib/typescript/commonjs/clipboard.d.ts +3 -0
- package/lib/typescript/commonjs/components/Attachments.d.ts +16 -0
- package/lib/typescript/commonjs/components/NewSupportTicket.d.ts +12 -0
- package/lib/typescript/commonjs/components/PhotoTray.d.ts +17 -0
- package/lib/typescript/commonjs/components/SupportContact.d.ts +7 -0
- package/lib/typescript/commonjs/components/SupportTicketDetail.d.ts +7 -0
- package/lib/typescript/commonjs/components/SupportTicketList.d.ts +28 -0
- package/lib/typescript/commonjs/device.d.ts +6 -0
- package/lib/typescript/commonjs/files.d.ts +10 -0
- package/lib/typescript/commonjs/format.d.ts +10 -0
- package/lib/typescript/commonjs/index.d.ts +15 -0
- package/lib/typescript/commonjs/package.json +1 -0
- package/lib/typescript/commonjs/photos.d.ts +16 -0
- package/lib/typescript/commonjs/report/ReportIssueButton.d.ts +14 -0
- package/lib/typescript/commonjs/report/captureAndReport.d.ts +15 -0
- package/lib/typescript/commonjs/report/index.d.ts +11 -0
- package/lib/typescript/commonjs/share/index.d.ts +10 -0
- package/lib/typescript/commonjs/types.d.ts +152 -0
- package/lib/typescript/commonjs/validation.d.ts +10 -0
- package/lib/typescript/module/SupportProvider.d.ts +82 -0
- package/lib/typescript/module/client.d.ts +63 -0
- package/lib/typescript/module/clipboard.d.ts +3 -0
- package/lib/typescript/module/components/Attachments.d.ts +16 -0
- package/lib/typescript/module/components/NewSupportTicket.d.ts +12 -0
- package/lib/typescript/module/components/PhotoTray.d.ts +17 -0
- package/lib/typescript/module/components/SupportContact.d.ts +7 -0
- package/lib/typescript/module/components/SupportTicketDetail.d.ts +7 -0
- package/lib/typescript/module/components/SupportTicketList.d.ts +28 -0
- package/lib/typescript/module/device.d.ts +6 -0
- package/lib/typescript/module/files.d.ts +10 -0
- package/lib/typescript/module/format.d.ts +10 -0
- package/lib/typescript/module/index.d.ts +15 -0
- package/lib/typescript/module/package.json +1 -0
- package/lib/typescript/module/photos.d.ts +16 -0
- package/lib/typescript/module/report/ReportIssueButton.d.ts +14 -0
- package/lib/typescript/module/report/captureAndReport.d.ts +15 -0
- package/lib/typescript/module/report/index.d.ts +11 -0
- package/lib/typescript/module/share/index.d.ts +10 -0
- package/lib/typescript/module/types.d.ts +152 -0
- package/lib/typescript/module/validation.d.ts +10 -0
- package/package.json +124 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Webority Technologies
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# @webority/mobile-support
|
|
2
|
+
|
|
3
|
+
Customer support tickets for Webority React Native apps. It talks to the Webority Support
|
|
4
|
+
endpoints a product hosts (`MapWeboritySupportEndpoints("/api/support")`) and gives the app
|
|
5
|
+
ready screens: the ticket list, a ticket's thread, a new ticket form, a contact card and a
|
|
6
|
+
report-an-issue button that attaches a screenshot of the current screen.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install @webority/mobile-support
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Builds on `@webority/mobile-core` (the HTTP client, device context, image
|
|
13
|
+
compression) and `@webority/mobile-ui` (theme, components, form engine).
|
|
14
|
+
|
|
15
|
+
## Peers
|
|
16
|
+
|
|
17
|
+
| Package | Why | Required |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| `expo-image-picker` | photos from the library and the camera | yes |
|
|
20
|
+
| `expo-image-manipulator`, `expo-file-system` | photos are bounded and compressed before upload (mobile-core's compressor); attachments are saved to the cache before opening | yes |
|
|
21
|
+
| `expo-clipboard` | copy the support email | yes |
|
|
22
|
+
| `react-native-gesture-handler` | pinch to zoom in the full-screen image view | yes (mobile-ui needs it too) |
|
|
23
|
+
| `react-native-view-shot` | the screen capture behind `ReportIssueButton` | only if you import `/report` |
|
|
24
|
+
| `expo-sharing` | opens a PDF or text attachment through the share sheet | only if you import `/share` |
|
|
25
|
+
|
|
26
|
+
`react-native-view-shot` is named only under the `/report` import path and `expo-sharing` only
|
|
27
|
+
under `/share`, so an app that never imports them does not need the packages installed.
|
|
28
|
+
|
|
29
|
+
## Wire it up
|
|
30
|
+
|
|
31
|
+
Create the client over the app's own signed-in HTTP client, so every support call carries the
|
|
32
|
+
same bearer token, tenant header and refresh handling as the rest of the app. Pass the prefix the
|
|
33
|
+
product mapped the endpoints on.
|
|
34
|
+
|
|
35
|
+
```tsx
|
|
36
|
+
import { userClient } from '@webority/mobile-core';
|
|
37
|
+
import { createSupportClient, SupportProvider } from '@webority/mobile-support';
|
|
38
|
+
|
|
39
|
+
const supportClient = createSupportClient(userClient, '/api/support');
|
|
40
|
+
|
|
41
|
+
export const App = () => (
|
|
42
|
+
<ThemeProvider>
|
|
43
|
+
<SupportProvider client={supportClient}>
|
|
44
|
+
<Navigation />
|
|
45
|
+
</SupportProvider>
|
|
46
|
+
</ThemeProvider>
|
|
47
|
+
);
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Screens
|
|
51
|
+
|
|
52
|
+
The screens know nothing about navigation. Place them in your own navigator and pass callbacks.
|
|
53
|
+
|
|
54
|
+
```tsx
|
|
55
|
+
import {
|
|
56
|
+
NewSupportTicket,
|
|
57
|
+
SupportContact,
|
|
58
|
+
SupportTicketDetail,
|
|
59
|
+
SupportTicketList
|
|
60
|
+
} from '@webority/mobile-support';
|
|
61
|
+
|
|
62
|
+
const SupportScreen = ({ navigation }) => (
|
|
63
|
+
<SupportTicketList
|
|
64
|
+
header={<SupportContact />}
|
|
65
|
+
onOpenTicket={(t) => navigation.navigate('SupportTicket', { id: t.id })}
|
|
66
|
+
onNewTicket={() => navigation.navigate('NewSupportTicket')}
|
|
67
|
+
/>
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const SupportTicketScreen = ({ route }) => <SupportTicketDetail ticketId={route.params.id} />;
|
|
71
|
+
|
|
72
|
+
const NewSupportTicketScreen = ({ navigation, route }) => (
|
|
73
|
+
<NewSupportTicket
|
|
74
|
+
initialDraft={route.params?.draft}
|
|
75
|
+
onCancel={() => navigation.goBack()}
|
|
76
|
+
onCreated={(t) => navigation.replace('SupportTicket', { id: t.id })}
|
|
77
|
+
/>
|
|
78
|
+
);
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
- **SupportTicketList**: one page at a time (`pageSize`, default 20), the next page loading as
|
|
82
|
+
the person reaches the end. Status chips carry the counts from `GET /tickets/counts`; a chip and
|
|
83
|
+
the search box (subject or ticket number) filter on the server and start again from page 1, as
|
|
84
|
+
does pull to refresh. Empty state, retry on failure, and a Load more retry when a later page
|
|
85
|
+
fails without losing the rows already shown.
|
|
86
|
+
- **SupportTicketDetail**: the thread as bubbles, status events as small centred lines, image
|
|
87
|
+
thumbnails that open full screen, a reply box with photos, Reopen when `canReopen`, and a
|
|
88
|
+
satisfied or not satisfied rating with an optional comment when `canRate`. Replying to a
|
|
89
|
+
resolved or closed ticket reopens it.
|
|
90
|
+
- **NewSupportTicket**: category, subject, message and photos, checked against the limits the
|
|
91
|
+
product sends in `GET /settings` before anything is uploaded.
|
|
92
|
+
- **SupportContact**: the support email with copy and compose, the phone number, and the hours.
|
|
93
|
+
|
|
94
|
+
## Report an issue
|
|
95
|
+
|
|
96
|
+
```tsx
|
|
97
|
+
import { ReportIssueButton } from '@webority/mobile-support/report';
|
|
98
|
+
|
|
99
|
+
<ReportIssueButton
|
|
100
|
+
screenName={route.name}
|
|
101
|
+
appVersion={AppSettings.VERSION_DISPLAY}
|
|
102
|
+
onReport={(draft) => navigation.navigate('NewSupportTicket', { draft })}
|
|
103
|
+
/>;
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The button captures the screen, then hands your app a draft with the image under
|
|
107
|
+
`screenshots`, the screen name as the page, the device line and the app version.
|
|
108
|
+
`captureAndReport(options)` does the same without the button. The device line reads the
|
|
109
|
+
platform and OS version, plus the model when the app has called mobile-core's
|
|
110
|
+
`setDeviceContext`.
|
|
111
|
+
|
|
112
|
+
## Hooks
|
|
113
|
+
|
|
114
|
+
`useSupportConfig()`, `useSupportTickets({ pageSize })`, `useSupportTicket(id)` and
|
|
115
|
+
`useCreateSupportTicket()` expose the same data for apps that build their own screens.
|
|
116
|
+
`useSupportTickets` returns the pages loaded so far as `tickets`, the `counts`, `status` and
|
|
117
|
+
`search` with their setters, `loadMore` and `hasMore`, and `refresh`. Every
|
|
118
|
+
failure is a `SupportApiError` whose `message` is written for the person and can be shown as is;
|
|
119
|
+
branch on `code`, never show it.
|
|
120
|
+
|
|
121
|
+
`client.confirmAction(token)` sends the person's yes to a support action an assistant prepared
|
|
122
|
+
(raise a ticket, reply on one, reopen one) as `POST {base}/actions/confirm`. Only this call
|
|
123
|
+
completes the action. It resolves to the ticket it acted on; an expired or unknown token rejects
|
|
124
|
+
with the code `action_expired`.
|
|
125
|
+
|
|
126
|
+
## Attachments
|
|
127
|
+
|
|
128
|
+
Attachment files need the bearer token, so thumbnails are fetched through the client you passed
|
|
129
|
+
in and shown from a `data:` URI, cached for the session.
|
|
130
|
+
|
|
131
|
+
Files that are not images (a PDF, a text file) show their name and size. To let people open
|
|
132
|
+
them, give `SupportProvider` an `openFile`. The file is downloaded through the same client, saved
|
|
133
|
+
to the app's cache, then handed to your opener. `/share` ships one that uses the system share
|
|
134
|
+
sheet (install `expo-sharing`):
|
|
135
|
+
|
|
136
|
+
```tsx
|
|
137
|
+
import { shareSupportFile } from '@webority/mobile-support/share';
|
|
138
|
+
|
|
139
|
+
<SupportProvider client={supportClient} openFile={shareSupportFile}>
|
|
140
|
+
<Navigation />
|
|
141
|
+
</SupportProvider>;
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
On both platforms the person picks the app to open or save the file with; on Android this is
|
|
145
|
+
the share chooser rather than an "open with" list. If the file cannot be
|
|
146
|
+
opened, the person sees a short message and can try again.
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.useSupportTickets = exports.useSupportTicket = exports.useSupportConfig = exports.useSupportClient = exports.useCreateSupportTicket = exports.useAttachmentOpener = exports.useAttachmentLoader = exports.SupportProvider = void 0;
|
|
7
|
+
var _react = require("react");
|
|
8
|
+
var _client = require("./client.js");
|
|
9
|
+
var _files = require("./files.js");
|
|
10
|
+
var _jsxRuntime = require("react/jsx-runtime");
|
|
11
|
+
/** A downloaded attachment, ready for the OS to open. */
|
|
12
|
+
|
|
13
|
+
/** Hands a downloaded file to the OS. `shareSupportFile` from the `/share` entry is one. */
|
|
14
|
+
|
|
15
|
+
const SupportContext = /*#__PURE__*/(0, _react.createContext)(null);
|
|
16
|
+
const SupportProvider = ({
|
|
17
|
+
client,
|
|
18
|
+
openFile,
|
|
19
|
+
children
|
|
20
|
+
}) => {
|
|
21
|
+
const [config, setConfig] = (0, _react.useState)(null);
|
|
22
|
+
const [configError, setConfigError] = (0, _react.useState)(null);
|
|
23
|
+
const [configLoading, setConfigLoading] = (0, _react.useState)(true);
|
|
24
|
+
const [revision, setRevision] = (0, _react.useState)(0);
|
|
25
|
+
// Attachments never change once stored, so each is fetched once per session.
|
|
26
|
+
const attachments = (0, _react.useRef)(new Map());
|
|
27
|
+
const reloadConfig = (0, _react.useCallback)(async () => {
|
|
28
|
+
setConfigLoading(true);
|
|
29
|
+
try {
|
|
30
|
+
setConfig(await client.getConfig());
|
|
31
|
+
setConfigError(null);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
setConfigError((0, _client.toSupportApiError)(error));
|
|
34
|
+
} finally {
|
|
35
|
+
setConfigLoading(false);
|
|
36
|
+
}
|
|
37
|
+
}, [client]);
|
|
38
|
+
(0, _react.useEffect)(() => {
|
|
39
|
+
attachments.current.clear();
|
|
40
|
+
void reloadConfig();
|
|
41
|
+
}, [reloadConfig]);
|
|
42
|
+
const loadAttachment = (0, _react.useCallback)(attachment => {
|
|
43
|
+
const cached = attachments.current.get(attachment.id);
|
|
44
|
+
if (cached) {
|
|
45
|
+
return cached;
|
|
46
|
+
}
|
|
47
|
+
const pending = client.getAttachmentDataUri(attachment);
|
|
48
|
+
attachments.current.set(attachment.id, pending);
|
|
49
|
+
pending.catch(() => attachments.current.delete(attachment.id));
|
|
50
|
+
return pending;
|
|
51
|
+
}, [client]);
|
|
52
|
+
const openAttachment = (0, _react.useMemo)(() => openFile ? async attachment => {
|
|
53
|
+
const uri = await (0, _files.saveAttachmentToCache)(attachment, () => client.getAttachmentBytes(attachment.id));
|
|
54
|
+
await openFile({
|
|
55
|
+
uri,
|
|
56
|
+
name: attachment.fileName,
|
|
57
|
+
contentType: attachment.contentType
|
|
58
|
+
});
|
|
59
|
+
} : null, [client, openFile]);
|
|
60
|
+
const bumpRevision = (0, _react.useCallback)(() => setRevision(r => r + 1), []);
|
|
61
|
+
const value = (0, _react.useMemo)(() => ({
|
|
62
|
+
client,
|
|
63
|
+
config,
|
|
64
|
+
configError,
|
|
65
|
+
configLoading,
|
|
66
|
+
reloadConfig,
|
|
67
|
+
loadAttachment,
|
|
68
|
+
openAttachment,
|
|
69
|
+
revision,
|
|
70
|
+
bumpRevision
|
|
71
|
+
}), [client, config, configError, configLoading, reloadConfig, loadAttachment, openAttachment, revision, bumpRevision]);
|
|
72
|
+
return /*#__PURE__*/(0, _jsxRuntime.jsx)(SupportContext.Provider, {
|
|
73
|
+
value: value,
|
|
74
|
+
children: children
|
|
75
|
+
});
|
|
76
|
+
};
|
|
77
|
+
exports.SupportProvider = SupportProvider;
|
|
78
|
+
const useSupportContext = () => {
|
|
79
|
+
const ctx = (0, _react.useContext)(SupportContext);
|
|
80
|
+
if (!ctx) {
|
|
81
|
+
throw new Error('[@webority/mobile-support] Support screens and hooks need a <SupportProvider client={...}> above them.');
|
|
82
|
+
}
|
|
83
|
+
return ctx;
|
|
84
|
+
};
|
|
85
|
+
const useSupportClient = () => useSupportContext().client;
|
|
86
|
+
|
|
87
|
+
/** @internal Used by the attachment thumbnails. */
|
|
88
|
+
exports.useSupportClient = useSupportClient;
|
|
89
|
+
const useAttachmentLoader = () => useSupportContext().loadAttachment;
|
|
90
|
+
|
|
91
|
+
/** @internal Used by the file rows; null when the app gave no `openFile`. */
|
|
92
|
+
exports.useAttachmentLoader = useAttachmentLoader;
|
|
93
|
+
const useAttachmentOpener = () => useSupportContext().openAttachment;
|
|
94
|
+
exports.useAttachmentOpener = useAttachmentOpener;
|
|
95
|
+
const useSupportConfig = () => {
|
|
96
|
+
const {
|
|
97
|
+
config,
|
|
98
|
+
configLoading,
|
|
99
|
+
configError,
|
|
100
|
+
reloadConfig
|
|
101
|
+
} = useSupportContext();
|
|
102
|
+
return {
|
|
103
|
+
config,
|
|
104
|
+
loading: configLoading,
|
|
105
|
+
error: configError,
|
|
106
|
+
reload: reloadConfig
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
exports.useSupportConfig = useSupportConfig;
|
|
110
|
+
const DEFAULT_PAGE_SIZE = 20;
|
|
111
|
+
const useSupportTickets = (options = {}) => {
|
|
112
|
+
const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE;
|
|
113
|
+
const {
|
|
114
|
+
client,
|
|
115
|
+
revision
|
|
116
|
+
} = useSupportContext();
|
|
117
|
+
const [status, setStatus] = (0, _react.useState)(null);
|
|
118
|
+
const [search, setSearch] = (0, _react.useState)('');
|
|
119
|
+
const [tickets, setTickets] = (0, _react.useState)([]);
|
|
120
|
+
const [counts, setCounts] = (0, _react.useState)(null);
|
|
121
|
+
const [page, setPage] = (0, _react.useState)(0);
|
|
122
|
+
const [totalPages, setTotalPages] = (0, _react.useState)(0);
|
|
123
|
+
const [loading, setLoading] = (0, _react.useState)(true);
|
|
124
|
+
const [refreshing, setRefreshing] = (0, _react.useState)(false);
|
|
125
|
+
const [loadingMore, setLoadingMore] = (0, _react.useState)(false);
|
|
126
|
+
const [error, setError] = (0, _react.useState)(null);
|
|
127
|
+
const [loadMoreError, setLoadMoreError] = (0, _react.useState)(null);
|
|
128
|
+
const mounted = (0, _react.useRef)(true);
|
|
129
|
+
// Each first-page load takes a new number; a response carrying an older one
|
|
130
|
+
// belongs to a filter or search the person has already left, and is dropped.
|
|
131
|
+
const sequence = (0, _react.useRef)(0);
|
|
132
|
+
const moreInFlight = (0, _react.useRef)(false);
|
|
133
|
+
// The client whose counts are on screen; null until a load with counts lands.
|
|
134
|
+
const countsFrom = (0, _react.useRef)(null);
|
|
135
|
+
(0, _react.useEffect)(() => {
|
|
136
|
+
mounted.current = true;
|
|
137
|
+
return () => {
|
|
138
|
+
mounted.current = false;
|
|
139
|
+
};
|
|
140
|
+
}, []);
|
|
141
|
+
const query = (0, _react.useCallback)(nextPage => ({
|
|
142
|
+
status: status ?? undefined,
|
|
143
|
+
search: search.trim() || undefined,
|
|
144
|
+
page: nextPage,
|
|
145
|
+
pageSize
|
|
146
|
+
}), [status, search, pageSize]);
|
|
147
|
+
const loadFirstPage = (0, _react.useCallback)(async mode => {
|
|
148
|
+
sequence.current += 1;
|
|
149
|
+
const seq = sequence.current;
|
|
150
|
+
const current = () => mounted.current && seq === sequence.current;
|
|
151
|
+
moreInFlight.current = false;
|
|
152
|
+
setLoadingMore(false);
|
|
153
|
+
if (mode === 'refresh') {
|
|
154
|
+
setRefreshing(true);
|
|
155
|
+
} else if (mode === 'replace') {
|
|
156
|
+
setLoading(true);
|
|
157
|
+
setTickets([]);
|
|
158
|
+
}
|
|
159
|
+
// Counts ignore the status and search, so a filter change keeps the ones it has.
|
|
160
|
+
const withCounts = mode !== 'replace' || countsFrom.current !== client;
|
|
161
|
+
try {
|
|
162
|
+
const [first, nextCounts] = await Promise.all([client.listTickets(query(1)), withCounts ? client.getTicketCounts() : null]);
|
|
163
|
+
if (current()) {
|
|
164
|
+
setTickets(first.items);
|
|
165
|
+
setPage(first.page);
|
|
166
|
+
setTotalPages(first.totalPages);
|
|
167
|
+
if (nextCounts) {
|
|
168
|
+
setCounts(nextCounts);
|
|
169
|
+
countsFrom.current = client;
|
|
170
|
+
}
|
|
171
|
+
setError(null);
|
|
172
|
+
setLoadMoreError(null);
|
|
173
|
+
}
|
|
174
|
+
} catch (e) {
|
|
175
|
+
if (current()) {
|
|
176
|
+
setError((0, _client.toSupportApiError)(e));
|
|
177
|
+
}
|
|
178
|
+
} finally {
|
|
179
|
+
if (current()) {
|
|
180
|
+
setLoading(false);
|
|
181
|
+
setRefreshing(false);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}, [client, query]);
|
|
185
|
+
const seenRevision = (0, _react.useRef)(revision);
|
|
186
|
+
(0, _react.useEffect)(() => {
|
|
187
|
+
// A write elsewhere refetches without blanking the rows on screen.
|
|
188
|
+
const mode = seenRevision.current === revision ? 'replace' : 'quiet';
|
|
189
|
+
seenRevision.current = revision;
|
|
190
|
+
void loadFirstPage(mode);
|
|
191
|
+
}, [loadFirstPage, revision]);
|
|
192
|
+
const hasMore = page < totalPages;
|
|
193
|
+
const loadMore = (0, _react.useCallback)(async () => {
|
|
194
|
+
if (moreInFlight.current || loading || refreshing || !hasMore) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const seq = sequence.current;
|
|
198
|
+
const current = () => mounted.current && seq === sequence.current;
|
|
199
|
+
moreInFlight.current = true;
|
|
200
|
+
setLoadingMore(true);
|
|
201
|
+
setLoadMoreError(null);
|
|
202
|
+
try {
|
|
203
|
+
const next = await client.listTickets(query(page + 1));
|
|
204
|
+
if (current()) {
|
|
205
|
+
// Order is by last update, so a ticket that changed between two pages
|
|
206
|
+
// can arrive twice.
|
|
207
|
+
setTickets(prev => {
|
|
208
|
+
const ids = new Set(prev.map(t => t.id));
|
|
209
|
+
return [...prev, ...next.items.filter(t => !ids.has(t.id))];
|
|
210
|
+
});
|
|
211
|
+
setPage(next.page);
|
|
212
|
+
setTotalPages(next.totalPages);
|
|
213
|
+
}
|
|
214
|
+
} catch (e) {
|
|
215
|
+
if (current()) {
|
|
216
|
+
setLoadMoreError((0, _client.toSupportApiError)(e));
|
|
217
|
+
}
|
|
218
|
+
} finally {
|
|
219
|
+
if (current()) {
|
|
220
|
+
moreInFlight.current = false;
|
|
221
|
+
setLoadingMore(false);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}, [client, query, page, hasMore, loading, refreshing]);
|
|
225
|
+
const refresh = (0, _react.useCallback)(() => loadFirstPage('refresh'), [loadFirstPage]);
|
|
226
|
+
const createTicket = useCreateSupportTicket();
|
|
227
|
+
return {
|
|
228
|
+
tickets,
|
|
229
|
+
counts,
|
|
230
|
+
status,
|
|
231
|
+
search,
|
|
232
|
+
setStatus,
|
|
233
|
+
setSearch,
|
|
234
|
+
loading,
|
|
235
|
+
refreshing,
|
|
236
|
+
loadingMore,
|
|
237
|
+
hasMore,
|
|
238
|
+
error,
|
|
239
|
+
loadMoreError,
|
|
240
|
+
refresh,
|
|
241
|
+
loadMore,
|
|
242
|
+
createTicket
|
|
243
|
+
};
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
/** Raises a ticket and tells every mounted ticket list to refetch. */
|
|
247
|
+
exports.useSupportTickets = useSupportTickets;
|
|
248
|
+
const useCreateSupportTicket = () => {
|
|
249
|
+
const {
|
|
250
|
+
client,
|
|
251
|
+
bumpRevision
|
|
252
|
+
} = useSupportContext();
|
|
253
|
+
return (0, _react.useCallback)(async input => {
|
|
254
|
+
const created = await client.createTicket(input);
|
|
255
|
+
bumpRevision();
|
|
256
|
+
return created;
|
|
257
|
+
}, [client, bumpRevision]);
|
|
258
|
+
};
|
|
259
|
+
exports.useCreateSupportTicket = useCreateSupportTicket;
|
|
260
|
+
const useSupportTicket = id => {
|
|
261
|
+
const {
|
|
262
|
+
client,
|
|
263
|
+
bumpRevision
|
|
264
|
+
} = useSupportContext();
|
|
265
|
+
const [ticket, setTicket] = (0, _react.useState)(null);
|
|
266
|
+
const [loading, setLoading] = (0, _react.useState)(true);
|
|
267
|
+
const [error, setError] = (0, _react.useState)(null);
|
|
268
|
+
const [actionError, setActionError] = (0, _react.useState)(null);
|
|
269
|
+
const [busy, setBusy] = (0, _react.useState)(false);
|
|
270
|
+
const mounted = (0, _react.useRef)(true);
|
|
271
|
+
(0, _react.useEffect)(() => {
|
|
272
|
+
mounted.current = true;
|
|
273
|
+
return () => {
|
|
274
|
+
mounted.current = false;
|
|
275
|
+
};
|
|
276
|
+
}, []);
|
|
277
|
+
const reload = (0, _react.useCallback)(async () => {
|
|
278
|
+
setLoading(true);
|
|
279
|
+
try {
|
|
280
|
+
const next = await client.getTicket(id);
|
|
281
|
+
if (mounted.current) {
|
|
282
|
+
setTicket(next);
|
|
283
|
+
setError(null);
|
|
284
|
+
}
|
|
285
|
+
} catch (e) {
|
|
286
|
+
if (mounted.current) {
|
|
287
|
+
setError((0, _client.toSupportApiError)(e));
|
|
288
|
+
}
|
|
289
|
+
} finally {
|
|
290
|
+
if (mounted.current) {
|
|
291
|
+
setLoading(false);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}, [client, id]);
|
|
295
|
+
(0, _react.useEffect)(() => {
|
|
296
|
+
void reload();
|
|
297
|
+
}, [reload]);
|
|
298
|
+
const act = (0, _react.useCallback)(async run => {
|
|
299
|
+
setBusy(true);
|
|
300
|
+
setActionError(null);
|
|
301
|
+
try {
|
|
302
|
+
const next = await run();
|
|
303
|
+
if (mounted.current) {
|
|
304
|
+
setTicket(next);
|
|
305
|
+
}
|
|
306
|
+
bumpRevision();
|
|
307
|
+
return true;
|
|
308
|
+
} catch (e) {
|
|
309
|
+
if (mounted.current) {
|
|
310
|
+
setActionError((0, _client.toSupportApiError)(e));
|
|
311
|
+
}
|
|
312
|
+
return false;
|
|
313
|
+
} finally {
|
|
314
|
+
if (mounted.current) {
|
|
315
|
+
setBusy(false);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}, [bumpRevision]);
|
|
319
|
+
const reply = (0, _react.useCallback)(input => act(() => client.reply(id, input)), [act, client, id]);
|
|
320
|
+
const reopen = (0, _react.useCallback)(() => act(() => client.reopen(id)), [act, client, id]);
|
|
321
|
+
const rate = (0, _react.useCallback)(input => act(() => client.rate(id, input)), [act, client, id]);
|
|
322
|
+
return {
|
|
323
|
+
ticket,
|
|
324
|
+
loading,
|
|
325
|
+
error,
|
|
326
|
+
actionError,
|
|
327
|
+
busy,
|
|
328
|
+
reload,
|
|
329
|
+
reply,
|
|
330
|
+
reopen,
|
|
331
|
+
rate
|
|
332
|
+
};
|
|
333
|
+
};
|
|
334
|
+
exports.useSupportTicket = useSupportTicket;
|
|
335
|
+
//# sourceMappingURL=SupportProvider.js.map
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.toSupportApiError = exports.encodeBase64 = exports.createSupportClient = exports.SupportApiError = exports.OFFLINE_MESSAGE = exports.GENERIC_MESSAGE = void 0;
|
|
7
|
+
/**
|
|
8
|
+
* The slice of an HTTP client the support client needs. The app's signed-in
|
|
9
|
+
* axios instance (mobile-core's `userClient`, or its own) satisfies it as is, so
|
|
10
|
+
* whatever it adds to a request (bearer token, tenant header, refresh on 401)
|
|
11
|
+
* applies to support calls too. Declared structurally so no axios type leaks into
|
|
12
|
+
* this package's declarations.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const OFFLINE_MESSAGE = exports.OFFLINE_MESSAGE = 'You are offline. Check your connection and try again.';
|
|
16
|
+
const GENERIC_MESSAGE = exports.GENERIC_MESSAGE = 'Something went wrong. Try again in a moment.';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A failed support call. `message` is written for the person and is shown as is;
|
|
20
|
+
* `code` is the backend's stable code, for branching only.
|
|
21
|
+
*/
|
|
22
|
+
class SupportApiError extends Error {
|
|
23
|
+
constructor(code, message, status) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = 'SupportApiError';
|
|
26
|
+
this.code = code;
|
|
27
|
+
this.status = status;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
exports.SupportApiError = SupportApiError;
|
|
31
|
+
/** Turns whatever the HTTP client threw into a SupportApiError. */
|
|
32
|
+
const toSupportApiError = error => {
|
|
33
|
+
if (error instanceof SupportApiError) {
|
|
34
|
+
return error;
|
|
35
|
+
}
|
|
36
|
+
const e = error ?? {};
|
|
37
|
+
// By name, not instanceof: a second copy of mobile-core would fail instanceof.
|
|
38
|
+
if (e.name === 'RequestOfflineError') {
|
|
39
|
+
return new SupportApiError('offline', OFFLINE_MESSAGE, null);
|
|
40
|
+
}
|
|
41
|
+
const status = typeof e.response?.status === 'number' ? e.response.status : null;
|
|
42
|
+
const data = e.response?.data;
|
|
43
|
+
if (data && typeof data.message === 'string' && data.message.trim()) {
|
|
44
|
+
const code = typeof data.code === 'string' ? data.code : 'unknown';
|
|
45
|
+
return new SupportApiError(code, data.message, status);
|
|
46
|
+
}
|
|
47
|
+
if (!e.response) {
|
|
48
|
+
return new SupportApiError('network', OFFLINE_MESSAGE, null);
|
|
49
|
+
}
|
|
50
|
+
return new SupportApiError('unknown', GENERIC_MESSAGE, status);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Photo uploads get the fleet's multipart timeout, not the 30 s JSON one.
|
|
54
|
+
exports.toSupportApiError = toSupportApiError;
|
|
55
|
+
const UPLOAD_TIMEOUT_MS = 300000;
|
|
56
|
+
const MULTIPART = {
|
|
57
|
+
'Content-Type': 'multipart/form-data'
|
|
58
|
+
};
|
|
59
|
+
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
60
|
+
const encodeBase64 = buffer => {
|
|
61
|
+
const bytes = new Uint8Array(buffer);
|
|
62
|
+
let out = '';
|
|
63
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
64
|
+
const b0 = bytes[i];
|
|
65
|
+
const b1 = i + 1 < bytes.length ? bytes[i + 1] : undefined;
|
|
66
|
+
const b2 = i + 2 < bytes.length ? bytes[i + 2] : undefined;
|
|
67
|
+
out += B64[b0 >> 2];
|
|
68
|
+
out += B64[(b0 & 0x03) << 4 | (b1 ?? 0) >> 4];
|
|
69
|
+
out += b1 === undefined ? '=' : B64[(b1 & 0x0f) << 2 | (b2 ?? 0) >> 6];
|
|
70
|
+
out += b2 === undefined ? '=' : B64[b2 & 0x3f];
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
};
|
|
74
|
+
exports.encodeBase64 = encodeBase64;
|
|
75
|
+
const appendParts = (form, field, parts) => {
|
|
76
|
+
for (const part of parts ?? []) {
|
|
77
|
+
// React Native's FormData takes a { uri, name, type } object as a file part.
|
|
78
|
+
form.append(field, {
|
|
79
|
+
uri: part.uri,
|
|
80
|
+
name: part.name,
|
|
81
|
+
type: part.type
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
const appendText = (form, field, value) => {
|
|
86
|
+
if (value !== undefined && value !== '') {
|
|
87
|
+
form.append(field, value);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
const queryString = query => {
|
|
91
|
+
const pairs = [];
|
|
92
|
+
const add = (key, value) => {
|
|
93
|
+
if (value !== undefined && value !== '') {
|
|
94
|
+
pairs.push(`${key}=${encodeURIComponent(String(value))}`);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
add('status', query.status);
|
|
98
|
+
add('search', query.search?.trim());
|
|
99
|
+
add('page', query.page);
|
|
100
|
+
add('pageSize', query.pageSize);
|
|
101
|
+
return pairs.length > 0 ? `?${pairs.join('&')}` : '';
|
|
102
|
+
};
|
|
103
|
+
const createSupportClient = (http, basePath = '/api/support') => {
|
|
104
|
+
const base = basePath.replace(/\/+$/, '');
|
|
105
|
+
const ticketUrl = id => `${base}/tickets/${encodeURIComponent(id)}`;
|
|
106
|
+
const call = async request => {
|
|
107
|
+
try {
|
|
108
|
+
const response = await request();
|
|
109
|
+
return response.data;
|
|
110
|
+
} catch (error) {
|
|
111
|
+
throw toSupportApiError(error);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
const getBytes = id => call(() => http.get(`${base}/attachments/${encodeURIComponent(id)}`, {
|
|
115
|
+
responseType: 'arraybuffer'
|
|
116
|
+
}));
|
|
117
|
+
return {
|
|
118
|
+
basePath: base,
|
|
119
|
+
getConfig: () => call(() => http.get(`${base}/settings`)),
|
|
120
|
+
listTickets: (query = {}) => call(() => http.get(`${base}/tickets${queryString(query)}`)),
|
|
121
|
+
getTicketCounts: () => call(() => http.get(`${base}/tickets/counts`)),
|
|
122
|
+
getTicket: id => call(() => http.get(ticketUrl(id))),
|
|
123
|
+
createTicket: input => {
|
|
124
|
+
const form = new FormData();
|
|
125
|
+
form.append('category', input.category);
|
|
126
|
+
form.append('subject', input.subject);
|
|
127
|
+
form.append('body', input.body);
|
|
128
|
+
appendText(form, 'pageUrl', input.pageUrl);
|
|
129
|
+
appendText(form, 'userAgent', input.userAgent);
|
|
130
|
+
appendText(form, 'appVersion', input.appVersion);
|
|
131
|
+
appendParts(form, 'files', input.files);
|
|
132
|
+
appendParts(form, 'screenshots', input.screenshots);
|
|
133
|
+
return call(() => http.post(`${base}/tickets`, form, {
|
|
134
|
+
headers: MULTIPART,
|
|
135
|
+
timeout: UPLOAD_TIMEOUT_MS
|
|
136
|
+
}));
|
|
137
|
+
},
|
|
138
|
+
reply: (id, input) => {
|
|
139
|
+
const form = new FormData();
|
|
140
|
+
form.append('body', input.body);
|
|
141
|
+
appendParts(form, 'files', input.files);
|
|
142
|
+
appendParts(form, 'screenshots', input.screenshots);
|
|
143
|
+
return call(() => http.post(`${ticketUrl(id)}/replies`, form, {
|
|
144
|
+
headers: MULTIPART,
|
|
145
|
+
timeout: UPLOAD_TIMEOUT_MS
|
|
146
|
+
}));
|
|
147
|
+
},
|
|
148
|
+
reopen: id => call(() => http.post(`${ticketUrl(id)}/reopen`)),
|
|
149
|
+
rate: (id, input) => call(() => http.post(`${ticketUrl(id)}/rating`, {
|
|
150
|
+
satisfaction: input.satisfaction,
|
|
151
|
+
comment: input.comment?.trim() ? input.comment.trim() : null
|
|
152
|
+
})),
|
|
153
|
+
getAttachmentBytes: getBytes,
|
|
154
|
+
getAttachmentDataUri: async attachment => {
|
|
155
|
+
const bytes = await getBytes(attachment.id);
|
|
156
|
+
return `data:${attachment.contentType};base64,${encodeBase64(bytes)}`;
|
|
157
|
+
},
|
|
158
|
+
confirmAction: token => call(() => http.post(`${base}/actions/confirm`, {
|
|
159
|
+
token
|
|
160
|
+
}))
|
|
161
|
+
};
|
|
162
|
+
};
|
|
163
|
+
exports.createSupportClient = createSupportClient;
|
|
164
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.copyText = void 0;
|
|
7
|
+
/** The one place `expo-clipboard` is named. */
|
|
8
|
+
const copyText = text => require('expo-clipboard').setStringAsync(text);
|
|
9
|
+
exports.copyText = copyText;
|
|
10
|
+
//# sourceMappingURL=clipboard.js.map
|