ajaxter-chat 3.0.13 → 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 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,7 +20,9 @@ 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 }) => {
23
+ import { PermissionsGateScreen } from './PermissionsGateScreen';
24
+ import { hasStoredPermissionsGrant } from '../utils/widgetPermissions';
25
+ export const ChatWidget = ({ theme: localTheme, viewer }) => {
24
26
  var _a, _b, _c, _d, _e, _f;
25
27
  /* SSR guard */
26
28
  const [mounted, setMounted] = useState(false);
@@ -43,18 +45,21 @@ 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;
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 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);
58
63
  const recents = Object.entries(data.sampleChats).map(([uid, msgs]) => {
59
64
  const user = all.find(u => u.uid === uid);
60
65
  if (!user || msgs.length === 0)
@@ -71,7 +76,7 @@ export const ChatWidget = ({ theme: localTheme }) => {
71
76
  }).filter(Boolean);
72
77
  setRecentChats(recents);
73
78
  }
74
- }, [data]);
79
+ }, [data, viewer === null || viewer === void 0 ? void 0 : viewer.projectId]);
75
80
  /* Chat hook */
76
81
  const { messages, activeUser, isPaused, isReported, selectUser, sendMessage, togglePause, reportChat, clearChat, setMessages, } = useChat();
77
82
  /* WebRTC hook */
@@ -104,9 +109,16 @@ export const ChatWidget = ({ theme: localTheme }) => {
104
109
  setClosing(false);
105
110
  }, 300);
106
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]);
107
119
  const restoredRef = useRef(false);
108
120
  useEffect(() => {
109
- var _a, _b, _c;
121
+ var _a, _b, _c, _d;
110
122
  if (!(data === null || data === void 0 ? void 0 : data.widget) || restoredRef.current)
111
123
  return;
112
124
  const w = data.widget;
@@ -127,17 +139,21 @@ export const ChatWidget = ({ theme: localTheme }) => {
127
139
  setViewingTicketId((_a = p.viewingTicketId) !== null && _a !== void 0 ? _a : null);
128
140
  setChatReturnCtx((_b = p.chatReturnCtx) !== null && _b !== void 0 ? _b : 'conversation');
129
141
  if (p.activeUserUid) {
130
- const u = [...data.developers, ...data.users].find(x => x.uid === p.activeUserUid);
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);
131
147
  if (u) {
132
148
  const hist = Array.isArray(p.messages) && p.messages.length
133
149
  ? p.messages
134
- : ((_c = data.sampleChats[u.uid]) !== null && _c !== void 0 ? _c : []);
150
+ : ((_d = data.sampleChats[u.uid]) !== null && _d !== void 0 ? _d : []);
135
151
  selectUser(u, hist);
136
152
  }
137
153
  }
138
154
  }
139
155
  restoredRef.current = true;
140
- }, [data, selectUser, clearChat]);
156
+ }, [data, selectUser, clearChat, viewer === null || viewer === void 0 ? void 0 : viewer.projectId]);
141
157
  useEffect(() => {
142
158
  var _a;
143
159
  if (!((_a = data === null || data === void 0 ? void 0 : data.widget) === null || _a === void 0 ? void 0 : _a.viewerBlocked))
@@ -146,12 +162,12 @@ export const ChatWidget = ({ theme: localTheme }) => {
146
162
  setScreen('home');
147
163
  setActiveTab('home');
148
164
  setViewingTicketId(null);
149
- }, [(_c = data === null || data === void 0 ? void 0 : data.widget) === null || _c === void 0 ? void 0 : _c.viewerBlocked, clearChat]);
165
+ }, [(_d = data === null || data === void 0 ? void 0 : data.widget) === null || _d === void 0 ? void 0 : _d.viewerBlocked, clearChat]);
150
166
  useEffect(() => {
151
167
  if (!(data === null || data === void 0 ? void 0 : data.widget))
152
168
  return;
153
169
  persistWidgetState();
154
- }, [(_d = data === null || data === void 0 ? void 0 : data.widget) === null || _d === void 0 ? void 0 : _d.id, screen, activeTab, userListCtx, activeUser === null || activeUser === void 0 ? void 0 : activeUser.uid, messages, viewingTicketId, chatReturnCtx, persistWidgetState]);
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]);
155
171
  const incomingSoundRef = useRef(0);
156
172
  useEffect(() => {
157
173
  incomingSoundRef.current = messages.length;
@@ -287,9 +303,31 @@ export const ChatWidget = ({ theme: localTheme }) => {
287
303
  }, [endCall]);
288
304
  /* ── Derived ─────────────────────────────────────────────────────────── */
289
305
  const isBlocked = activeUser ? blockedUids.includes(activeUser.uid) : false;
290
- const widgetConfig = data === null || data === void 0 ? void 0 : data.widget;
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]);
291
320
  const primaryColor = theme.primaryColor;
292
- const allUsers = data ? [...data.developers, ...data.users] : [];
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]);
293
331
  const viewerIsDev = (widgetConfig === null || widgetConfig === void 0 ? void 0 : widgetConfig.viewerType) === 'developer';
294
332
  const viewerUid = widgetConfig === null || widgetConfig === void 0 ? void 0 : widgetConfig.viewerUid;
295
333
  const filteredUsers = screen === 'user-list'
@@ -305,7 +343,7 @@ export const ChatWidget = ({ theme: localTheme }) => {
305
343
  return u.type === 'user';
306
344
  })
307
345
  : [];
308
- const otherDevelopers = (_e = data === null || data === void 0 ? void 0 : data.developers.filter(d => d.uid !== viewerUid)) !== null && _e !== void 0 ? _e : [];
346
+ const otherDevelopers = useMemo(() => allUsers.filter(u => u.type === 'developer' && u.uid !== viewerUid), [allUsers, viewerUid]);
309
347
  const blockedUsers = allUsers.filter(u => blockedUids.includes(u.uid));
310
348
  const handleTransferToDeveloper = useCallback((dev) => {
311
349
  var _a;
@@ -396,7 +434,7 @@ export const ChatWidget = ({ theme: localTheme }) => {
396
434
  right: theme.buttonPosition === 'bottom-left' ? 'auto' : 12,
397
435
  left: theme.buttonPosition === 'bottom-left' ? 12 : 'auto',
398
436
  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 => {
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 => {
400
438
  setListEntranceAnimation(false);
401
439
  setViewingTicketId(id);
402
440
  setScreen('ticket-detail');
@@ -405,6 +443,7 @@ export const ChatWidget = ({ theme: localTheme }) => {
405
443
  return t ? (_jsx(TicketDetailScreen, { ticket: t, config: widgetConfig, onBack: () => { setViewingTicketId(null); setScreen('tickets'); } })) : null;
406
444
  })()), screen === 'block-list' && (_jsx(BlockListScreen, { blockedUsers: blockedUsers, config: widgetConfig, onUnblock: handleUnblock, onBack: () => { setScreen('home'); setActiveTab('home'); } }))] })), widgetConfig.status === 'ACTIVE' &&
407
445
  !widgetConfig.viewerBlocked &&
446
+ permissionsOk &&
408
447
  screen !== 'chat' &&
409
448
  screen !== 'call' &&
410
449
  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 */
@@ -138,6 +143,18 @@ export interface ChatWidgetTheme {
138
143
  buttonPosition?: 'bottom-right' | 'bottom-left';
139
144
  borderRadius?: string;
140
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
+ }
141
157
  export interface ChatWidgetProps {
142
158
  theme?: ChatWidgetTheme;
159
+ viewer?: ChatWidgetViewer;
143
160
  }
@@ -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.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",
@@ -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,9 @@ 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 inProject = (u: ChatUser) => !pid || u.project === pid;
73
+ const all = [...(data.developers ?? []), ...(data.users ?? [])].filter(inProject);
69
74
  const recents: RecentChat[] = Object.entries(data.sampleChats).map(([uid, msgs]) => {
70
75
  const user = all.find(u => u.uid === uid);
71
76
  if (!user || msgs.length === 0) return null;
@@ -81,7 +86,7 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
81
86
  }).filter(Boolean) as RecentChat[];
82
87
  setRecentChats(recents);
83
88
  }
84
- }, [data]);
89
+ }, [data, viewer?.projectId]);
85
90
 
86
91
  /* Chat hook */
87
92
  const {
@@ -121,6 +126,12 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
121
126
  }, 300);
122
127
  }, [persistWidgetState]);
123
128
 
129
+ useEffect(() => {
130
+ const id = data?.widget?.id;
131
+ if (!id) return;
132
+ setPermissionsOk(hasStoredPermissionsGrant(id));
133
+ }, [data?.widget?.id]);
134
+
124
135
  const restoredRef = useRef(false);
125
136
  useEffect(() => {
126
137
  if (!data?.widget || restoredRef.current) return;
@@ -142,7 +153,11 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
142
153
  setViewingTicketId(p.viewingTicketId ?? null);
143
154
  setChatReturnCtx(p.chatReturnCtx ?? 'conversation');
144
155
  if (p.activeUserUid) {
145
- const u = [...data.developers, ...data.users].find(x => x.uid === p.activeUserUid);
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);
146
161
  if (u) {
147
162
  const hist = Array.isArray(p.messages) && p.messages.length
148
163
  ? p.messages
@@ -152,7 +167,7 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
152
167
  }
153
168
  }
154
169
  restoredRef.current = true;
155
- }, [data, selectUser, clearChat]);
170
+ }, [data, selectUser, clearChat, viewer?.projectId]);
156
171
 
157
172
  useEffect(() => {
158
173
  if (!data?.widget?.viewerBlocked) return;
@@ -308,10 +323,29 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
308
323
 
309
324
  /* ── Derived ─────────────────────────────────────────────────────────── */
310
325
  const isBlocked = activeUser ? blockedUids.includes(activeUser.uid) : false;
311
- const widgetConfig = data?.widget;
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
+
312
339
  const primaryColor = theme.primaryColor;
313
340
 
314
- const allUsers = data ? [...data.developers, ...data.users] : [];
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
+
315
349
  const viewerIsDev = widgetConfig?.viewerType === 'developer';
316
350
  const viewerUid = widgetConfig?.viewerUid;
317
351
 
@@ -328,7 +362,10 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
328
362
  })
329
363
  : [];
330
364
 
331
- const otherDevelopers = data?.developers.filter(d => d.uid !== viewerUid) ?? [];
365
+ const otherDevelopers = useMemo(
366
+ () => allUsers.filter(u => u.type === 'developer' && u.uid !== viewerUid),
367
+ [allUsers, viewerUid],
368
+ );
332
369
  const blockedUsers = allUsers.filter(u => blockedUids.includes(u.uid));
333
370
 
334
371
  const handleTransferToDeveloper = useCallback((dev: ChatUser) => {
@@ -534,8 +571,17 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
534
571
  <ViewerBlockedScreen config={widgetConfig} apiKey={apiKey} />
535
572
  )}
536
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
+
537
583
  {/* ── ACTIVE ── */}
538
- {widgetConfig.status === 'ACTIVE' && !widgetConfig.viewerBlocked && (
584
+ {widgetConfig.status === 'ACTIVE' && !widgetConfig.viewerBlocked && permissionsOk && (
539
585
  <div className="cw-scroll" style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
540
586
 
541
587
  {screen === 'home' && (
@@ -654,6 +700,7 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
654
700
  {/* ── Bottom Tabs (hidden during chat/call/user-list/block-list) ── */}
655
701
  {widgetConfig.status === 'ACTIVE' &&
656
702
  !widgetConfig.viewerBlocked &&
703
+ permissionsOk &&
657
704
  screen !== 'chat' &&
658
705
  screen !== 'call' &&
659
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 &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 */
@@ -167,6 +172,19 @@ export interface ChatWidgetTheme {
167
172
  borderRadius?: string;
168
173
  }
169
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
+
170
187
  export interface ChatWidgetProps {
171
188
  theme?: ChatWidgetTheme;
189
+ viewer?: ChatWidgetViewer;
172
190
  }
@@ -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
+ }