ajaxter-chat 3.0.13 → 3.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
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';
@@ -20,8 +20,10 @@ import { CallScreen } from './CallScreen';
20
20
  import { MaintenanceView } from './MaintenanceView';
21
21
  import { BottomTabs } from './Tabs/BottomTabs';
22
22
  import { ViewerBlockedScreen } from './ViewerBlockedScreen';
23
- export const ChatWidget = ({ theme: localTheme }) => {
24
- var _a, _b, _c, _d, _e, _f;
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;
25
27
  /* SSR guard */
26
28
  const [mounted, setMounted] = useState(false);
27
29
  useEffect(() => { setMounted(true); }, []);
@@ -43,18 +45,22 @@ export const ChatWidget = ({ theme: localTheme }) => {
43
45
  const [messageSoundEnabled, setMessageSoundEnabledState] = useState(true);
44
46
  /** Stagger list animation only when opening from home burger menu */
45
47
  const [listEntranceAnimation, setListEntranceAnimation] = useState(false);
48
+ /** Microphone, geolocation, and screen capture granted for this tab */
49
+ const [permissionsOk, setPermissionsOk] = useState(false);
46
50
  /* App state */
47
51
  const [tickets, setTickets] = useState((_a = data === null || data === void 0 ? void 0 : data.sampleTickets) !== null && _a !== void 0 ? _a : []);
48
52
  const [recentChats, setRecentChats] = useState([]);
49
53
  const [blockedUids, setBlockedUids] = useState((_b = data === null || data === void 0 ? void 0 : data.blockedUsers) !== null && _b !== void 0 ? _b : []);
50
54
  /* Sync remote data into local state once loaded */
51
55
  useEffect(() => {
52
- var _a, _b;
56
+ var _a, _b, _c, _d;
53
57
  if (data) {
54
58
  setTickets(data.sampleTickets);
55
59
  setBlockedUids(data.blockedUsers);
56
- // Seed recent chats from sample chats
57
- const all = [...((_a = data.developers) !== null && _a !== void 0 ? _a : []), ...((_b = data.users) !== null && _b !== void 0 ? _b : [])];
60
+ const pid = (_a = viewer === null || viewer === void 0 ? void 0 : viewer.projectId) === null || _a === void 0 ? void 0 : _a.trim();
61
+ const devs = (_b = data.developers) !== null && _b !== void 0 ? _b : [];
62
+ const usr = pid ? ((_c = data.users) !== null && _c !== void 0 ? _c : []).filter(u => u.project === pid) : ((_d = data.users) !== null && _d !== void 0 ? _d : []);
63
+ const all = [...devs, ...usr];
58
64
  const recents = Object.entries(data.sampleChats).map(([uid, msgs]) => {
59
65
  const user = all.find(u => u.uid === uid);
60
66
  if (!user || msgs.length === 0)
@@ -71,7 +77,7 @@ export const ChatWidget = ({ theme: localTheme }) => {
71
77
  }).filter(Boolean);
72
78
  setRecentChats(recents);
73
79
  }
74
- }, [data]);
80
+ }, [data, viewer === null || viewer === void 0 ? void 0 : viewer.projectId]);
75
81
  /* Chat hook */
76
82
  const { messages, activeUser, isPaused, isReported, selectUser, sendMessage, togglePause, reportChat, clearChat, setMessages, } = useChat();
77
83
  /* WebRTC hook */
@@ -104,14 +110,27 @@ export const ChatWidget = ({ theme: localTheme }) => {
104
110
  setClosing(false);
105
111
  }, 300);
106
112
  }, [persistWidgetState]);
113
+ useEffect(() => {
114
+ var _a;
115
+ const id = (_a = data === null || data === void 0 ? void 0 : data.widget) === null || _a === void 0 ? void 0 : _a.id;
116
+ if (!id)
117
+ return;
118
+ setPermissionsOk(hasStoredPermissionsGrant(id));
119
+ }, [(_c = data === null || data === void 0 ? void 0 : data.widget) === null || _c === void 0 ? void 0 : _c.id]);
107
120
  const restoredRef = useRef(false);
108
121
  useEffect(() => {
109
- var _a, _b, _c;
122
+ var _a, _b, _c, _d, _e, _f;
110
123
  if (!(data === null || data === void 0 ? void 0 : data.widget) || restoredRef.current)
111
124
  return;
112
125
  const w = data.widget;
113
126
  setMessageSoundEnabledState(getMessageSoundEnabled(w.id));
114
- if (w.viewerBlocked) {
127
+ const uidForBlock = (_b = ((_a = viewer === null || viewer === void 0 ? void 0 : viewer.uid) !== null && _a !== void 0 ? _a : w.viewerUid)) === null || _b === void 0 ? void 0 : _b.trim();
128
+ let viewerIsBlocked = w.viewerBlocked === true;
129
+ if (!viewerIsBlocked && uidForBlock) {
130
+ const rec = [...data.developers, ...data.users].find(x => x.uid === uidForBlock);
131
+ viewerIsBlocked = (rec === null || rec === void 0 ? void 0 : rec.viewerBlocked) === true;
132
+ }
133
+ if (viewerIsBlocked) {
115
134
  clearChat();
116
135
  setScreen('home');
117
136
  setActiveTab('home');
@@ -124,29 +143,42 @@ export const ChatWidget = ({ theme: localTheme }) => {
124
143
  setScreen(p.screen);
125
144
  setActiveTab(p.activeTab);
126
145
  setUserListCtx(p.userListCtx);
127
- setViewingTicketId((_a = p.viewingTicketId) !== null && _a !== void 0 ? _a : null);
128
- setChatReturnCtx((_b = p.chatReturnCtx) !== null && _b !== void 0 ? _b : 'conversation');
146
+ setViewingTicketId((_c = p.viewingTicketId) !== null && _c !== void 0 ? _c : null);
147
+ setChatReturnCtx((_d = p.chatReturnCtx) !== null && _d !== void 0 ? _d : 'conversation');
129
148
  if (p.activeUserUid) {
130
- const u = [...data.developers, ...data.users].find(x => x.uid === p.activeUserUid);
149
+ const pid = (_e = viewer === null || viewer === void 0 ? void 0 : viewer.projectId) === null || _e === void 0 ? void 0 : _e.trim();
150
+ const pool = pid
151
+ ? [...data.developers, ...data.users].filter(u => u.project === pid)
152
+ : [...data.developers, ...data.users];
153
+ const u = pool.find(x => x.uid === p.activeUserUid);
131
154
  if (u) {
132
155
  const hist = Array.isArray(p.messages) && p.messages.length
133
156
  ? p.messages
134
- : ((_c = data.sampleChats[u.uid]) !== null && _c !== void 0 ? _c : []);
157
+ : ((_f = data.sampleChats[u.uid]) !== null && _f !== void 0 ? _f : []);
135
158
  selectUser(u, hist);
136
159
  }
137
160
  }
138
161
  }
139
162
  restoredRef.current = true;
140
- }, [data, selectUser, clearChat]);
163
+ }, [data, selectUser, clearChat, viewer === null || viewer === void 0 ? void 0 : viewer.projectId, viewer === null || viewer === void 0 ? void 0 : viewer.uid]);
141
164
  useEffect(() => {
142
- var _a;
143
- if (!((_a = data === null || data === void 0 ? void 0 : data.widget) === null || _a === void 0 ? void 0 : _a.viewerBlocked))
165
+ var _a, _b;
166
+ if (!(data === null || data === void 0 ? void 0 : data.widget))
167
+ return;
168
+ const w = data.widget;
169
+ const uid = (_b = ((_a = viewer === null || viewer === void 0 ? void 0 : viewer.uid) !== null && _a !== void 0 ? _a : w.viewerUid)) === null || _b === void 0 ? void 0 : _b.trim();
170
+ let blocked = w.viewerBlocked === true;
171
+ if (!blocked && uid) {
172
+ const rec = [...data.developers, ...data.users].find(x => x.uid === uid);
173
+ blocked = (rec === null || rec === void 0 ? void 0 : rec.viewerBlocked) === true;
174
+ }
175
+ if (!blocked)
144
176
  return;
145
177
  clearChat();
146
178
  setScreen('home');
147
179
  setActiveTab('home');
148
180
  setViewingTicketId(null);
149
- }, [(_c = data === null || data === void 0 ? void 0 : data.widget) === null || _c === void 0 ? void 0 : _c.viewerBlocked, clearChat]);
181
+ }, [data === null || data === void 0 ? void 0 : data.widget, data === null || data === void 0 ? void 0 : data.developers, data === null || data === void 0 ? void 0 : data.users, viewer === null || viewer === void 0 ? void 0 : viewer.uid, clearChat]);
150
182
  useEffect(() => {
151
183
  if (!(data === null || data === void 0 ? void 0 : data.widget))
152
184
  return;
@@ -287,9 +319,45 @@ export const ChatWidget = ({ theme: localTheme }) => {
287
319
  }, [endCall]);
288
320
  /* ── Derived ─────────────────────────────────────────────────────────── */
289
321
  const isBlocked = activeUser ? blockedUids.includes(activeUser.uid) : false;
290
- const widgetConfig = data === null || data === void 0 ? void 0 : data.widget;
322
+ const widgetConfig = useMemo(() => {
323
+ var _a;
324
+ if (!(data === null || data === void 0 ? void 0 : data.widget))
325
+ return undefined;
326
+ const w = Object.assign({}, data.widget);
327
+ if (viewer) {
328
+ w.viewerUid = viewer.uid;
329
+ w.viewerName = viewer.name;
330
+ w.viewerType = viewer.type;
331
+ if ((_a = viewer.projectId) === null || _a === void 0 ? void 0 : _a.trim())
332
+ w.viewerProjectId = viewer.projectId.trim();
333
+ }
334
+ return w;
335
+ }, [data === null || data === void 0 ? void 0 : data.widget, viewer]);
291
336
  const primaryColor = theme.primaryColor;
292
- const allUsers = data ? [...data.developers, ...data.users] : [];
337
+ /** All developers are listed; only end-`user` rows are filtered by `viewer.projectId`. */
338
+ const allUsers = useMemo(() => {
339
+ var _a, _b;
340
+ if (!data)
341
+ return [];
342
+ const pid = (_a = viewer === null || viewer === void 0 ? void 0 : viewer.projectId) === null || _a === void 0 ? void 0 : _a.trim();
343
+ const devs = (_b = data.developers) !== null && _b !== void 0 ? _b : [];
344
+ if (!pid)
345
+ return [...devs, ...data.users];
346
+ const usersInProject = data.users.filter(u => u.project === pid);
347
+ return [...devs, ...usersInProject];
348
+ }, [data, viewer === null || viewer === void 0 ? void 0 : viewer.projectId]);
349
+ const effectiveViewerBlocked = useMemo(() => {
350
+ var _a, _b;
351
+ if (!widgetConfig)
352
+ return false;
353
+ if (widgetConfig.viewerBlocked === true)
354
+ return true;
355
+ const uid = (_b = ((_a = viewer === null || viewer === void 0 ? void 0 : viewer.uid) !== null && _a !== void 0 ? _a : widgetConfig.viewerUid)) === null || _b === void 0 ? void 0 : _b.trim();
356
+ if (!uid || !data)
357
+ return false;
358
+ const rec = [...data.developers, ...data.users].find(x => x.uid === uid);
359
+ return (rec === null || rec === void 0 ? void 0 : rec.viewerBlocked) === true;
360
+ }, [widgetConfig, viewer === null || viewer === void 0 ? void 0 : viewer.uid, data]);
293
361
  const viewerIsDev = (widgetConfig === null || widgetConfig === void 0 ? void 0 : widgetConfig.viewerType) === 'developer';
294
362
  const viewerUid = widgetConfig === null || widgetConfig === void 0 ? void 0 : widgetConfig.viewerUid;
295
363
  const filteredUsers = screen === 'user-list'
@@ -305,7 +373,7 @@ export const ChatWidget = ({ theme: localTheme }) => {
305
373
  return u.type === 'user';
306
374
  })
307
375
  : [];
308
- const otherDevelopers = (_e = data === null || data === void 0 ? void 0 : data.developers.filter(d => d.uid !== viewerUid)) !== null && _e !== void 0 ? _e : [];
376
+ const otherDevelopers = useMemo(() => allUsers.filter(u => u.type === 'developer' && u.uid !== viewerUid), [allUsers, viewerUid]);
309
377
  const blockedUsers = allUsers.filter(u => blockedUids.includes(u.uid));
310
378
  const handleTransferToDeveloper = useCallback((dev) => {
311
379
  var _a;
@@ -396,7 +464,7 @@ export const ChatWidget = ({ theme: localTheme }) => {
396
464
  right: theme.buttonPosition === 'bottom-left' ? 'auto' : 12,
397
465
  left: theme.buttonPosition === 'bottom-left' ? 12 : 'auto',
398
466
  zIndex: 20, display: 'flex', gap: 6,
399
- }, 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 && (_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 => {
467
+ }, 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' && effectiveViewerBlocked && (_jsx(ViewerBlockedScreen, { config: widgetConfig, apiKey: apiKey })), widgetConfig.status === 'ACTIVE' && !effectiveViewerBlocked && !permissionsOk && (_jsx(PermissionsGateScreen, { primaryColor: primaryColor, widgetId: widgetConfig.id, onGranted: () => setPermissionsOk(true) })), widgetConfig.status === 'ACTIVE' && !effectiveViewerBlocked && 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: (_e = widgetConfig.viewerType) !== null && _e !== void 0 ? _e : '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 => {
400
468
  setListEntranceAnimation(false);
401
469
  setViewingTicketId(id);
402
470
  setScreen('ticket-detail');
@@ -404,7 +472,8 @@ export const ChatWidget = ({ theme: localTheme }) => {
404
472
  const t = tickets.find(x => x.id === viewingTicketId);
405
473
  return t ? (_jsx(TicketDetailScreen, { ticket: t, config: widgetConfig, onBack: () => { setViewingTicketId(null); setScreen('tickets'); } })) : null;
406
474
  })()), screen === 'block-list' && (_jsx(BlockListScreen, { blockedUsers: blockedUsers, config: widgetConfig, onUnblock: handleUnblock, onBack: () => { setScreen('home'); setActiveTab('home'); } }))] })), widgetConfig.status === 'ACTIVE' &&
407
- !widgetConfig.viewerBlocked &&
475
+ !effectiveViewerBlocked &&
476
+ permissionsOk &&
408
477
  screen !== 'chat' &&
409
478
  screen !== 'call' &&
410
479
  screen !== 'user-list' &&
@@ -0,0 +1,8 @@
1
+ import React from 'react';
2
+ interface PermissionsGateScreenProps {
3
+ primaryColor: string;
4
+ widgetId: string;
5
+ onGranted: () => void;
6
+ }
7
+ export declare const PermissionsGateScreen: React.FC<PermissionsGateScreenProps>;
8
+ export {};
@@ -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
+ };
package/dist/index.d.ts CHANGED
@@ -19,4 +19,4 @@ export type { ReenableRequestPayload } from './utils/reenableRequest';
19
19
  export { loadLocalConfig, fetchRemoteChatData } from './config';
20
20
  export { mergeTheme, darken } from './utils/theme';
21
21
  export { avatarColor, initials, formatTime, formatDate, generateTranscript, downloadText, truncateWords } from './utils/chat';
22
- 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';
@@ -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 */
@@ -80,6 +85,11 @@ export interface ChatUser {
80
85
  avatar: string | null;
81
86
  status: OnlineStatus;
82
87
  designation: string;
88
+ /**
89
+ * When `true` for the row matching the current viewer (`viewerUid` / `viewer.uid`),
90
+ * the widget shows the spam/blocked screen (same as `widget.viewerBlocked`).
91
+ */
92
+ viewerBlocked?: boolean;
83
93
  }
84
94
  export interface ChatMessage {
85
95
  id: string;
@@ -138,6 +148,18 @@ export interface ChatWidgetTheme {
138
148
  buttonPosition?: 'bottom-right' | 'bottom-left';
139
149
  borderRadius?: string;
140
150
  }
151
+ /**
152
+ * Pass the logged-in user from your React app so the widget matches identity and UI (user vs developer).
153
+ * Overrides `viewerUid`, `viewerName`, `viewerType` from remote `chatData.json` when provided.
154
+ */
155
+ export interface ChatWidgetViewer {
156
+ uid: string;
157
+ name: string;
158
+ type: UserType;
159
+ /** When set, directory lists only include users whose `ChatUser.project` equals this string (exact match). */
160
+ projectId?: string;
161
+ }
141
162
  export interface ChatWidgetProps {
142
163
  theme?: ChatWidgetTheme;
164
+ viewer?: ChatWidgetViewer;
143
165
  }
@@ -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.13",
3
+ "version": "3.0.15",
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",
@@ -62,7 +62,8 @@
62
62
  "type": "user",
63
63
  "avatar": null,
64
64
  "status": "online",
65
- "designation": "Product Manager"
65
+ "designation": "Product Manager",
66
+ "viewerBlocked": false
66
67
  },
67
68
  {
68
69
  "uid": "usr_002",
@@ -73,7 +74,8 @@
73
74
  "type": "user",
74
75
  "avatar": null,
75
76
  "status": "away",
76
- "designation": "Business Analyst"
77
+ "designation": "Business Analyst",
78
+ "viewerBlocked": true
77
79
  },
78
80
  {
79
81
  "uid": "usr_003",
@@ -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';
@@ -22,8 +22,10 @@ import { CallScreen } from './CallScreen';
22
22
  import { MaintenanceView } from './MaintenanceView';
23
23
  import { BottomTabs } from './Tabs/BottomTabs';
24
24
  import { ViewerBlockedScreen } from './ViewerBlockedScreen';
25
+ import { PermissionsGateScreen } from './PermissionsGateScreen';
26
+ import { hasStoredPermissionsGrant } from '../utils/widgetPermissions';
25
27
 
26
- export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) => {
28
+ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewer }) => {
27
29
  /* SSR guard */
28
30
  const [mounted, setMounted] = useState(false);
29
31
  useEffect(() => { setMounted(true); }, []);
@@ -53,6 +55,8 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
53
55
  const [messageSoundEnabled, setMessageSoundEnabledState] = useState(true);
54
56
  /** Stagger list animation only when opening from home burger menu */
55
57
  const [listEntranceAnimation, setListEntranceAnimation] = useState(false);
58
+ /** Microphone, geolocation, and screen capture granted for this tab */
59
+ const [permissionsOk, setPermissionsOk] = useState(false);
56
60
 
57
61
  /* App state */
58
62
  const [tickets, setTickets] = useState<Ticket[]>(data?.sampleTickets ?? []);
@@ -64,8 +68,10 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
64
68
  if (data) {
65
69
  setTickets(data.sampleTickets);
66
70
  setBlockedUids(data.blockedUsers);
67
- // Seed recent chats from sample chats
68
- const all = [...(data.developers ?? []), ...(data.users ?? [])];
71
+ const pid = viewer?.projectId?.trim();
72
+ const devs = data.developers ?? [];
73
+ const usr = pid ? (data.users ?? []).filter(u => u.project === pid) : (data.users ?? []);
74
+ const all = [...devs, ...usr];
69
75
  const recents: RecentChat[] = Object.entries(data.sampleChats).map(([uid, msgs]) => {
70
76
  const user = all.find(u => u.uid === uid);
71
77
  if (!user || msgs.length === 0) return null;
@@ -81,7 +87,7 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
81
87
  }).filter(Boolean) as RecentChat[];
82
88
  setRecentChats(recents);
83
89
  }
84
- }, [data]);
90
+ }, [data, viewer?.projectId]);
85
91
 
86
92
  /* Chat hook */
87
93
  const {
@@ -121,12 +127,24 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
121
127
  }, 300);
122
128
  }, [persistWidgetState]);
123
129
 
130
+ useEffect(() => {
131
+ const id = data?.widget?.id;
132
+ if (!id) return;
133
+ setPermissionsOk(hasStoredPermissionsGrant(id));
134
+ }, [data?.widget?.id]);
135
+
124
136
  const restoredRef = useRef(false);
125
137
  useEffect(() => {
126
138
  if (!data?.widget || restoredRef.current) return;
127
139
  const w = data.widget;
128
140
  setMessageSoundEnabledState(getMessageSoundEnabled(w.id));
129
- if (w.viewerBlocked) {
141
+ const uidForBlock = (viewer?.uid ?? w.viewerUid)?.trim();
142
+ let viewerIsBlocked = w.viewerBlocked === true;
143
+ if (!viewerIsBlocked && uidForBlock) {
144
+ const rec = [...data.developers, ...data.users].find(x => x.uid === uidForBlock);
145
+ viewerIsBlocked = rec?.viewerBlocked === true;
146
+ }
147
+ if (viewerIsBlocked) {
130
148
  clearChat();
131
149
  setScreen('home');
132
150
  setActiveTab('home');
@@ -142,7 +160,11 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
142
160
  setViewingTicketId(p.viewingTicketId ?? null);
143
161
  setChatReturnCtx(p.chatReturnCtx ?? 'conversation');
144
162
  if (p.activeUserUid) {
145
- const u = [...data.developers, ...data.users].find(x => x.uid === p.activeUserUid);
163
+ const pid = viewer?.projectId?.trim();
164
+ const pool = pid
165
+ ? [...data.developers, ...data.users].filter(u => u.project === pid)
166
+ : [...data.developers, ...data.users];
167
+ const u = pool.find(x => x.uid === p.activeUserUid);
146
168
  if (u) {
147
169
  const hist = Array.isArray(p.messages) && p.messages.length
148
170
  ? p.messages
@@ -152,15 +174,23 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
152
174
  }
153
175
  }
154
176
  restoredRef.current = true;
155
- }, [data, selectUser, clearChat]);
177
+ }, [data, selectUser, clearChat, viewer?.projectId, viewer?.uid]);
156
178
 
157
179
  useEffect(() => {
158
- if (!data?.widget?.viewerBlocked) return;
180
+ if (!data?.widget) return;
181
+ const w = data.widget;
182
+ const uid = (viewer?.uid ?? w.viewerUid)?.trim();
183
+ let blocked = w.viewerBlocked === true;
184
+ if (!blocked && uid) {
185
+ const rec = [...data.developers, ...data.users].find(x => x.uid === uid);
186
+ blocked = rec?.viewerBlocked === true;
187
+ }
188
+ if (!blocked) return;
159
189
  clearChat();
160
190
  setScreen('home');
161
191
  setActiveTab('home');
162
192
  setViewingTicketId(null);
163
- }, [data?.widget?.viewerBlocked, clearChat]);
193
+ }, [data?.widget, data?.developers, data?.users, viewer?.uid, clearChat]);
164
194
 
165
195
  useEffect(() => {
166
196
  if (!data?.widget) return;
@@ -308,10 +338,40 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
308
338
 
309
339
  /* ── Derived ─────────────────────────────────────────────────────────── */
310
340
  const isBlocked = activeUser ? blockedUids.includes(activeUser.uid) : false;
311
- const widgetConfig = data?.widget;
341
+
342
+ const widgetConfig = useMemo(() => {
343
+ if (!data?.widget) return undefined;
344
+ const w = { ...data.widget };
345
+ if (viewer) {
346
+ w.viewerUid = viewer.uid;
347
+ w.viewerName = viewer.name;
348
+ w.viewerType = viewer.type;
349
+ if (viewer.projectId?.trim()) w.viewerProjectId = viewer.projectId.trim();
350
+ }
351
+ return w;
352
+ }, [data?.widget, viewer]);
353
+
312
354
  const primaryColor = theme.primaryColor;
313
355
 
314
- const allUsers = data ? [...data.developers, ...data.users] : [];
356
+ /** All developers are listed; only end-`user` rows are filtered by `viewer.projectId`. */
357
+ const allUsers = useMemo(() => {
358
+ if (!data) return [];
359
+ const pid = viewer?.projectId?.trim();
360
+ const devs = data.developers ?? [];
361
+ if (!pid) return [...devs, ...data.users];
362
+ const usersInProject = data.users.filter(u => u.project === pid);
363
+ return [...devs, ...usersInProject];
364
+ }, [data, viewer?.projectId]);
365
+
366
+ const effectiveViewerBlocked = useMemo(() => {
367
+ if (!widgetConfig) return false;
368
+ if (widgetConfig.viewerBlocked === true) return true;
369
+ const uid = (viewer?.uid ?? widgetConfig.viewerUid)?.trim();
370
+ if (!uid || !data) return false;
371
+ const rec = [...data.developers, ...data.users].find(x => x.uid === uid);
372
+ return rec?.viewerBlocked === true;
373
+ }, [widgetConfig, viewer?.uid, data]);
374
+
315
375
  const viewerIsDev = widgetConfig?.viewerType === 'developer';
316
376
  const viewerUid = widgetConfig?.viewerUid;
317
377
 
@@ -328,7 +388,10 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
328
388
  })
329
389
  : [];
330
390
 
331
- const otherDevelopers = data?.developers.filter(d => d.uid !== viewerUid) ?? [];
391
+ const otherDevelopers = useMemo(
392
+ () => allUsers.filter(u => u.type === 'developer' && u.uid !== viewerUid),
393
+ [allUsers, viewerUid],
394
+ );
332
395
  const blockedUsers = allUsers.filter(u => blockedUids.includes(u.uid));
333
396
 
334
397
  const handleTransferToDeveloper = useCallback((dev: ChatUser) => {
@@ -530,12 +593,21 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
530
593
  )}
531
594
 
532
595
  {/* ── ACTIVE: viewer spam-blocked (no chat/tickets UI) ── */}
533
- {widgetConfig.status === 'ACTIVE' && widgetConfig.viewerBlocked && (
596
+ {widgetConfig.status === 'ACTIVE' && effectiveViewerBlocked && (
534
597
  <ViewerBlockedScreen config={widgetConfig} apiKey={apiKey} />
535
598
  )}
536
599
 
600
+ {/* ── ACTIVE: microphone, location, screen share required ── */}
601
+ {widgetConfig.status === 'ACTIVE' && !effectiveViewerBlocked && !permissionsOk && (
602
+ <PermissionsGateScreen
603
+ primaryColor={primaryColor}
604
+ widgetId={widgetConfig.id}
605
+ onGranted={() => setPermissionsOk(true)}
606
+ />
607
+ )}
608
+
537
609
  {/* ── ACTIVE ── */}
538
- {widgetConfig.status === 'ACTIVE' && !widgetConfig.viewerBlocked && (
610
+ {widgetConfig.status === 'ACTIVE' && !effectiveViewerBlocked && permissionsOk && (
539
611
  <div className="cw-scroll" style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
540
612
 
541
613
  {screen === 'home' && (
@@ -653,7 +725,8 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
653
725
 
654
726
  {/* ── Bottom Tabs (hidden during chat/call/user-list/block-list) ── */}
655
727
  {widgetConfig.status === 'ACTIVE' &&
656
- !widgetConfig.viewerBlocked &&
728
+ !effectiveViewerBlocked &&
729
+ permissionsOk &&
657
730
  screen !== 'chat' &&
658
731
  screen !== 'call' &&
659
732
  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 &amp; 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
+ };
package/src/index.ts CHANGED
@@ -23,7 +23,7 @@ export { mergeTheme, darken } from './utils/theme';
23
23
  export { avatarColor, initials, formatTime, formatDate, generateTranscript, downloadText, truncateWords } from './utils/chat';
24
24
 
25
25
  export type {
26
- ChatWidgetProps, ChatWidgetTheme,
26
+ ChatWidgetProps, ChatWidgetTheme, ChatWidgetViewer,
27
27
  WidgetConfig, RemoteChatData,
28
28
  ChatUser, ChatMessage, Ticket, RecentChat,
29
29
  CallSession, CallState,
@@ -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 */
@@ -95,6 +100,11 @@ export interface ChatUser {
95
100
  avatar: string | null;
96
101
  status: OnlineStatus;
97
102
  designation: string;
103
+ /**
104
+ * When `true` for the row matching the current viewer (`viewerUid` / `viewer.uid`),
105
+ * the widget shows the spam/blocked screen (same as `widget.viewerBlocked`).
106
+ */
107
+ viewerBlocked?: boolean;
98
108
  }
99
109
 
100
110
  // ─── Message ────────────────────────────────────────────────────────────────
@@ -167,6 +177,19 @@ export interface ChatWidgetTheme {
167
177
  borderRadius?: string;
168
178
  }
169
179
 
180
+ /**
181
+ * Pass the logged-in user from your React app so the widget matches identity and UI (user vs developer).
182
+ * Overrides `viewerUid`, `viewerName`, `viewerType` from remote `chatData.json` when provided.
183
+ */
184
+ export interface ChatWidgetViewer {
185
+ uid: string;
186
+ name: string;
187
+ type: UserType;
188
+ /** When set, directory lists only include users whose `ChatUser.project` equals this string (exact match). */
189
+ projectId?: string;
190
+ }
191
+
170
192
  export interface ChatWidgetProps {
171
193
  theme?: ChatWidgetTheme;
194
+ viewer?: ChatWidgetViewer;
172
195
  }
@@ -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
+ }