@erlancarreira/evolution-chat 0.1.1 → 0.1.3

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/next/index.d.ts CHANGED
@@ -46,6 +46,8 @@ interface ChatConfig {
46
46
  /** URL pública da imagem de capa do grupo (opcional; ignora se vazia). */
47
47
  groupImage?: string;
48
48
  }
49
+ /** Estado de presença (digitação) normalizado da Evolution. */
50
+ type PresenceState = "composing" | "paused" | "recording";
49
51
  /** Evento de tempo real publicado no canal broadcast `chat:<realtimeToken>`. */
50
52
  type ChatEvent = {
51
53
  type: "message";
@@ -53,6 +55,14 @@ type ChatEvent = {
53
55
  } | {
54
56
  type: "session";
55
57
  status: ChatSessionStatus;
58
+ }
59
+ /** Indicador de digitação: `from` diz quem está digitando (dona da plataforma
60
+ * ou visitante) para o widget exibir os "3 pontinhos" apenas quando a OUTRA
61
+ * ponta digita. */
62
+ | {
63
+ type: "typing";
64
+ isTyping: boolean;
65
+ from: "owner" | "visitor";
56
66
  };
57
67
 
58
68
  /** Persistência de sessões/mensagens vista pela bridge. */
@@ -120,6 +130,10 @@ interface CreateGroupResult {
120
130
  }
121
131
  interface EvolutionClient {
122
132
  sendText(instance: string, number: string, text: string): Promise<SendTextResult>;
133
+ /** Envia presença de digitação ("digitando…") para o número/grupo. `presence` =
134
+ * "composing" | "recording" (digitando) ou "paused" (parou). Best-effort: a
135
+ * presença é anunciada pela CONTA CONECTADA à instância, não por terceiros. */
136
+ sendPresence(instance: string, number: string, presence: PresenceState): Promise<void>;
123
137
  createGroup(instance: string, subject: string, participants: string[], description?: string): Promise<CreateGroupResult>;
124
138
  setGroupPicture(instance: string, groupJid: string, image: string): Promise<void>;
125
139
  leaveGroup(instance: string, groupJid: string): Promise<void>;
@@ -193,6 +207,29 @@ declare class ChatBridge {
193
207
  * não reenviar.
194
208
  */
195
209
  private handleGroupParticipants;
210
+ /**
211
+ * Trata `PRESENCE_UPDATE` (Evolution): quando a OUTRA ponta está digitando, publica
212
+ * um evento de tempo real para o widget exibir os "3 pontinhos". O `from` distingue
213
+ * quem digita (dona da plataforma vs. visitante) para o widget mostrar só quando a
214
+ * ponta OPOSTA digita.
215
+ * NUNCA lança: erros são logados e devolvidos como tratado (200) para a Evolution não
216
+ * reenviar.
217
+ */
218
+ private handlePresence;
219
+ /**
220
+ * Sinaliza que o visitante está (ou parou de) digitar no widget. Publica um evento
221
+ * `typing` no canal em tempo real da sessão para que uma eventual INTERFACE DE
222
+ * ATENDENTE exiba o indicador.
223
+ *
224
+ * NOTA sobre WhatsApp: a Evolution só permite anunciar a presença da CONTA CONECTADA
225
+ * à instância. Como o atendente desta plataforma OPERA a própria instância, enviar
226
+ * `sendPresence` faria o widget exibir um "digitando" FALSO do atendente (eco do
227
+ * próprio evento de volta pelo webhook). Por isso, aqui, apenas publicamos no canal
228
+ * em tempo real — a presença WhatsApp do visitante não é representável por terceiros.
229
+ */
230
+ setVisitorTyping(token: string, isTyping: boolean): Promise<void>;
231
+ /** Verdadeiro se `jid` é a conta da plataforma (dona da instância). */
232
+ private isPlatformParticipant;
196
233
  /**
197
234
  * Encerra a sessão (status closed + evento realtime) e — só quando o grupo morreu do
198
235
  * lado do visitante (ele saiu ou o destino sumiu) — faz a EMPRESA SAIR do grupo na
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@erlancarreira/evolution-chat",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Chat widget + Evolution API bridge (WhatsApp) — reutilizável, hexagonal, SOLID",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -20,6 +20,14 @@ type ChatEvent = {
20
20
  } | {
21
21
  type: "session";
22
22
  status: ChatSessionStatus;
23
+ }
24
+ /** Indicador de digitação: `from` diz quem está digitando (dona da plataforma
25
+ * ou visitante) para o widget exibir os "3 pontinhos" apenas quando a OUTRA
26
+ * ponta digita. */
27
+ | {
28
+ type: "typing";
29
+ isTyping: boolean;
30
+ from: "owner" | "visitor";
23
31
  };
24
32
 
25
33
  /** Publicação server-side de eventos para o canal de uma sessão (fire-and-forget). */
@@ -20,6 +20,14 @@ type ChatEvent = {
20
20
  } | {
21
21
  type: "session";
22
22
  status: ChatSessionStatus;
23
+ }
24
+ /** Indicador de digitação: `from` diz quem está digitando (dona da plataforma
25
+ * ou visitante) para o widget exibir os "3 pontinhos" apenas quando a OUTRA
26
+ * ponta digita. */
27
+ | {
28
+ type: "typing";
29
+ isTyping: boolean;
30
+ from: "owner" | "visitor";
23
31
  };
24
32
 
25
33
  /** Publicação server-side de eventos para o canal de uma sessão (fire-and-forget). */
package/widget/index.cjs CHANGED
@@ -58,7 +58,8 @@ var WIDGET_KEYS = [
58
58
  "retry",
59
59
  "sessionClosed",
60
60
  "newConversation",
61
- "poweredBy"
61
+ "poweredBy",
62
+ "typing"
62
63
  ];
63
64
  var pt = {
64
65
  openChat: "Abrir chat",
@@ -75,7 +76,8 @@ var pt = {
75
76
  retry: "Tentar novamente",
76
77
  sessionClosed: "Esta conversa foi encerrada. Abra uma nova para continuar.",
77
78
  newConversation: "Iniciar nova conversa",
78
- poweredBy: "Powered by"
79
+ poweredBy: "Powered by",
80
+ typing: "digitando\u2026"
79
81
  };
80
82
  var en = {
81
83
  openChat: "Open chat",
@@ -92,7 +94,8 @@ var en = {
92
94
  retry: "Try again",
93
95
  sessionClosed: "This conversation was closed. Start a new one to continue.",
94
96
  newConversation: "Start a new conversation",
95
- poweredBy: "Powered by"
97
+ poweredBy: "Powered by",
98
+ typing: "typing\u2026"
96
99
  };
97
100
  var es = {
98
101
  openChat: "Abrir chat",
@@ -109,7 +112,8 @@ var es = {
109
112
  retry: "Reintentar",
110
113
  sessionClosed: "Esta conversaci\xF3n fue cerrada. Abre una nueva para continuar.",
111
114
  newConversation: "Iniciar nueva conversaci\xF3n",
112
- poweredBy: "Powered by"
115
+ poweredBy: "Powered by",
116
+ typing: "escribiendo\u2026"
113
117
  };
114
118
  var dictionaries = { pt, en, es };
115
119
  function t(locale, key, overrides) {
@@ -358,6 +362,31 @@ var WIDGET_CSS = `/*
358
362
  .ecw-send:disabled { opacity: 0.55; cursor: not-allowed; }
359
363
  .ecw-send svg { width: 18px; height: 18px; }
360
364
 
365
+ /* \u2500\u2500 indicador "digitando\u2026" (3 pontinhos) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
366
+ .ecw-typing {
367
+ display: inline-flex;
368
+ align-items: center;
369
+ gap: 4px;
370
+ padding: 10px 14px;
371
+ border-radius: 12px;
372
+ border-bottom-left-radius: 4px;
373
+ background: var(--ecw-surface);
374
+ border: 1px solid var(--ecw-border);
375
+ }
376
+ .ecw-typing-dot {
377
+ width: 7px;
378
+ height: 7px;
379
+ border-radius: 50%;
380
+ background: var(--ecw-muted);
381
+ animation: ecw-typing-bounce 1.2s infinite ease-in-out;
382
+ }
383
+ .ecw-typing-dot:nth-child(2) { animation-delay: 0.18s; }
384
+ .ecw-typing-dot:nth-child(3) { animation-delay: 0.36s; }
385
+ @keyframes ecw-typing-bounce {
386
+ 0%, 60%, 100% { transform: translateY(0); opacity: 0.5; }
387
+ 30% { transform: translateY(-4px); opacity: 1; }
388
+ }
389
+
361
390
  /* \u2500\u2500 sess\xE3o encerrada / falha \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
362
391
  .ecw-closed { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px; border-top: 1px solid var(--ecw-border); background: var(--ecw-surface); }
363
392
 
@@ -422,17 +451,23 @@ var initialState = {
422
451
  messages: [],
423
452
  unread: 0,
424
453
  sending: false,
425
- error: null
454
+ error: null,
455
+ ownerTyping: false,
456
+ visitorTyping: false
426
457
  };
427
458
  function withMessage(state, message) {
428
459
  const index = state.messages.findIndex((m) => m.id === message.id);
460
+ let messages;
429
461
  if (index >= 0) {
430
- const messages = [...state.messages];
462
+ messages = [...state.messages];
431
463
  messages[index] = message;
432
- return { ...state, messages };
464
+ } else {
465
+ messages = [...state.messages, message];
433
466
  }
434
467
  const bump = state.open || message.direction !== "owner" ? 0 : 1;
435
- return { ...state, messages: [...state.messages, message], unread: state.unread + bump };
468
+ const next = { ...state, messages, unread: state.unread + bump };
469
+ if (message.direction === "owner") next.ownerTyping = false;
470
+ return next;
436
471
  }
437
472
  function chatReducer(state, action) {
438
473
  switch (action.type) {
@@ -496,6 +531,8 @@ function chatReducer(state, action) {
496
531
  return { ...state, sending: action.value };
497
532
  case "error":
498
533
  return { ...state, error: action.value };
534
+ case "typing":
535
+ return action.from === "owner" ? state.ownerTyping === action.isTyping ? state : { ...state, ownerTyping: action.isTyping } : state.visitorTyping === action.isTyping ? state : { ...state, visitorTyping: action.isTyping };
499
536
  }
500
537
  }
501
538
  function normalizePhone(value) {
@@ -566,8 +603,9 @@ function tmpMessage(body) {
566
603
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
567
604
  };
568
605
  }
569
- function useChat({ endpoint, realtime }) {
606
+ function useChat({ endpoint, realtime, typingEndpoint }) {
570
607
  const [state, dispatch] = (0, import_react.useReducer)(chatReducer, initialState);
608
+ const typingUrl = typingEndpoint ?? `${endpoint.replace(/\/+$/, "")}/typing`;
571
609
  const stateRef = (0, import_react.useRef)(state);
572
610
  (0, import_react.useEffect)(() => {
573
611
  stateRef.current = state;
@@ -622,6 +660,8 @@ function useChat({ endpoint, realtime }) {
622
660
  dispatch({ type: "append", message: e.message });
623
661
  } else if (e.type === "session" && e.status !== void 0) {
624
662
  dispatch({ type: "session-status", status: e.status });
663
+ } else if (e.type === "typing" && e.from !== void 0) {
664
+ dispatch({ type: "typing", from: e.from, isTyping: e.isTyping });
625
665
  }
626
666
  }, []);
627
667
  (0, import_react.useEffect)(() => {
@@ -728,6 +768,21 @@ function useChat({ endpoint, realtime }) {
728
768
  clearStoredSession();
729
769
  dispatch({ type: "reset-form" });
730
770
  }, []);
771
+ const notifyTyping = (0, import_react.useCallback)(
772
+ async (isTyping) => {
773
+ const token = stateRef.current.token;
774
+ if (token === null) return;
775
+ try {
776
+ await fetch(typingUrl, {
777
+ method: "POST",
778
+ headers: { "content-type": "application/json" },
779
+ body: JSON.stringify({ token, isTyping })
780
+ });
781
+ } catch {
782
+ }
783
+ },
784
+ [typingUrl]
785
+ );
731
786
  const submitForm = (0, import_react.useCallback)(
732
787
  async ({ name, phone, message, honeypot }) => {
733
788
  if (honeypot.trim() !== "") return;
@@ -775,7 +830,7 @@ function useChat({ endpoint, realtime }) {
775
830
  },
776
831
  [endpoint]
777
832
  );
778
- return { state, openPanel, closePanel, togglePanel, submitForm, sendMessage, retryMessage, startNewConversation };
833
+ return { state, openPanel, closePanel, togglePanel, submitForm, sendMessage, retryMessage, startNewConversation, notifyTyping };
779
834
  }
780
835
 
781
836
  // src/widget/chat-widget.tsx
@@ -799,6 +854,14 @@ function StatusMark({ status }) {
799
854
  const glyph = status === "pending" ? "\u2713" : status === "sent" ? "\u2713\u2713" : "\u26A0";
800
855
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: status === "failed" ? "ecw-status ecw-status--failed" : "ecw-status", "aria-hidden": "true", children: glyph });
801
856
  }
857
+ function TypingIndicator({ label }) {
858
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("li", { className: "ecw-item ecw-item--owner", "aria-live": "polite", children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "ecw-typing", role: "status", "aria-label": label, children: [
859
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "ecw-typing-dot" }),
860
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "ecw-typing-dot" }),
861
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "ecw-typing-dot" }),
862
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "ecw-sr-only", children: label })
863
+ ] }) });
864
+ }
802
865
  function PreChatForm({ welcome, tr, error, sending, firstFieldRef, onSubmit }) {
803
866
  const uid = (0, import_react2.useId)();
804
867
  const [name, setName] = (0, import_react2.useState)("");
@@ -898,29 +961,53 @@ function PreChatForm({ welcome, tr, error, sending, firstFieldRef, onSubmit }) {
898
961
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "ecw-notice", children: tr("privacyNote") })
899
962
  ] });
900
963
  }
901
- function ChatPanel({ messages, canCompose, sending, locale, tr, listRef, firstFieldRef, onSend, onRetry, onNewConversation }) {
964
+ function ChatPanel({ messages, canCompose, sending, locale, tr, listRef, firstFieldRef, ownerTyping, onTyping, onSend, onRetry, onNewConversation }) {
902
965
  const uid = (0, import_react2.useId)();
903
966
  const [draft, setDraft] = (0, import_react2.useState)("");
967
+ const typingTimer = (0, import_react2.useRef)(null);
968
+ (0, import_react2.useEffect)(() => {
969
+ return () => {
970
+ if (typingTimer.current !== null) clearTimeout(typingTimer.current);
971
+ };
972
+ }, []);
973
+ const handleDraftChange = (e) => {
974
+ setDraft(e.target.value);
975
+ if (!canCompose) return;
976
+ void onTyping(true);
977
+ if (typingTimer.current !== null) clearTimeout(typingTimer.current);
978
+ typingTimer.current = setTimeout(() => {
979
+ void onTyping(false);
980
+ typingTimer.current = null;
981
+ }, 2500);
982
+ };
904
983
  const submit = (e) => {
905
984
  e.preventDefault();
906
985
  const text = draft.trim();
907
986
  if (text === "" || !canCompose) return;
987
+ if (typingTimer.current !== null) {
988
+ clearTimeout(typingTimer.current);
989
+ typingTimer.current = null;
990
+ }
991
+ void onTyping(false);
908
992
  setDraft("");
909
993
  void onSend(text);
910
994
  };
911
995
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
912
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("ul", { ref: listRef, className: "ecw-list", children: messages.map(
913
- (m) => m.direction === "system" ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("li", { className: "ecw-item ecw-item--system", role: "status", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "ecw-system", children: m.body }) }, m.id) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("li", { className: `ecw-item ecw-item--${m.direction}`, children: [
914
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: `ecw-bubble ecw-bubble--${m.direction}`, children: m.body }),
915
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "ecw-meta", children: [
916
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("time", { dateTime: m.createdAt, children: formatTime(m.createdAt, locale) }),
917
- m.direction === "visitor" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(StatusMark, { status: m.status }),
918
- m.status === "failed" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "ecw-retry", onClick: () => {
919
- void onRetry(m.id);
920
- }, children: tr("retry") })
921
- ] })
922
- ] }, m.id)
923
- ) }),
996
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("ul", { ref: listRef, className: "ecw-list", children: [
997
+ messages.map(
998
+ (m) => m.direction === "system" ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("li", { className: "ecw-item ecw-item--system", role: "status", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "ecw-system", children: m.body }) }, m.id) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("li", { className: `ecw-item ecw-item--${m.direction}`, children: [
999
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: `ecw-bubble ecw-bubble--${m.direction}`, children: m.body }),
1000
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "ecw-meta", children: [
1001
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("time", { dateTime: m.createdAt, children: formatTime(m.createdAt, locale) }),
1002
+ m.direction === "visitor" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(StatusMark, { status: m.status }),
1003
+ m.status === "failed" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "ecw-retry", onClick: () => {
1004
+ void onRetry(m.id);
1005
+ }, children: tr("retry") })
1006
+ ] })
1007
+ ] }, m.id)
1008
+ ),
1009
+ ownerTyping && canCompose && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(TypingIndicator, { label: tr("typing") })
1010
+ ] }),
924
1011
  !canCompose && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "ecw-closed", children: [
925
1012
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "ecw-notice", role: "status", children: tr("sessionClosed") }),
926
1013
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "ecw-submit", onClick: onNewConversation, children: tr("newConversation") })
@@ -937,9 +1024,7 @@ function ChatPanel({ messages, canCompose, sending, locale, tr, listRef, firstFi
937
1024
  autoComplete: "off",
938
1025
  placeholder: tr("message"),
939
1026
  value: draft,
940
- onChange: (e) => {
941
- setDraft(e.target.value);
942
- },
1027
+ onChange: handleDraftChange,
943
1028
  disabled: !canCompose
944
1029
  }
945
1030
  ),
@@ -951,7 +1036,7 @@ function ChatWidget(props) {
951
1036
  const { endpoint, locale, welcome, projectName, realtime, labels } = props;
952
1037
  const accentColor = props.accentColor ?? DEFAULT_ACCENT;
953
1038
  const tr = (0, import_react2.useCallback)((key) => t(locale, key, labels), [locale, labels]);
954
- const { state, closePanel, togglePanel, submitForm, sendMessage, retryMessage, startNewConversation } = useChat({ endpoint, realtime });
1039
+ const { state, closePanel, togglePanel, submitForm, sendMessage, retryMessage, startNewConversation, notifyTyping } = useChat({ endpoint, realtime });
955
1040
  const bubbleRef = (0, import_react2.useRef)(null);
956
1041
  const listRef = (0, import_react2.useRef)(null);
957
1042
  const firstFieldRef = (0, import_react2.useRef)(null);
@@ -976,7 +1061,7 @@ function ChatWidget(props) {
976
1061
  (0, import_react2.useEffect)(() => {
977
1062
  const list = listRef.current;
978
1063
  if (list !== null) list.scrollTop = list.scrollHeight;
979
- }, [state.messages, state.open]);
1064
+ }, [state.messages, state.ownerTyping, state.open]);
980
1065
  const handleClose = (0, import_react2.useCallback)(() => {
981
1066
  closePanel();
982
1067
  bubbleRef.current?.focus();
@@ -1019,6 +1104,8 @@ function ChatWidget(props) {
1019
1104
  tr,
1020
1105
  listRef,
1021
1106
  firstFieldRef,
1107
+ ownerTyping: state.ownerTyping,
1108
+ onTyping: notifyTyping,
1022
1109
  onSend: sendMessage,
1023
1110
  onRetry: retryMessage,
1024
1111
  onNewConversation: startNewConversation