ajaxter-chat 3.0.12 → 3.0.14
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 +0 -0
- package/dist/components/ChatWidget.js +74 -16
- package/dist/components/PermissionsGateScreen/index.d.ts +8 -0
- package/dist/components/PermissionsGateScreen/index.js +48 -0
- package/dist/components/ViewerBlockedScreen/index.d.ts +8 -0
- package/dist/components/ViewerBlockedScreen/index.js +83 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/types/index.d.ts +29 -0
- package/dist/utils/reenableRequest.d.ts +11 -0
- package/dist/utils/reenableRequest.js +16 -0
- package/dist/utils/widgetPermissions.d.ts +9 -0
- package/dist/utils/widgetPermissions.js +67 -0
- package/package.json +1 -1
- package/public/chatData.json +2 -1
- package/src/components/ChatWidget.tsx +81 -11
- package/src/components/PermissionsGateScreen/index.tsx +105 -0
- package/src/components/ViewerBlockedScreen/index.tsx +148 -0
- package/src/index.ts +3 -1
- package/src/types/index.ts +30 -0
- package/src/utils/reenableRequest.ts +27 -0
- package/src/utils/widgetPermissions.ts +67 -0
package/README.md
CHANGED
|
Binary file
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
-
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
3
|
+
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
|
4
4
|
import { loadLocalConfig } from '../config';
|
|
5
5
|
import { mergeTheme } from '../utils/theme';
|
|
6
6
|
import { useRemoteConfig } from '../hooks/useRemoteConfig';
|
|
@@ -19,8 +19,11 @@ import { BlockListScreen } from './BlockList';
|
|
|
19
19
|
import { CallScreen } from './CallScreen';
|
|
20
20
|
import { MaintenanceView } from './MaintenanceView';
|
|
21
21
|
import { BottomTabs } from './Tabs/BottomTabs';
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
import { ViewerBlockedScreen } from './ViewerBlockedScreen';
|
|
23
|
+
import { PermissionsGateScreen } from './PermissionsGateScreen';
|
|
24
|
+
import { hasStoredPermissionsGrant } from '../utils/widgetPermissions';
|
|
25
|
+
export const ChatWidget = ({ theme: localTheme, viewer }) => {
|
|
26
|
+
var _a, _b, _c, _d, _e, _f;
|
|
24
27
|
/* SSR guard */
|
|
25
28
|
const [mounted, setMounted] = useState(false);
|
|
26
29
|
useEffect(() => { setMounted(true); }, []);
|
|
@@ -42,18 +45,21 @@ export const ChatWidget = ({ theme: localTheme }) => {
|
|
|
42
45
|
const [messageSoundEnabled, setMessageSoundEnabledState] = useState(true);
|
|
43
46
|
/** Stagger list animation only when opening from home burger menu */
|
|
44
47
|
const [listEntranceAnimation, setListEntranceAnimation] = useState(false);
|
|
48
|
+
/** Microphone, geolocation, and screen capture granted for this tab */
|
|
49
|
+
const [permissionsOk, setPermissionsOk] = useState(false);
|
|
45
50
|
/* App state */
|
|
46
51
|
const [tickets, setTickets] = useState((_a = data === null || data === void 0 ? void 0 : data.sampleTickets) !== null && _a !== void 0 ? _a : []);
|
|
47
52
|
const [recentChats, setRecentChats] = useState([]);
|
|
48
53
|
const [blockedUids, setBlockedUids] = useState((_b = data === null || data === void 0 ? void 0 : data.blockedUsers) !== null && _b !== void 0 ? _b : []);
|
|
49
54
|
/* Sync remote data into local state once loaded */
|
|
50
55
|
useEffect(() => {
|
|
51
|
-
var _a, _b;
|
|
56
|
+
var _a, _b, _c;
|
|
52
57
|
if (data) {
|
|
53
58
|
setTickets(data.sampleTickets);
|
|
54
59
|
setBlockedUids(data.blockedUsers);
|
|
55
|
-
|
|
56
|
-
const
|
|
60
|
+
const pid = (_a = viewer === null || viewer === void 0 ? void 0 : viewer.projectId) === null || _a === void 0 ? void 0 : _a.trim();
|
|
61
|
+
const inProject = (u) => !pid || u.project === pid;
|
|
62
|
+
const all = [...((_b = data.developers) !== null && _b !== void 0 ? _b : []), ...((_c = data.users) !== null && _c !== void 0 ? _c : [])].filter(inProject);
|
|
57
63
|
const recents = Object.entries(data.sampleChats).map(([uid, msgs]) => {
|
|
58
64
|
const user = all.find(u => u.uid === uid);
|
|
59
65
|
if (!user || msgs.length === 0)
|
|
@@ -70,7 +76,7 @@ export const ChatWidget = ({ theme: localTheme }) => {
|
|
|
70
76
|
}).filter(Boolean);
|
|
71
77
|
setRecentChats(recents);
|
|
72
78
|
}
|
|
73
|
-
}, [data]);
|
|
79
|
+
}, [data, viewer === null || viewer === void 0 ? void 0 : viewer.projectId]);
|
|
74
80
|
/* Chat hook */
|
|
75
81
|
const { messages, activeUser, isPaused, isReported, selectUser, sendMessage, togglePause, reportChat, clearChat, setMessages, } = useChat();
|
|
76
82
|
/* WebRTC hook */
|
|
@@ -103,13 +109,28 @@ export const ChatWidget = ({ theme: localTheme }) => {
|
|
|
103
109
|
setClosing(false);
|
|
104
110
|
}, 300);
|
|
105
111
|
}, [persistWidgetState]);
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
var _a;
|
|
114
|
+
const id = (_a = data === null || data === void 0 ? void 0 : data.widget) === null || _a === void 0 ? void 0 : _a.id;
|
|
115
|
+
if (!id)
|
|
116
|
+
return;
|
|
117
|
+
setPermissionsOk(hasStoredPermissionsGrant(id));
|
|
118
|
+
}, [(_c = data === null || data === void 0 ? void 0 : data.widget) === null || _c === void 0 ? void 0 : _c.id]);
|
|
106
119
|
const restoredRef = useRef(false);
|
|
107
120
|
useEffect(() => {
|
|
108
|
-
var _a, _b, _c;
|
|
121
|
+
var _a, _b, _c, _d;
|
|
109
122
|
if (!(data === null || data === void 0 ? void 0 : data.widget) || restoredRef.current)
|
|
110
123
|
return;
|
|
111
124
|
const w = data.widget;
|
|
112
125
|
setMessageSoundEnabledState(getMessageSoundEnabled(w.id));
|
|
126
|
+
if (w.viewerBlocked) {
|
|
127
|
+
clearChat();
|
|
128
|
+
setScreen('home');
|
|
129
|
+
setActiveTab('home');
|
|
130
|
+
setViewingTicketId(null);
|
|
131
|
+
restoredRef.current = true;
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
113
134
|
const p = loadSession(w.id);
|
|
114
135
|
if (p) {
|
|
115
136
|
setScreen(p.screen);
|
|
@@ -118,22 +139,35 @@ export const ChatWidget = ({ theme: localTheme }) => {
|
|
|
118
139
|
setViewingTicketId((_a = p.viewingTicketId) !== null && _a !== void 0 ? _a : null);
|
|
119
140
|
setChatReturnCtx((_b = p.chatReturnCtx) !== null && _b !== void 0 ? _b : 'conversation');
|
|
120
141
|
if (p.activeUserUid) {
|
|
121
|
-
const
|
|
142
|
+
const pid = (_c = viewer === null || viewer === void 0 ? void 0 : viewer.projectId) === null || _c === void 0 ? void 0 : _c.trim();
|
|
143
|
+
const pool = pid
|
|
144
|
+
? [...data.developers, ...data.users].filter(u => u.project === pid)
|
|
145
|
+
: [...data.developers, ...data.users];
|
|
146
|
+
const u = pool.find(x => x.uid === p.activeUserUid);
|
|
122
147
|
if (u) {
|
|
123
148
|
const hist = Array.isArray(p.messages) && p.messages.length
|
|
124
149
|
? p.messages
|
|
125
|
-
: ((
|
|
150
|
+
: ((_d = data.sampleChats[u.uid]) !== null && _d !== void 0 ? _d : []);
|
|
126
151
|
selectUser(u, hist);
|
|
127
152
|
}
|
|
128
153
|
}
|
|
129
154
|
}
|
|
130
155
|
restoredRef.current = true;
|
|
131
|
-
}, [data, selectUser]);
|
|
156
|
+
}, [data, selectUser, clearChat, viewer === null || viewer === void 0 ? void 0 : viewer.projectId]);
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
var _a;
|
|
159
|
+
if (!((_a = data === null || data === void 0 ? void 0 : data.widget) === null || _a === void 0 ? void 0 : _a.viewerBlocked))
|
|
160
|
+
return;
|
|
161
|
+
clearChat();
|
|
162
|
+
setScreen('home');
|
|
163
|
+
setActiveTab('home');
|
|
164
|
+
setViewingTicketId(null);
|
|
165
|
+
}, [(_d = data === null || data === void 0 ? void 0 : data.widget) === null || _d === void 0 ? void 0 : _d.viewerBlocked, clearChat]);
|
|
132
166
|
useEffect(() => {
|
|
133
167
|
if (!(data === null || data === void 0 ? void 0 : data.widget))
|
|
134
168
|
return;
|
|
135
169
|
persistWidgetState();
|
|
136
|
-
}, [(
|
|
170
|
+
}, [(_e = data === null || data === void 0 ? void 0 : data.widget) === null || _e === void 0 ? void 0 : _e.id, screen, activeTab, userListCtx, activeUser === null || activeUser === void 0 ? void 0 : activeUser.uid, messages, viewingTicketId, chatReturnCtx, persistWidgetState]);
|
|
137
171
|
const incomingSoundRef = useRef(0);
|
|
138
172
|
useEffect(() => {
|
|
139
173
|
incomingSoundRef.current = messages.length;
|
|
@@ -269,9 +303,31 @@ export const ChatWidget = ({ theme: localTheme }) => {
|
|
|
269
303
|
}, [endCall]);
|
|
270
304
|
/* ── Derived ─────────────────────────────────────────────────────────── */
|
|
271
305
|
const isBlocked = activeUser ? blockedUids.includes(activeUser.uid) : false;
|
|
272
|
-
const widgetConfig =
|
|
306
|
+
const widgetConfig = useMemo(() => {
|
|
307
|
+
var _a;
|
|
308
|
+
if (!(data === null || data === void 0 ? void 0 : data.widget))
|
|
309
|
+
return undefined;
|
|
310
|
+
const w = Object.assign({}, data.widget);
|
|
311
|
+
if (viewer) {
|
|
312
|
+
w.viewerUid = viewer.uid;
|
|
313
|
+
w.viewerName = viewer.name;
|
|
314
|
+
w.viewerType = viewer.type;
|
|
315
|
+
if ((_a = viewer.projectId) === null || _a === void 0 ? void 0 : _a.trim())
|
|
316
|
+
w.viewerProjectId = viewer.projectId.trim();
|
|
317
|
+
}
|
|
318
|
+
return w;
|
|
319
|
+
}, [data === null || data === void 0 ? void 0 : data.widget, viewer]);
|
|
273
320
|
const primaryColor = theme.primaryColor;
|
|
274
|
-
const allUsers =
|
|
321
|
+
const allUsers = useMemo(() => {
|
|
322
|
+
var _a;
|
|
323
|
+
if (!data)
|
|
324
|
+
return [];
|
|
325
|
+
const pid = (_a = viewer === null || viewer === void 0 ? void 0 : viewer.projectId) === null || _a === void 0 ? void 0 : _a.trim();
|
|
326
|
+
const list = [...data.developers, ...data.users];
|
|
327
|
+
if (!pid)
|
|
328
|
+
return list;
|
|
329
|
+
return list.filter(u => u.project === pid);
|
|
330
|
+
}, [data, viewer === null || viewer === void 0 ? void 0 : viewer.projectId]);
|
|
275
331
|
const viewerIsDev = (widgetConfig === null || widgetConfig === void 0 ? void 0 : widgetConfig.viewerType) === 'developer';
|
|
276
332
|
const viewerUid = widgetConfig === null || widgetConfig === void 0 ? void 0 : widgetConfig.viewerUid;
|
|
277
333
|
const filteredUsers = screen === 'user-list'
|
|
@@ -287,7 +343,7 @@ export const ChatWidget = ({ theme: localTheme }) => {
|
|
|
287
343
|
return u.type === 'user';
|
|
288
344
|
})
|
|
289
345
|
: [];
|
|
290
|
-
const otherDevelopers = (
|
|
346
|
+
const otherDevelopers = useMemo(() => allUsers.filter(u => u.type === 'developer' && u.uid !== viewerUid), [allUsers, viewerUid]);
|
|
291
347
|
const blockedUsers = allUsers.filter(u => blockedUids.includes(u.uid));
|
|
292
348
|
const handleTransferToDeveloper = useCallback((dev) => {
|
|
293
349
|
var _a;
|
|
@@ -378,7 +434,7 @@ export const ChatWidget = ({ theme: localTheme }) => {
|
|
|
378
434
|
right: theme.buttonPosition === 'bottom-left' ? 'auto' : 12,
|
|
379
435
|
left: theme.buttonPosition === 'bottom-left' ? 12 : 'auto',
|
|
380
436
|
zIndex: 20, display: 'flex', gap: 6,
|
|
381
|
-
}, children: _jsx(CornerBtn, { onClick: closeDrawer, title: "Close", children: _jsx("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "none", children: _jsx("path", { d: "M18 6L6 18M6 6l12 12", stroke: "#fff", strokeWidth: "2.5", strokeLinecap: "round" }) }) }) })), widgetConfig.status === 'MAINTENANCE' && (_jsx(MaintenanceView, { primaryColor: primaryColor })), widgetConfig.status === 'DISABLE' && (_jsxs("div", { style: { display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', padding: 32, textAlign: 'center', gap: 12 }, children: [_jsx("div", { style: { fontSize: 40 }, children: "\uD83D\uDD12" }), _jsx("p", { style: { fontWeight: 700, color: '#1a2332' }, children: "Chat is disabled" }), _jsx("button", { onClick: closeDrawer, style: { padding: '9px 20px', borderRadius: 10, border: 'none', background: primaryColor, color: '#fff', cursor: 'pointer', fontWeight: 700 }, children: "Close" })] })), widgetConfig.status === 'ACTIVE' && (_jsxs("div", { className: "cw-scroll", style: { flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }, children: [screen === 'home' && (_jsx(HomeScreen, { config: widgetConfig, onNavigate: handleCardClick, onOpenTicket: handleOpenTicket, tickets: tickets })), screen === 'user-list' && (_jsx(UserListScreen, { context: userListCtx, users: filteredUsers, primaryColor: primaryColor, viewerType: (
|
|
437
|
+
}, children: _jsx(CornerBtn, { onClick: closeDrawer, title: "Close", children: _jsx("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "none", children: _jsx("path", { d: "M18 6L6 18M6 6l12 12", stroke: "#fff", strokeWidth: "2.5", strokeLinecap: "round" }) }) }) })), widgetConfig.status === 'MAINTENANCE' && (_jsx(MaintenanceView, { primaryColor: primaryColor })), widgetConfig.status === 'DISABLE' && (_jsxs("div", { style: { display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', padding: 32, textAlign: 'center', gap: 12 }, children: [_jsx("div", { style: { fontSize: 40 }, children: "\uD83D\uDD12" }), _jsx("p", { style: { fontWeight: 700, color: '#1a2332' }, children: "Chat is disabled" }), _jsx("button", { onClick: closeDrawer, style: { padding: '9px 20px', borderRadius: 10, border: 'none', background: primaryColor, color: '#fff', cursor: 'pointer', fontWeight: 700 }, children: "Close" })] })), widgetConfig.status === 'ACTIVE' && widgetConfig.viewerBlocked && (_jsx(ViewerBlockedScreen, { config: widgetConfig, apiKey: apiKey })), widgetConfig.status === 'ACTIVE' && !widgetConfig.viewerBlocked && !permissionsOk && (_jsx(PermissionsGateScreen, { primaryColor: primaryColor, widgetId: widgetConfig.id, onGranted: () => setPermissionsOk(true) })), widgetConfig.status === 'ACTIVE' && !widgetConfig.viewerBlocked && permissionsOk && (_jsxs("div", { className: "cw-scroll", style: { flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }, children: [screen === 'home' && (_jsx(HomeScreen, { config: widgetConfig, onNavigate: handleCardClick, onOpenTicket: handleOpenTicket, tickets: tickets })), screen === 'user-list' && (_jsx(UserListScreen, { context: userListCtx, users: filteredUsers, primaryColor: primaryColor, viewerType: (_f = widgetConfig.viewerType) !== null && _f !== void 0 ? _f : 'user', onBack: () => { setListEntranceAnimation(false); setScreen('home'); }, onSelectUser: handleSelectUser, onBlockList: userListCtx === 'conversation' ? () => setScreen('block-list') : undefined, useHomeHeader: userListCtx === 'support' && widgetConfig.viewerType !== 'developer', animateEntrance: listEntranceAnimation })), screen === 'chat' && activeUser && (_jsx(ChatScreen, { activeUser: activeUser, messages: messages, config: widgetConfig, isPaused: isPaused, isReported: isReported, isBlocked: isBlocked, onSend: sendMessage, onBack: handleBackFromChat, onClose: closeDrawer, onTogglePause: handleTogglePause, onReport: reportChat, onBlock: handleBlock, onStartCall: handleStartCall, onNavAction: handleNavFromMenu, otherDevelopers: otherDevelopers, onTransferToDeveloper: handleTransferToDeveloper, messageSoundEnabled: messageSoundEnabled, onToggleMessageSound: toggleMessageSound })), screen === 'call' && callSession.peer && (_jsx(CallScreen, { session: callSession, localVideoRef: localVideoRef, remoteVideoRef: remoteVideoRef, onEnd: handleEndCall, onToggleMute: toggleMute, onToggleCamera: toggleCamera, primaryColor: primaryColor })), screen === 'recent-chats' && (_jsx(RecentChatsScreen, { chats: recentChats, config: widgetConfig, onSelectChat: u => handleSelectUser(u, listCtxForUser(u, viewerIsDev)), animateEntrance: listEntranceAnimation })), screen === 'tickets' && (_jsx(TicketScreen, { tickets: tickets, config: widgetConfig, onNewTicket: () => { setListEntranceAnimation(false); setScreen('ticket-new'); }, onSelectTicket: id => {
|
|
382
438
|
setListEntranceAnimation(false);
|
|
383
439
|
setViewingTicketId(id);
|
|
384
440
|
setScreen('ticket-detail');
|
|
@@ -386,6 +442,8 @@ export const ChatWidget = ({ theme: localTheme }) => {
|
|
|
386
442
|
const t = tickets.find(x => x.id === viewingTicketId);
|
|
387
443
|
return t ? (_jsx(TicketDetailScreen, { ticket: t, config: widgetConfig, onBack: () => { setViewingTicketId(null); setScreen('tickets'); } })) : null;
|
|
388
444
|
})()), screen === 'block-list' && (_jsx(BlockListScreen, { blockedUsers: blockedUsers, config: widgetConfig, onUnblock: handleUnblock, onBack: () => { setScreen('home'); setActiveTab('home'); } }))] })), widgetConfig.status === 'ACTIVE' &&
|
|
445
|
+
!widgetConfig.viewerBlocked &&
|
|
446
|
+
permissionsOk &&
|
|
389
447
|
screen !== 'chat' &&
|
|
390
448
|
screen !== 'call' &&
|
|
391
449
|
screen !== 'user-list' &&
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { requestWidgetPermissions, storePermissionsGrant } from '../../utils/widgetPermissions';
|
|
5
|
+
const DENIED = 'You cannot use this widget due to less permission granted by you.';
|
|
6
|
+
export const PermissionsGateScreen = ({ primaryColor, widgetId, onGranted, }) => {
|
|
7
|
+
const [phase, setPhase] = useState('prompt');
|
|
8
|
+
const handleAllow = async () => {
|
|
9
|
+
setPhase('checking');
|
|
10
|
+
const ok = await requestWidgetPermissions();
|
|
11
|
+
if (ok) {
|
|
12
|
+
storePermissionsGrant(widgetId);
|
|
13
|
+
onGranted();
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
setPhase('denied');
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
return (_jsx("div", { style: {
|
|
20
|
+
flex: 1,
|
|
21
|
+
display: 'flex',
|
|
22
|
+
flexDirection: 'column',
|
|
23
|
+
alignItems: 'center',
|
|
24
|
+
justifyContent: 'center',
|
|
25
|
+
padding: '28px 22px',
|
|
26
|
+
textAlign: 'center',
|
|
27
|
+
minHeight: 0,
|
|
28
|
+
}, children: phase === 'denied' ? (_jsxs(_Fragment, { children: [_jsx("div", { style: { fontSize: 44, marginBottom: 16 }, children: "\uD83D\uDD12" }), _jsx("p", { style: { margin: '0 0 20px', fontSize: 15, fontWeight: 600, color: '#1e293b', lineHeight: 1.55, maxWidth: 320 }, children: DENIED }), _jsx("p", { style: { margin: '0 0 22px', fontSize: 13, color: '#64748b', lineHeight: 1.5, maxWidth: 340 }, children: "Allow microphone, location, and screen sharing in your browser settings for this site, then try again." }), _jsx("button", { type: "button", onClick: () => { setPhase('prompt'); void handleAllow(); }, style: {
|
|
29
|
+
padding: '12px 22px',
|
|
30
|
+
borderRadius: 12,
|
|
31
|
+
border: 'none',
|
|
32
|
+
background: primaryColor,
|
|
33
|
+
color: '#fff',
|
|
34
|
+
fontWeight: 700,
|
|
35
|
+
fontSize: 14,
|
|
36
|
+
cursor: 'pointer',
|
|
37
|
+
}, children: "Try again" })] })) : (_jsxs(_Fragment, { children: [_jsx("div", { style: { fontSize: 44, marginBottom: 16 }, children: "\uD83C\uDF99\uFE0F" }), _jsx("p", { style: { margin: '0 0 10px', fontSize: 16, fontWeight: 700, color: '#0f172a' }, children: "Permissions required" }), _jsxs("p", { style: { margin: '0 0 8px', fontSize: 14, color: '#475569', lineHeight: 1.55, maxWidth: 340 }, children: ["This widget needs ", _jsx("strong", { children: "microphone" }), " (voice & calls), ", _jsx("strong", { children: "location" }), ", and", ' ', _jsx("strong", { children: "screen sharing" }), " to work."] }), _jsx("p", { style: { margin: '0 0 22px', fontSize: 12, color: '#94a3b8', lineHeight: 1.45, maxWidth: 360 }, children: "You will be asked to pick a screen once \u2014 you can stop sharing immediately after; we only verify access." }), _jsx("button", { type: "button", disabled: phase === 'checking', onClick: handleAllow, style: {
|
|
38
|
+
padding: '14px 28px',
|
|
39
|
+
borderRadius: 12,
|
|
40
|
+
border: 'none',
|
|
41
|
+
background: phase === 'checking' ? '#94a3b8' : primaryColor,
|
|
42
|
+
color: '#fff',
|
|
43
|
+
fontWeight: 700,
|
|
44
|
+
fontSize: 15,
|
|
45
|
+
cursor: phase === 'checking' ? 'default' : 'pointer',
|
|
46
|
+
minWidth: 200,
|
|
47
|
+
}, children: phase === 'checking' ? 'Checking…' : 'Allow & continue' })] })) }));
|
|
48
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { submitReenableRequest } from '../../utils/reenableRequest';
|
|
5
|
+
const DEFAULT_MESSAGE = 'You have been marked as Blocked user due to spam';
|
|
6
|
+
export const ViewerBlockedScreen = ({ config, apiKey }) => {
|
|
7
|
+
var _a, _b;
|
|
8
|
+
const [text, setText] = useState('');
|
|
9
|
+
const [status, setStatus] = useState('idle');
|
|
10
|
+
const [error, setError] = useState(null);
|
|
11
|
+
const primary = config.primaryColor;
|
|
12
|
+
const body = (((_a = config.blockedViewerMessage) === null || _a === void 0 ? void 0 : _a.trim()) || DEFAULT_MESSAGE);
|
|
13
|
+
const url = (_b = config.reenableRequestUrl) === null || _b === void 0 ? void 0 : _b.trim();
|
|
14
|
+
const handleSubmit = async () => {
|
|
15
|
+
var _a;
|
|
16
|
+
if (!url) {
|
|
17
|
+
setError('Re-enable endpoint is not configured. Contact support directly.');
|
|
18
|
+
setStatus('error');
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const msg = text.trim();
|
|
22
|
+
if (!msg) {
|
|
23
|
+
setError('Please describe why you should be re-enabled.');
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
setError(null);
|
|
27
|
+
setStatus('sending');
|
|
28
|
+
try {
|
|
29
|
+
await submitReenableRequest(url, {
|
|
30
|
+
widgetId: config.id,
|
|
31
|
+
apiKey,
|
|
32
|
+
viewerUid: ((_a = config.viewerUid) === null || _a === void 0 ? void 0 : _a.trim()) || undefined,
|
|
33
|
+
message: msg,
|
|
34
|
+
});
|
|
35
|
+
setStatus('sent');
|
|
36
|
+
setText('');
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
setStatus('error');
|
|
40
|
+
setError(e instanceof Error ? e.message : 'Request failed');
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
return (_jsx("div", { className: "cw-scroll", style: {
|
|
44
|
+
flex: 1,
|
|
45
|
+
display: 'flex',
|
|
46
|
+
flexDirection: 'column',
|
|
47
|
+
alignItems: 'center',
|
|
48
|
+
justifyContent: 'center',
|
|
49
|
+
padding: '28px 20px 32px',
|
|
50
|
+
textAlign: 'center',
|
|
51
|
+
overflowY: 'auto',
|
|
52
|
+
minHeight: 0,
|
|
53
|
+
}, children: _jsxs("div", { style: { maxWidth: 380, width: '100%' }, children: [_jsx("div", { style: { fontSize: 44, marginBottom: 16 }, children: "\uD83D\uDEAB" }), _jsx("p", { style: {
|
|
54
|
+
margin: '0 0 28px',
|
|
55
|
+
fontSize: 15,
|
|
56
|
+
fontWeight: 600,
|
|
57
|
+
color: '#1e293b',
|
|
58
|
+
lineHeight: 1.55,
|
|
59
|
+
}, children: body }), status === 'sent' ? (_jsx("p", { style: { margin: 0, fontSize: 14, color: '#16a34a', fontWeight: 600 }, children: "Your request was sent. We will review it shortly." })) : (_jsxs(_Fragment, { children: [_jsx("label", { htmlFor: "cw-reenable-msg", style: { display: 'block', textAlign: 'left', fontSize: 13, fontWeight: 600, color: '#475569', marginBottom: 8 }, children: "Request access restoration" }), _jsx("textarea", { id: "cw-reenable-msg", value: text, onChange: e => { setText(e.target.value); setError(null); setStatus('idle'); }, placeholder: "Explain briefly why your access should be restored\u2026", rows: 4, disabled: status === 'sending', style: {
|
|
60
|
+
width: '100%',
|
|
61
|
+
boxSizing: 'border-box',
|
|
62
|
+
padding: '12px 14px',
|
|
63
|
+
borderRadius: 12,
|
|
64
|
+
border: '1.5px solid #e2e8f0',
|
|
65
|
+
fontSize: 14,
|
|
66
|
+
fontFamily: 'inherit',
|
|
67
|
+
color: '#1e293b',
|
|
68
|
+
resize: 'vertical',
|
|
69
|
+
minHeight: 100,
|
|
70
|
+
marginBottom: 14,
|
|
71
|
+
outline: 'none',
|
|
72
|
+
} }), _jsx("button", { type: "button", onClick: handleSubmit, disabled: status === 'sending' || !text.trim(), style: {
|
|
73
|
+
width: '100%',
|
|
74
|
+
padding: '12px 16px',
|
|
75
|
+
borderRadius: 12,
|
|
76
|
+
border: 'none',
|
|
77
|
+
background: text.trim() && status !== 'sending' ? primary : '#e2e8f0',
|
|
78
|
+
color: text.trim() && status !== 'sending' ? '#fff' : '#94a3b8',
|
|
79
|
+
fontWeight: 700,
|
|
80
|
+
fontSize: 15,
|
|
81
|
+
cursor: text.trim() && status !== 'sending' ? 'pointer' : 'default',
|
|
82
|
+
}, children: status === 'sending' ? 'Sending…' : 'Submit request' }), error && (_jsx("p", { style: { margin: '12px 0 0', fontSize: 13, color: '#dc2626', lineHeight: 1.45 }, children: error })), !url && (_jsxs("p", { style: { margin: '14px 0 0', fontSize: 12, color: '#94a3b8', lineHeight: 1.5 }, children: ["Your administrator must set ", _jsx("code", { style: { fontSize: 11 }, children: "reenableRequestUrl" }), " in widget config for online requests."] }))] }))] }) }));
|
|
83
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -14,7 +14,9 @@ export { useChat } from './hooks/useChat';
|
|
|
14
14
|
export { useWebRTC } from './hooks/useWebRTC';
|
|
15
15
|
export { useRemoteConfig } from './hooks/useRemoteConfig';
|
|
16
16
|
export { shouldShowPrivacyNotice, dismissPrivacyNotice, getPrivacyDismissedAt } from './utils/privacyConsent';
|
|
17
|
+
export { submitReenableRequest } from './utils/reenableRequest';
|
|
18
|
+
export type { ReenableRequestPayload } from './utils/reenableRequest';
|
|
17
19
|
export { loadLocalConfig, fetchRemoteChatData } from './config';
|
|
18
20
|
export { mergeTheme, darken } from './utils/theme';
|
|
19
21
|
export { avatarColor, initials, formatTime, formatDate, generateTranscript, downloadText, truncateWords } from './utils/chat';
|
|
20
|
-
export type { ChatWidgetProps, ChatWidgetTheme, WidgetConfig, RemoteChatData, ChatUser, ChatMessage, Ticket, RecentChat, CallSession, CallState, ChatStatus, ChatType, UserType, OnlineStatus, Screen, BottomTab, UserListContext, MessageType, LocalEnvConfig, } from './types';
|
|
22
|
+
export type { ChatWidgetProps, ChatWidgetTheme, ChatWidgetViewer, WidgetConfig, RemoteChatData, ChatUser, ChatMessage, Ticket, RecentChat, CallSession, CallState, ChatStatus, ChatType, UserType, OnlineStatus, Screen, BottomTab, UserListContext, MessageType, LocalEnvConfig, } from './types';
|
package/dist/index.js
CHANGED
|
@@ -14,6 +14,7 @@ export { useChat } from './hooks/useChat';
|
|
|
14
14
|
export { useWebRTC } from './hooks/useWebRTC';
|
|
15
15
|
export { useRemoteConfig } from './hooks/useRemoteConfig';
|
|
16
16
|
export { shouldShowPrivacyNotice, dismissPrivacyNotice, getPrivacyDismissedAt } from './utils/privacyConsent';
|
|
17
|
+
export { submitReenableRequest } from './utils/reenableRequest';
|
|
17
18
|
export { loadLocalConfig, fetchRemoteChatData } from './config';
|
|
18
19
|
export { mergeTheme, darken } from './utils/theme';
|
|
19
20
|
export { avatarColor, initials, formatTime, formatDate, generateTranscript, downloadText, truncateWords } from './utils/chat';
|
package/dist/types/index.d.ts
CHANGED
|
@@ -23,6 +23,11 @@ export interface WidgetConfig {
|
|
|
23
23
|
viewerUid?: string;
|
|
24
24
|
/** Display name for transfer notes (optional) */
|
|
25
25
|
viewerName?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Host app project scope (set when embedding passes `viewer.projectId`).
|
|
28
|
+
* Use for API calls; lists can be filtered to users in the same project.
|
|
29
|
+
*/
|
|
30
|
+
viewerProjectId?: string;
|
|
26
31
|
/** Privacy Policy URL (linked from chat consent banner) */
|
|
27
32
|
privacyPolicyUrl?: string;
|
|
28
33
|
/** Set false to hide the consent note above the composer */
|
|
@@ -41,6 +46,18 @@ export interface WidgetConfig {
|
|
|
41
46
|
allowTranscriptDownload: boolean;
|
|
42
47
|
allowReport: boolean;
|
|
43
48
|
allowBlock: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* When `true` (set by the server if this viewer is spam-blocked or not in allowed user/ticket/chat lists),
|
|
51
|
+
* the widget hides all normal navigation and shows only the blocked-user screen with a re-enable request form.
|
|
52
|
+
*/
|
|
53
|
+
viewerBlocked?: boolean;
|
|
54
|
+
/** Optional override for the blocked message (default is a fixed spam notice). */
|
|
55
|
+
blockedViewerMessage?: string;
|
|
56
|
+
/**
|
|
57
|
+
* Absolute URL for `POST` JSON re-enable requests. If omitted, the submit button explains that no endpoint is configured.
|
|
58
|
+
* @example https://api.example.com/widgets/reenable-request
|
|
59
|
+
*/
|
|
60
|
+
reenableRequestUrl?: string;
|
|
44
61
|
}
|
|
45
62
|
export interface RemoteChatData {
|
|
46
63
|
widget: WidgetConfig;
|
|
@@ -126,6 +143,18 @@ export interface ChatWidgetTheme {
|
|
|
126
143
|
buttonPosition?: 'bottom-right' | 'bottom-left';
|
|
127
144
|
borderRadius?: string;
|
|
128
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* Pass the logged-in user from your React app so the widget matches identity and UI (user vs developer).
|
|
148
|
+
* Overrides `viewerUid`, `viewerName`, `viewerType` from remote `chatData.json` when provided.
|
|
149
|
+
*/
|
|
150
|
+
export interface ChatWidgetViewer {
|
|
151
|
+
uid: string;
|
|
152
|
+
name: string;
|
|
153
|
+
type: UserType;
|
|
154
|
+
/** When set, directory lists only include users whose `ChatUser.project` equals this string (exact match). */
|
|
155
|
+
projectId?: string;
|
|
156
|
+
}
|
|
129
157
|
export interface ChatWidgetProps {
|
|
130
158
|
theme?: ChatWidgetTheme;
|
|
159
|
+
viewer?: ChatWidgetViewer;
|
|
131
160
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Payload sent when a blocked viewer requests access restoration.
|
|
3
|
+
* Backend should validate `apiKey` + `widgetId` and associate `viewerUid` with the session.
|
|
4
|
+
*/
|
|
5
|
+
export interface ReenableRequestPayload {
|
|
6
|
+
widgetId: string;
|
|
7
|
+
apiKey: string;
|
|
8
|
+
viewerUid?: string;
|
|
9
|
+
message: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function submitReenableRequest(url: string, payload: ReenableRequestPayload): Promise<void>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export async function submitReenableRequest(url, payload) {
|
|
2
|
+
const res = await fetch(url, {
|
|
3
|
+
method: 'POST',
|
|
4
|
+
headers: {
|
|
5
|
+
Accept: 'application/json',
|
|
6
|
+
'Content-Type': 'application/json',
|
|
7
|
+
},
|
|
8
|
+
body: JSON.stringify(payload),
|
|
9
|
+
mode: 'cors',
|
|
10
|
+
credentials: 'omit',
|
|
11
|
+
});
|
|
12
|
+
if (!res.ok) {
|
|
13
|
+
const text = await res.text().catch(() => '');
|
|
14
|
+
throw new Error(text || `Request failed (${res.status})`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Requests microphone, geolocation, and screen-capture access required by the widget.
|
|
3
|
+
* Stops all tracks immediately after success (probe only).
|
|
4
|
+
*/
|
|
5
|
+
export declare function requestWidgetPermissions(): Promise<boolean>;
|
|
6
|
+
export declare function permissionsSessionKey(widgetId: string): string;
|
|
7
|
+
export declare function hasStoredPermissionsGrant(widgetId: string): boolean;
|
|
8
|
+
export declare function storePermissionsGrant(widgetId: string): void;
|
|
9
|
+
export declare function clearStoredPermissionsGrant(widgetId: string): void;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Requests microphone, geolocation, and screen-capture access required by the widget.
|
|
3
|
+
* Stops all tracks immediately after success (probe only).
|
|
4
|
+
*/
|
|
5
|
+
export async function requestWidgetPermissions() {
|
|
6
|
+
var _a, _b, _c;
|
|
7
|
+
if (typeof navigator === 'undefined')
|
|
8
|
+
return false;
|
|
9
|
+
try {
|
|
10
|
+
if (!((_a = navigator.mediaDevices) === null || _a === void 0 ? void 0 : _a.getUserMedia))
|
|
11
|
+
return false;
|
|
12
|
+
const mic = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
13
|
+
mic.getTracks().forEach(t => t.stop());
|
|
14
|
+
}
|
|
15
|
+
catch (_d) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
if (!((_b = navigator.geolocation) === null || _b === void 0 ? void 0 : _b.getCurrentPosition))
|
|
20
|
+
return false;
|
|
21
|
+
await new Promise((resolve, reject) => {
|
|
22
|
+
navigator.geolocation.getCurrentPosition(() => resolve(), e => reject(e), { enableHighAccuracy: false, timeout: 20000, maximumAge: 60000 });
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
catch (_e) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
if (!((_c = navigator.mediaDevices) === null || _c === void 0 ? void 0 : _c.getDisplayMedia))
|
|
30
|
+
return false;
|
|
31
|
+
const screen = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false });
|
|
32
|
+
screen.getTracks().forEach(t => t.stop());
|
|
33
|
+
}
|
|
34
|
+
catch (_f) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
export function permissionsSessionKey(widgetId) {
|
|
40
|
+
return `ajaxter_widget_permissions_ok_${widgetId}`;
|
|
41
|
+
}
|
|
42
|
+
export function hasStoredPermissionsGrant(widgetId) {
|
|
43
|
+
if (typeof sessionStorage === 'undefined')
|
|
44
|
+
return false;
|
|
45
|
+
try {
|
|
46
|
+
return sessionStorage.getItem(permissionsSessionKey(widgetId)) === '1';
|
|
47
|
+
}
|
|
48
|
+
catch (_a) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function storePermissionsGrant(widgetId) {
|
|
53
|
+
try {
|
|
54
|
+
sessionStorage.setItem(permissionsSessionKey(widgetId), '1');
|
|
55
|
+
}
|
|
56
|
+
catch (_a) {
|
|
57
|
+
/* quota */
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export function clearStoredPermissionsGrant(widgetId) {
|
|
61
|
+
try {
|
|
62
|
+
sessionStorage.removeItem(permissionsSessionKey(widgetId));
|
|
63
|
+
}
|
|
64
|
+
catch (_a) {
|
|
65
|
+
/* */
|
|
66
|
+
}
|
|
67
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ajaxter-chat",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.14",
|
|
4
4
|
"description": "Drawer-based chat widget with support chat, tickets, WebRTC calling, voice messages, block list, and transcript download.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
package/public/chatData.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
3
|
+
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
|
4
4
|
import { ChatWidgetProps, BottomTab, Screen, UserListContext, ChatUser, Ticket, RecentChat, ChatMessage } from '../types';
|
|
5
5
|
import { loadLocalConfig } from '../config';
|
|
6
6
|
import { mergeTheme } from '../utils/theme';
|
|
@@ -21,8 +21,11 @@ import { BlockListScreen } from './BlockList';
|
|
|
21
21
|
import { CallScreen } from './CallScreen';
|
|
22
22
|
import { MaintenanceView } from './MaintenanceView';
|
|
23
23
|
import { BottomTabs } from './Tabs/BottomTabs';
|
|
24
|
+
import { ViewerBlockedScreen } from './ViewerBlockedScreen';
|
|
25
|
+
import { PermissionsGateScreen } from './PermissionsGateScreen';
|
|
26
|
+
import { hasStoredPermissionsGrant } from '../utils/widgetPermissions';
|
|
24
27
|
|
|
25
|
-
export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) => {
|
|
28
|
+
export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewer }) => {
|
|
26
29
|
/* SSR guard */
|
|
27
30
|
const [mounted, setMounted] = useState(false);
|
|
28
31
|
useEffect(() => { setMounted(true); }, []);
|
|
@@ -52,6 +55,8 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
|
|
|
52
55
|
const [messageSoundEnabled, setMessageSoundEnabledState] = useState(true);
|
|
53
56
|
/** Stagger list animation only when opening from home burger menu */
|
|
54
57
|
const [listEntranceAnimation, setListEntranceAnimation] = useState(false);
|
|
58
|
+
/** Microphone, geolocation, and screen capture granted for this tab */
|
|
59
|
+
const [permissionsOk, setPermissionsOk] = useState(false);
|
|
55
60
|
|
|
56
61
|
/* App state */
|
|
57
62
|
const [tickets, setTickets] = useState<Ticket[]>(data?.sampleTickets ?? []);
|
|
@@ -63,8 +68,9 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
|
|
|
63
68
|
if (data) {
|
|
64
69
|
setTickets(data.sampleTickets);
|
|
65
70
|
setBlockedUids(data.blockedUsers);
|
|
66
|
-
|
|
67
|
-
const
|
|
71
|
+
const pid = viewer?.projectId?.trim();
|
|
72
|
+
const inProject = (u: ChatUser) => !pid || u.project === pid;
|
|
73
|
+
const all = [...(data.developers ?? []), ...(data.users ?? [])].filter(inProject);
|
|
68
74
|
const recents: RecentChat[] = Object.entries(data.sampleChats).map(([uid, msgs]) => {
|
|
69
75
|
const user = all.find(u => u.uid === uid);
|
|
70
76
|
if (!user || msgs.length === 0) return null;
|
|
@@ -80,7 +86,7 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
|
|
|
80
86
|
}).filter(Boolean) as RecentChat[];
|
|
81
87
|
setRecentChats(recents);
|
|
82
88
|
}
|
|
83
|
-
}, [data]);
|
|
89
|
+
}, [data, viewer?.projectId]);
|
|
84
90
|
|
|
85
91
|
/* Chat hook */
|
|
86
92
|
const {
|
|
@@ -120,11 +126,25 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
|
|
|
120
126
|
}, 300);
|
|
121
127
|
}, [persistWidgetState]);
|
|
122
128
|
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
const id = data?.widget?.id;
|
|
131
|
+
if (!id) return;
|
|
132
|
+
setPermissionsOk(hasStoredPermissionsGrant(id));
|
|
133
|
+
}, [data?.widget?.id]);
|
|
134
|
+
|
|
123
135
|
const restoredRef = useRef(false);
|
|
124
136
|
useEffect(() => {
|
|
125
137
|
if (!data?.widget || restoredRef.current) return;
|
|
126
138
|
const w = data.widget;
|
|
127
139
|
setMessageSoundEnabledState(getMessageSoundEnabled(w.id));
|
|
140
|
+
if (w.viewerBlocked) {
|
|
141
|
+
clearChat();
|
|
142
|
+
setScreen('home');
|
|
143
|
+
setActiveTab('home');
|
|
144
|
+
setViewingTicketId(null);
|
|
145
|
+
restoredRef.current = true;
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
128
148
|
const p = loadSession(w.id);
|
|
129
149
|
if (p) {
|
|
130
150
|
setScreen(p.screen);
|
|
@@ -133,7 +153,11 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
|
|
|
133
153
|
setViewingTicketId(p.viewingTicketId ?? null);
|
|
134
154
|
setChatReturnCtx(p.chatReturnCtx ?? 'conversation');
|
|
135
155
|
if (p.activeUserUid) {
|
|
136
|
-
const
|
|
156
|
+
const pid = viewer?.projectId?.trim();
|
|
157
|
+
const pool = pid
|
|
158
|
+
? [...data.developers, ...data.users].filter(u => u.project === pid)
|
|
159
|
+
: [...data.developers, ...data.users];
|
|
160
|
+
const u = pool.find(x => x.uid === p.activeUserUid);
|
|
137
161
|
if (u) {
|
|
138
162
|
const hist = Array.isArray(p.messages) && p.messages.length
|
|
139
163
|
? p.messages
|
|
@@ -143,7 +167,15 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
|
|
|
143
167
|
}
|
|
144
168
|
}
|
|
145
169
|
restoredRef.current = true;
|
|
146
|
-
}, [data, selectUser]);
|
|
170
|
+
}, [data, selectUser, clearChat, viewer?.projectId]);
|
|
171
|
+
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
if (!data?.widget?.viewerBlocked) return;
|
|
174
|
+
clearChat();
|
|
175
|
+
setScreen('home');
|
|
176
|
+
setActiveTab('home');
|
|
177
|
+
setViewingTicketId(null);
|
|
178
|
+
}, [data?.widget?.viewerBlocked, clearChat]);
|
|
147
179
|
|
|
148
180
|
useEffect(() => {
|
|
149
181
|
if (!data?.widget) return;
|
|
@@ -291,10 +323,29 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
|
|
|
291
323
|
|
|
292
324
|
/* ── Derived ─────────────────────────────────────────────────────────── */
|
|
293
325
|
const isBlocked = activeUser ? blockedUids.includes(activeUser.uid) : false;
|
|
294
|
-
|
|
326
|
+
|
|
327
|
+
const widgetConfig = useMemo(() => {
|
|
328
|
+
if (!data?.widget) return undefined;
|
|
329
|
+
const w = { ...data.widget };
|
|
330
|
+
if (viewer) {
|
|
331
|
+
w.viewerUid = viewer.uid;
|
|
332
|
+
w.viewerName = viewer.name;
|
|
333
|
+
w.viewerType = viewer.type;
|
|
334
|
+
if (viewer.projectId?.trim()) w.viewerProjectId = viewer.projectId.trim();
|
|
335
|
+
}
|
|
336
|
+
return w;
|
|
337
|
+
}, [data?.widget, viewer]);
|
|
338
|
+
|
|
295
339
|
const primaryColor = theme.primaryColor;
|
|
296
340
|
|
|
297
|
-
const allUsers
|
|
341
|
+
const allUsers = useMemo(() => {
|
|
342
|
+
if (!data) return [];
|
|
343
|
+
const pid = viewer?.projectId?.trim();
|
|
344
|
+
const list = [...data.developers, ...data.users];
|
|
345
|
+
if (!pid) return list;
|
|
346
|
+
return list.filter(u => u.project === pid);
|
|
347
|
+
}, [data, viewer?.projectId]);
|
|
348
|
+
|
|
298
349
|
const viewerIsDev = widgetConfig?.viewerType === 'developer';
|
|
299
350
|
const viewerUid = widgetConfig?.viewerUid;
|
|
300
351
|
|
|
@@ -311,7 +362,10 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
|
|
|
311
362
|
})
|
|
312
363
|
: [];
|
|
313
364
|
|
|
314
|
-
const otherDevelopers =
|
|
365
|
+
const otherDevelopers = useMemo(
|
|
366
|
+
() => allUsers.filter(u => u.type === 'developer' && u.uid !== viewerUid),
|
|
367
|
+
[allUsers, viewerUid],
|
|
368
|
+
);
|
|
315
369
|
const blockedUsers = allUsers.filter(u => blockedUids.includes(u.uid));
|
|
316
370
|
|
|
317
371
|
const handleTransferToDeveloper = useCallback((dev: ChatUser) => {
|
|
@@ -512,8 +566,22 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
|
|
|
512
566
|
</div>
|
|
513
567
|
)}
|
|
514
568
|
|
|
569
|
+
{/* ── ACTIVE: viewer spam-blocked (no chat/tickets UI) ── */}
|
|
570
|
+
{widgetConfig.status === 'ACTIVE' && widgetConfig.viewerBlocked && (
|
|
571
|
+
<ViewerBlockedScreen config={widgetConfig} apiKey={apiKey} />
|
|
572
|
+
)}
|
|
573
|
+
|
|
574
|
+
{/* ── ACTIVE: microphone, location, screen share required ── */}
|
|
575
|
+
{widgetConfig.status === 'ACTIVE' && !widgetConfig.viewerBlocked && !permissionsOk && (
|
|
576
|
+
<PermissionsGateScreen
|
|
577
|
+
primaryColor={primaryColor}
|
|
578
|
+
widgetId={widgetConfig.id}
|
|
579
|
+
onGranted={() => setPermissionsOk(true)}
|
|
580
|
+
/>
|
|
581
|
+
)}
|
|
582
|
+
|
|
515
583
|
{/* ── ACTIVE ── */}
|
|
516
|
-
{widgetConfig.status === 'ACTIVE' && (
|
|
584
|
+
{widgetConfig.status === 'ACTIVE' && !widgetConfig.viewerBlocked && permissionsOk && (
|
|
517
585
|
<div className="cw-scroll" style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
|
518
586
|
|
|
519
587
|
{screen === 'home' && (
|
|
@@ -631,6 +699,8 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
|
|
|
631
699
|
|
|
632
700
|
{/* ── Bottom Tabs (hidden during chat/call/user-list/block-list) ── */}
|
|
633
701
|
{widgetConfig.status === 'ACTIVE' &&
|
|
702
|
+
!widgetConfig.viewerBlocked &&
|
|
703
|
+
permissionsOk &&
|
|
634
704
|
screen !== 'chat' &&
|
|
635
705
|
screen !== 'call' &&
|
|
636
706
|
screen !== 'user-list' &&
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState } from 'react';
|
|
4
|
+
import { requestWidgetPermissions, storePermissionsGrant } from '../../utils/widgetPermissions';
|
|
5
|
+
|
|
6
|
+
const DENIED =
|
|
7
|
+
'You cannot use this widget due to less permission granted by you.';
|
|
8
|
+
|
|
9
|
+
interface PermissionsGateScreenProps {
|
|
10
|
+
primaryColor: string;
|
|
11
|
+
widgetId: string;
|
|
12
|
+
onGranted: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const PermissionsGateScreen: React.FC<PermissionsGateScreenProps> = ({
|
|
16
|
+
primaryColor,
|
|
17
|
+
widgetId,
|
|
18
|
+
onGranted,
|
|
19
|
+
}) => {
|
|
20
|
+
const [phase, setPhase] = useState<'prompt' | 'checking' | 'denied'>('prompt');
|
|
21
|
+
|
|
22
|
+
const handleAllow = async () => {
|
|
23
|
+
setPhase('checking');
|
|
24
|
+
const ok = await requestWidgetPermissions();
|
|
25
|
+
if (ok) {
|
|
26
|
+
storePermissionsGrant(widgetId);
|
|
27
|
+
onGranted();
|
|
28
|
+
} else {
|
|
29
|
+
setPhase('denied');
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div
|
|
35
|
+
style={{
|
|
36
|
+
flex: 1,
|
|
37
|
+
display: 'flex',
|
|
38
|
+
flexDirection: 'column',
|
|
39
|
+
alignItems: 'center',
|
|
40
|
+
justifyContent: 'center',
|
|
41
|
+
padding: '28px 22px',
|
|
42
|
+
textAlign: 'center',
|
|
43
|
+
minHeight: 0,
|
|
44
|
+
}}
|
|
45
|
+
>
|
|
46
|
+
{phase === 'denied' ? (
|
|
47
|
+
<>
|
|
48
|
+
<div style={{ fontSize: 44, marginBottom: 16 }}>🔒</div>
|
|
49
|
+
<p style={{ margin: '0 0 20px', fontSize: 15, fontWeight: 600, color: '#1e293b', lineHeight: 1.55, maxWidth: 320 }}>
|
|
50
|
+
{DENIED}
|
|
51
|
+
</p>
|
|
52
|
+
<p style={{ margin: '0 0 22px', fontSize: 13, color: '#64748b', lineHeight: 1.5, maxWidth: 340 }}>
|
|
53
|
+
Allow microphone, location, and screen sharing in your browser settings for this site, then try again.
|
|
54
|
+
</p>
|
|
55
|
+
<button
|
|
56
|
+
type="button"
|
|
57
|
+
onClick={() => { setPhase('prompt'); void handleAllow(); }}
|
|
58
|
+
style={{
|
|
59
|
+
padding: '12px 22px',
|
|
60
|
+
borderRadius: 12,
|
|
61
|
+
border: 'none',
|
|
62
|
+
background: primaryColor,
|
|
63
|
+
color: '#fff',
|
|
64
|
+
fontWeight: 700,
|
|
65
|
+
fontSize: 14,
|
|
66
|
+
cursor: 'pointer',
|
|
67
|
+
}}
|
|
68
|
+
>
|
|
69
|
+
Try again
|
|
70
|
+
</button>
|
|
71
|
+
</>
|
|
72
|
+
) : (
|
|
73
|
+
<>
|
|
74
|
+
<div style={{ fontSize: 44, marginBottom: 16 }}>🎙️</div>
|
|
75
|
+
<p style={{ margin: '0 0 10px', fontSize: 16, fontWeight: 700, color: '#0f172a' }}>Permissions required</p>
|
|
76
|
+
<p style={{ margin: '0 0 8px', fontSize: 14, color: '#475569', lineHeight: 1.55, maxWidth: 340 }}>
|
|
77
|
+
This widget needs <strong>microphone</strong> (voice & calls), <strong>location</strong>, and{' '}
|
|
78
|
+
<strong>screen sharing</strong> to work.
|
|
79
|
+
</p>
|
|
80
|
+
<p style={{ margin: '0 0 22px', fontSize: 12, color: '#94a3b8', lineHeight: 1.45, maxWidth: 360 }}>
|
|
81
|
+
You will be asked to pick a screen once — you can stop sharing immediately after; we only verify access.
|
|
82
|
+
</p>
|
|
83
|
+
<button
|
|
84
|
+
type="button"
|
|
85
|
+
disabled={phase === 'checking'}
|
|
86
|
+
onClick={handleAllow}
|
|
87
|
+
style={{
|
|
88
|
+
padding: '14px 28px',
|
|
89
|
+
borderRadius: 12,
|
|
90
|
+
border: 'none',
|
|
91
|
+
background: phase === 'checking' ? '#94a3b8' : primaryColor,
|
|
92
|
+
color: '#fff',
|
|
93
|
+
fontWeight: 700,
|
|
94
|
+
fontSize: 15,
|
|
95
|
+
cursor: phase === 'checking' ? 'default' : 'pointer',
|
|
96
|
+
minWidth: 200,
|
|
97
|
+
}}
|
|
98
|
+
>
|
|
99
|
+
{phase === 'checking' ? 'Checking…' : 'Allow & continue'}
|
|
100
|
+
</button>
|
|
101
|
+
</>
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState } from 'react';
|
|
4
|
+
import { WidgetConfig } from '../../types';
|
|
5
|
+
import { submitReenableRequest } from '../../utils/reenableRequest';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_MESSAGE =
|
|
8
|
+
'You have been marked as Blocked user due to spam';
|
|
9
|
+
|
|
10
|
+
interface ViewerBlockedScreenProps {
|
|
11
|
+
config: WidgetConfig;
|
|
12
|
+
apiKey: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const ViewerBlockedScreen: React.FC<ViewerBlockedScreenProps> = ({ config, apiKey }) => {
|
|
16
|
+
const [text, setText] = useState('');
|
|
17
|
+
const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
|
|
18
|
+
const [error, setError] = useState<string | null>(null);
|
|
19
|
+
|
|
20
|
+
const primary = config.primaryColor;
|
|
21
|
+
const body = (config.blockedViewerMessage?.trim() || DEFAULT_MESSAGE);
|
|
22
|
+
const url = config.reenableRequestUrl?.trim();
|
|
23
|
+
|
|
24
|
+
const handleSubmit = async () => {
|
|
25
|
+
if (!url) {
|
|
26
|
+
setError('Re-enable endpoint is not configured. Contact support directly.');
|
|
27
|
+
setStatus('error');
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const msg = text.trim();
|
|
31
|
+
if (!msg) {
|
|
32
|
+
setError('Please describe why you should be re-enabled.');
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
setError(null);
|
|
36
|
+
setStatus('sending');
|
|
37
|
+
try {
|
|
38
|
+
await submitReenableRequest(url, {
|
|
39
|
+
widgetId: config.id,
|
|
40
|
+
apiKey,
|
|
41
|
+
viewerUid: config.viewerUid?.trim() || undefined,
|
|
42
|
+
message: msg,
|
|
43
|
+
});
|
|
44
|
+
setStatus('sent');
|
|
45
|
+
setText('');
|
|
46
|
+
} catch (e) {
|
|
47
|
+
setStatus('error');
|
|
48
|
+
setError(e instanceof Error ? e.message : 'Request failed');
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div
|
|
54
|
+
className="cw-scroll"
|
|
55
|
+
style={{
|
|
56
|
+
flex: 1,
|
|
57
|
+
display: 'flex',
|
|
58
|
+
flexDirection: 'column',
|
|
59
|
+
alignItems: 'center',
|
|
60
|
+
justifyContent: 'center',
|
|
61
|
+
padding: '28px 20px 32px',
|
|
62
|
+
textAlign: 'center',
|
|
63
|
+
overflowY: 'auto',
|
|
64
|
+
minHeight: 0,
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
<div style={{ maxWidth: 380, width: '100%' }}>
|
|
68
|
+
<div style={{ fontSize: 44, marginBottom: 16 }}>🚫</div>
|
|
69
|
+
<p
|
|
70
|
+
style={{
|
|
71
|
+
margin: '0 0 28px',
|
|
72
|
+
fontSize: 15,
|
|
73
|
+
fontWeight: 600,
|
|
74
|
+
color: '#1e293b',
|
|
75
|
+
lineHeight: 1.55,
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
{body}
|
|
79
|
+
</p>
|
|
80
|
+
|
|
81
|
+
{status === 'sent' ? (
|
|
82
|
+
<p style={{ margin: 0, fontSize: 14, color: '#16a34a', fontWeight: 600 }}>
|
|
83
|
+
Your request was sent. We will review it shortly.
|
|
84
|
+
</p>
|
|
85
|
+
) : (
|
|
86
|
+
<>
|
|
87
|
+
<label
|
|
88
|
+
htmlFor="cw-reenable-msg"
|
|
89
|
+
style={{ display: 'block', textAlign: 'left', fontSize: 13, fontWeight: 600, color: '#475569', marginBottom: 8 }}
|
|
90
|
+
>
|
|
91
|
+
Request access restoration
|
|
92
|
+
</label>
|
|
93
|
+
<textarea
|
|
94
|
+
id="cw-reenable-msg"
|
|
95
|
+
value={text}
|
|
96
|
+
onChange={e => { setText(e.target.value); setError(null); setStatus('idle'); }}
|
|
97
|
+
placeholder="Explain briefly why your access should be restored…"
|
|
98
|
+
rows={4}
|
|
99
|
+
disabled={status === 'sending'}
|
|
100
|
+
style={{
|
|
101
|
+
width: '100%',
|
|
102
|
+
boxSizing: 'border-box',
|
|
103
|
+
padding: '12px 14px',
|
|
104
|
+
borderRadius: 12,
|
|
105
|
+
border: '1.5px solid #e2e8f0',
|
|
106
|
+
fontSize: 14,
|
|
107
|
+
fontFamily: 'inherit',
|
|
108
|
+
color: '#1e293b',
|
|
109
|
+
resize: 'vertical',
|
|
110
|
+
minHeight: 100,
|
|
111
|
+
marginBottom: 14,
|
|
112
|
+
outline: 'none',
|
|
113
|
+
}}
|
|
114
|
+
/>
|
|
115
|
+
<button
|
|
116
|
+
type="button"
|
|
117
|
+
onClick={handleSubmit}
|
|
118
|
+
disabled={status === 'sending' || !text.trim()}
|
|
119
|
+
style={{
|
|
120
|
+
width: '100%',
|
|
121
|
+
padding: '12px 16px',
|
|
122
|
+
borderRadius: 12,
|
|
123
|
+
border: 'none',
|
|
124
|
+
background: text.trim() && status !== 'sending' ? primary : '#e2e8f0',
|
|
125
|
+
color: text.trim() && status !== 'sending' ? '#fff' : '#94a3b8',
|
|
126
|
+
fontWeight: 700,
|
|
127
|
+
fontSize: 15,
|
|
128
|
+
cursor: text.trim() && status !== 'sending' ? 'pointer' : 'default',
|
|
129
|
+
}}
|
|
130
|
+
>
|
|
131
|
+
{status === 'sending' ? 'Sending…' : 'Submit request'}
|
|
132
|
+
</button>
|
|
133
|
+
{error && (
|
|
134
|
+
<p style={{ margin: '12px 0 0', fontSize: 13, color: '#dc2626', lineHeight: 1.45 }}>
|
|
135
|
+
{error}
|
|
136
|
+
</p>
|
|
137
|
+
)}
|
|
138
|
+
{!url && (
|
|
139
|
+
<p style={{ margin: '14px 0 0', fontSize: 12, color: '#94a3b8', lineHeight: 1.5 }}>
|
|
140
|
+
Your administrator must set <code style={{ fontSize: 11 }}>reenableRequestUrl</code> in widget config for online requests.
|
|
141
|
+
</p>
|
|
142
|
+
)}
|
|
143
|
+
</>
|
|
144
|
+
)}
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
);
|
|
148
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -16,12 +16,14 @@ export { useWebRTC } from './hooks/useWebRTC';
|
|
|
16
16
|
export { useRemoteConfig } from './hooks/useRemoteConfig';
|
|
17
17
|
|
|
18
18
|
export { shouldShowPrivacyNotice, dismissPrivacyNotice, getPrivacyDismissedAt } from './utils/privacyConsent';
|
|
19
|
+
export { submitReenableRequest } from './utils/reenableRequest';
|
|
20
|
+
export type { ReenableRequestPayload } from './utils/reenableRequest';
|
|
19
21
|
export { loadLocalConfig, fetchRemoteChatData } from './config';
|
|
20
22
|
export { mergeTheme, darken } from './utils/theme';
|
|
21
23
|
export { avatarColor, initials, formatTime, formatDate, generateTranscript, downloadText, truncateWords } from './utils/chat';
|
|
22
24
|
|
|
23
25
|
export type {
|
|
24
|
-
ChatWidgetProps, ChatWidgetTheme,
|
|
26
|
+
ChatWidgetProps, ChatWidgetTheme, ChatWidgetViewer,
|
|
25
27
|
WidgetConfig, RemoteChatData,
|
|
26
28
|
ChatUser, ChatMessage, Ticket, RecentChat,
|
|
27
29
|
CallSession, CallState,
|
package/src/types/index.ts
CHANGED
|
@@ -24,6 +24,11 @@ export interface WidgetConfig {
|
|
|
24
24
|
viewerUid?: string;
|
|
25
25
|
/** Display name for transfer notes (optional) */
|
|
26
26
|
viewerName?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Host app project scope (set when embedding passes `viewer.projectId`).
|
|
29
|
+
* Use for API calls; lists can be filtered to users in the same project.
|
|
30
|
+
*/
|
|
31
|
+
viewerProjectId?: string;
|
|
27
32
|
/** Privacy Policy URL (linked from chat consent banner) */
|
|
28
33
|
privacyPolicyUrl?: string;
|
|
29
34
|
/** Set false to hide the consent note above the composer */
|
|
@@ -42,6 +47,18 @@ export interface WidgetConfig {
|
|
|
42
47
|
allowTranscriptDownload: boolean;
|
|
43
48
|
allowReport: boolean;
|
|
44
49
|
allowBlock: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* When `true` (set by the server if this viewer is spam-blocked or not in allowed user/ticket/chat lists),
|
|
52
|
+
* the widget hides all normal navigation and shows only the blocked-user screen with a re-enable request form.
|
|
53
|
+
*/
|
|
54
|
+
viewerBlocked?: boolean;
|
|
55
|
+
/** Optional override for the blocked message (default is a fixed spam notice). */
|
|
56
|
+
blockedViewerMessage?: string;
|
|
57
|
+
/**
|
|
58
|
+
* Absolute URL for `POST` JSON re-enable requests. If omitted, the submit button explains that no endpoint is configured.
|
|
59
|
+
* @example https://api.example.com/widgets/reenable-request
|
|
60
|
+
*/
|
|
61
|
+
reenableRequestUrl?: string;
|
|
45
62
|
}
|
|
46
63
|
|
|
47
64
|
export interface RemoteChatData {
|
|
@@ -155,6 +172,19 @@ export interface ChatWidgetTheme {
|
|
|
155
172
|
borderRadius?: string;
|
|
156
173
|
}
|
|
157
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Pass the logged-in user from your React app so the widget matches identity and UI (user vs developer).
|
|
177
|
+
* Overrides `viewerUid`, `viewerName`, `viewerType` from remote `chatData.json` when provided.
|
|
178
|
+
*/
|
|
179
|
+
export interface ChatWidgetViewer {
|
|
180
|
+
uid: string;
|
|
181
|
+
name: string;
|
|
182
|
+
type: UserType;
|
|
183
|
+
/** When set, directory lists only include users whose `ChatUser.project` equals this string (exact match). */
|
|
184
|
+
projectId?: string;
|
|
185
|
+
}
|
|
186
|
+
|
|
158
187
|
export interface ChatWidgetProps {
|
|
159
188
|
theme?: ChatWidgetTheme;
|
|
189
|
+
viewer?: ChatWidgetViewer;
|
|
160
190
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Payload sent when a blocked viewer requests access restoration.
|
|
3
|
+
* Backend should validate `apiKey` + `widgetId` and associate `viewerUid` with the session.
|
|
4
|
+
*/
|
|
5
|
+
export interface ReenableRequestPayload {
|
|
6
|
+
widgetId: string;
|
|
7
|
+
apiKey: string;
|
|
8
|
+
viewerUid?: string;
|
|
9
|
+
message: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function submitReenableRequest(url: string, payload: ReenableRequestPayload): Promise<void> {
|
|
13
|
+
const res = await fetch(url, {
|
|
14
|
+
method: 'POST',
|
|
15
|
+
headers: {
|
|
16
|
+
Accept: 'application/json',
|
|
17
|
+
'Content-Type': 'application/json',
|
|
18
|
+
},
|
|
19
|
+
body: JSON.stringify(payload),
|
|
20
|
+
mode: 'cors',
|
|
21
|
+
credentials: 'omit',
|
|
22
|
+
});
|
|
23
|
+
if (!res.ok) {
|
|
24
|
+
const text = await res.text().catch(() => '');
|
|
25
|
+
throw new Error(text || `Request failed (${res.status})`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Requests microphone, geolocation, and screen-capture access required by the widget.
|
|
3
|
+
* Stops all tracks immediately after success (probe only).
|
|
4
|
+
*/
|
|
5
|
+
export async function requestWidgetPermissions(): Promise<boolean> {
|
|
6
|
+
if (typeof navigator === 'undefined') return false;
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
if (!navigator.mediaDevices?.getUserMedia) return false;
|
|
10
|
+
const mic = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
11
|
+
mic.getTracks().forEach(t => t.stop());
|
|
12
|
+
} catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
if (!navigator.geolocation?.getCurrentPosition) return false;
|
|
18
|
+
await new Promise<void>((resolve, reject) => {
|
|
19
|
+
navigator.geolocation.getCurrentPosition(
|
|
20
|
+
() => resolve(),
|
|
21
|
+
e => reject(e),
|
|
22
|
+
{ enableHighAccuracy: false, timeout: 20_000, maximumAge: 60_000 },
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
if (!navigator.mediaDevices?.getDisplayMedia) return false;
|
|
31
|
+
const screen = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false });
|
|
32
|
+
screen.getTracks().forEach(t => t.stop());
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function permissionsSessionKey(widgetId: string): string {
|
|
41
|
+
return `ajaxter_widget_permissions_ok_${widgetId}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function hasStoredPermissionsGrant(widgetId: string): boolean {
|
|
45
|
+
if (typeof sessionStorage === 'undefined') return false;
|
|
46
|
+
try {
|
|
47
|
+
return sessionStorage.getItem(permissionsSessionKey(widgetId)) === '1';
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function storePermissionsGrant(widgetId: string): void {
|
|
54
|
+
try {
|
|
55
|
+
sessionStorage.setItem(permissionsSessionKey(widgetId), '1');
|
|
56
|
+
} catch {
|
|
57
|
+
/* quota */
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function clearStoredPermissionsGrant(widgetId: string): void {
|
|
62
|
+
try {
|
|
63
|
+
sessionStorage.removeItem(permissionsSessionKey(widgetId));
|
|
64
|
+
} catch {
|
|
65
|
+
/* */
|
|
66
|
+
}
|
|
67
|
+
}
|