@fayz-ai/plugin-conversations 0.17.1 → 0.19.0

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/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
- import * as React11 from 'react';
2
- import React11__default, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
3
- import { Loader2, MessageSquare, AlertTriangle, Pause, Play, Send, RefreshCw, Plus, Clock, CheckCircle2, Check, ShieldCheck, ShieldAlert, QrCode, Zap, ExternalLink, SquarePen, Globe, Mail, Instagram, Phone, Search, User, Inbox, CheckCheck, ChevronLeft, Archive, PanelRight, X, MapPin, Tag, StickyNote, Link2, MoreVertical, Snowflake, Sun, Flame, Building2 } from 'lucide-react';
1
+ import * as React12 from 'react';
2
+ import React12__default, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
3
+ import { Loader2, MessageSquare, AlertTriangle, Pause, Play, Send, RefreshCw, Plus, Clock, CheckCircle2, Check, ShieldCheck, ShieldAlert, QrCode, Zap, ExternalLink, SquarePen, Globe, Mail, Instagram, Phone, Search, User, Inbox, CheckCheck, ChevronLeft, Archive, PanelRight, X, MapPin, Tag, StickyNote, Link2, MoreVertical, Snowflake, Sun, Flame, Building2, UserRound, Copy, FileText, Download } from 'lucide-react';
4
4
  import { toast, Button, Input, defineKpiWidget, defineTableWidget, PageHeaderActions, cn, Badge, FilterChip, Skeleton, KpiCard, TableWidget } from '@fayz-ai/ui';
5
5
  import { createConnectionStore, connectionRuns, connectionStatus, getActiveTenantId, getSupabaseClientOptional, useActiveTenantId, registerTranslations, useTranslation, countByTenant, CONNECTOR_RUNTIME_TOKEN_HEADER, connectorRuntimeToken, errorMessage } from '@fayz-ai/core';
6
6
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
@@ -211,12 +211,12 @@ function Bar({ c }) {
211
211
  ] });
212
212
  }
213
213
  function CampaignsView() {
214
- const [rows, setRows] = React11__default.useState(null);
215
- const [templates, setTemplates] = React11__default.useState([]);
216
- const [providers, setProviders] = React11__default.useState([]);
217
- const [running, setRunning] = React11__default.useState(null);
218
- const stop = React11__default.useRef(false);
219
- const load = React11__default.useCallback(async () => {
214
+ const [rows, setRows] = React12__default.useState(null);
215
+ const [templates, setTemplates] = React12__default.useState([]);
216
+ const [providers, setProviders] = React12__default.useState([]);
217
+ const [running, setRunning] = React12__default.useState(null);
218
+ const stop = React12__default.useRef(false);
219
+ const load = React12__default.useCallback(async () => {
220
220
  try {
221
221
  const [c, t, p] = await Promise.all([
222
222
  campaignsApi.list(),
@@ -231,7 +231,7 @@ function CampaignsView() {
231
231
  setRows([]);
232
232
  }
233
233
  }, []);
234
- React11__default.useEffect(() => {
234
+ React12__default.useEffect(() => {
235
235
  void load();
236
236
  }, [load]);
237
237
  const active = providers.find((p) => p.isActive);
@@ -485,9 +485,9 @@ function avisarNoSistema(titulo, corpo, tag) {
485
485
  }
486
486
  function InboxWatcher({ label, route = "/conversations", section = "main", position = 1 }) {
487
487
  const tenantId = useActiveTenantId();
488
- const [naoLidas, setNaoLidas] = React11.useState(0);
489
- const podeTocar = React11.useRef(false);
490
- React11.useEffect(() => {
488
+ const [naoLidas, setNaoLidas] = React12.useState(0);
489
+ const podeTocar = React12.useRef(false);
490
+ React12.useEffect(() => {
491
491
  const liberar = () => {
492
492
  podeTocar.current = true;
493
493
  void pedirPermissao();
@@ -499,7 +499,7 @@ function InboxWatcher({ label, route = "/conversations", section = "main", posit
499
499
  for (const ev of ["pointerdown", "keydown", "touchstart"]) window.removeEventListener(ev, liberar);
500
500
  };
501
501
  }, []);
502
- const contar = React11.useCallback(async () => {
502
+ const contar = React12.useCallback(async () => {
503
503
  const supabase = getSupabaseClientOptional();
504
504
  if (!supabase) return;
505
505
  const { data } = await supabase.from(T.conversations).select("id, status, last_message_direction, unread_count").eq("status", "open");
@@ -511,10 +511,10 @@ function InboxWatcher({ label, route = "/conversations", section = "main", posit
511
511
  })).length;
512
512
  setNaoLidas(n);
513
513
  }, []);
514
- React11.useEffect(() => {
514
+ React12.useEffect(() => {
515
515
  void contar();
516
516
  }, [contar]);
517
- React11.useEffect(() => {
517
+ React12.useEffect(() => {
518
518
  if (!tenantId) return;
519
519
  return assinarChegada(tenantId, ({ conversationId }) => {
520
520
  void contar();
@@ -549,14 +549,14 @@ function slots(body) {
549
549
  return found.size;
550
550
  }
551
551
  function TemplatesView() {
552
- const [mine, setMine] = React11__default.useState(null);
553
- const [theirs, setTheirs] = React11__default.useState([]);
554
- const [providers, setProviders] = React11__default.useState([]);
555
- const [busy, setBusy] = React11__default.useState(null);
556
- const [draft, setDraft] = React11__default.useState({ name: "", body: "", variables: "" });
552
+ const [mine, setMine] = React12__default.useState(null);
553
+ const [theirs, setTheirs] = React12__default.useState([]);
554
+ const [providers, setProviders] = React12__default.useState([]);
555
+ const [busy, setBusy] = React12__default.useState(null);
556
+ const [draft, setDraft] = React12__default.useState({ name: "", body: "", variables: "" });
557
557
  const active = providers.find((p) => p.isActive);
558
558
  const supportsTemplates = active?.capabilities?.templates !== false;
559
- const load = React11__default.useCallback(async () => {
559
+ const load = React12__default.useCallback(async () => {
560
560
  try {
561
561
  const [m, p] = await Promise.all([campaignsApi.templates(), providersApi.list()]);
562
562
  setMine(m);
@@ -566,7 +566,7 @@ function TemplatesView() {
566
566
  setMine([]);
567
567
  }
568
568
  }, []);
569
- React11__default.useEffect(() => {
569
+ React12__default.useEffect(() => {
570
570
  void load();
571
571
  }, [load]);
572
572
  const sync = async () => {
@@ -757,8 +757,8 @@ function TemplatesView() {
757
757
  var DEFAULT_CONVERSATIONS_CONFIG = {
758
758
  contactKind: "contact"
759
759
  };
760
- var StoreContext = React11__default.createContext(null);
761
- var ConfigContext = React11__default.createContext(DEFAULT_CONVERSATIONS_CONFIG);
760
+ var StoreContext = React12__default.createContext(null);
761
+ var ConfigContext = React12__default.createContext(DEFAULT_CONVERSATIONS_CONFIG);
762
762
  function ConversationsContextProvider({
763
763
  store: store2,
764
764
  config = DEFAULT_CONVERSATIONS_CONFIG,
@@ -767,12 +767,12 @@ function ConversationsContextProvider({
767
767
  return /* @__PURE__ */ jsx(StoreContext.Provider, { value: store2, children: /* @__PURE__ */ jsx(ConfigContext.Provider, { value: config, children }) });
768
768
  }
769
769
  function useConversationsStore(selector) {
770
- const store2 = React11__default.useContext(StoreContext);
770
+ const store2 = React12__default.useContext(StoreContext);
771
771
  if (!store2) throw new Error("useConversationsStore must be used within ConversationsPage");
772
772
  return useStore(store2, selector);
773
773
  }
774
774
  function useConversationsConfig() {
775
- return React11__default.useContext(ConfigContext);
775
+ return React12__default.useContext(ConfigContext);
776
776
  }
777
777
  var CHANNEL_ICON = {
778
778
  sms: Phone,
@@ -789,10 +789,10 @@ var CHANNEL_ACCENT = {
789
789
  webchat: { color: "#f59e0b", badge: "bg-[#f59e0b]/12 text-[#b45309] dark:text-[#fcd34d]" }
790
790
  };
791
791
  function useMediaQuery(query) {
792
- const [matches, setMatches] = React11__default.useState(
792
+ const [matches, setMatches] = React12__default.useState(
793
793
  () => typeof window !== "undefined" && window.matchMedia(query).matches
794
794
  );
795
- React11__default.useEffect(() => {
795
+ React12__default.useEffect(() => {
796
796
  if (typeof window === "undefined") return;
797
797
  const mql = window.matchMedia(query);
798
798
  const onChange = () => setMatches(mql.matches);
@@ -881,7 +881,7 @@ function ConversationList({ className }) {
881
881
  setLeadOwnerFilter,
882
882
  setSearch
883
883
  } = useConversationsStore((s) => s);
884
- const donos = React11__default.useMemo(() => {
884
+ const donos = React12__default.useMemo(() => {
885
885
  const vistos = /* @__PURE__ */ new Map();
886
886
  for (const c of conversations) {
887
887
  if (c.leadOwnerId) vistos.set(c.leadOwnerId, c.leadOwnerName ?? c.leadOwnerId);
@@ -1016,9 +1016,9 @@ function ConversationList({ className }) {
1016
1016
  ] });
1017
1017
  }
1018
1018
  function useSendWindow(conversationId) {
1019
- const [row, setRow] = React11__default.useState(null);
1020
- const [providers, setProviders] = React11__default.useState(null);
1021
- React11__default.useEffect(() => {
1019
+ const [row, setRow] = React12__default.useState(null);
1020
+ const [providers, setProviders] = React12__default.useState(null);
1021
+ React12__default.useEffect(() => {
1022
1022
  let alive = true;
1023
1023
  providersApi.list().then((p) => {
1024
1024
  if (alive) setProviders(p);
@@ -1029,7 +1029,7 @@ function useSendWindow(conversationId) {
1029
1029
  alive = false;
1030
1030
  };
1031
1031
  }, []);
1032
- React11__default.useEffect(() => {
1032
+ React12__default.useEffect(() => {
1033
1033
  let alive = true;
1034
1034
  if (!conversationId) {
1035
1035
  setRow(null);
@@ -1050,7 +1050,7 @@ function useSendWindow(conversationId) {
1050
1050
  };
1051
1051
  }, [conversationId]);
1052
1052
  const provider = (providers ?? []).find((p) => p.isActive);
1053
- const mode = React11__default.useMemo(() => {
1053
+ const mode = React12__default.useMemo(() => {
1054
1054
  if (!provider) return { kind: "blocked", reason: "no_provider" };
1055
1055
  if (!provider.capabilities?.requires_template) {
1056
1056
  return { kind: "free", until: null, minutesLeft: null };
@@ -1125,10 +1125,10 @@ function DeliveryMark({ m }) {
1125
1125
  ] });
1126
1126
  }
1127
1127
  function MessageMenu({ m }) {
1128
- const [open, setOpen] = React11__default.useState(false);
1129
- const [detail, setDetail] = React11__default.useState(null);
1130
- const [loading, setLoading] = React11__default.useState(false);
1131
- const [err, setErr] = React11__default.useState(null);
1128
+ const [open, setOpen] = React12__default.useState(false);
1129
+ const [detail, setDetail] = React12__default.useState(null);
1130
+ const [loading, setLoading] = React12__default.useState(false);
1131
+ const [err, setErr] = React12__default.useState(null);
1132
1132
  async function load() {
1133
1133
  setOpen(true);
1134
1134
  if (!m.providerMessageId) return;
@@ -1199,6 +1199,180 @@ function MessageMenu({ m }) {
1199
1199
  ] }) })
1200
1200
  ] });
1201
1201
  }
1202
+ var COM_ARQUIVO = /* @__PURE__ */ new Set(["image", "video", "audio", "document", "sticker"]);
1203
+ function temArquivo(m) {
1204
+ return !!m.mediaPath && COM_ARQUIVO.has(String(m.messageType));
1205
+ }
1206
+ function useUrlAssinada(m, visivel) {
1207
+ const [url, setUrl] = React12.useState(null);
1208
+ const [erro, setErro] = React12.useState(false);
1209
+ React12.useEffect(() => {
1210
+ if (!visivel || url || erro || !temArquivo(m)) return;
1211
+ let vivo = true;
1212
+ (async () => {
1213
+ try {
1214
+ const sb = getSupabaseClientOptional();
1215
+ const { data, error } = await sb.functions.invoke("conversations-provider", {
1216
+ body: { tenantId: getActiveTenantId(), action: "media_url", messageId: m.id }
1217
+ });
1218
+ if (error || data?.error) throw new Error("falhou");
1219
+ if (vivo && data?.url) setUrl(data.url);
1220
+ else if (vivo) setErro(true);
1221
+ } catch {
1222
+ if (vivo) setErro(true);
1223
+ }
1224
+ })();
1225
+ return () => {
1226
+ vivo = false;
1227
+ };
1228
+ }, [visivel, m.id, url, erro]);
1229
+ return { url, erro };
1230
+ }
1231
+ function tamanho(bytes) {
1232
+ if (!bytes || bytes <= 0) return "";
1233
+ if (bytes < 1024) return `${bytes} B`;
1234
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
1235
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
1236
+ }
1237
+ function duracao(seg) {
1238
+ if (!seg || seg <= 0) return "";
1239
+ const m = Math.floor(seg / 60);
1240
+ const s = Math.floor(seg % 60);
1241
+ return `${m}:${String(s).padStart(2, "0")}`;
1242
+ }
1243
+ function MediaBubble({ m, outbound }) {
1244
+ const ref = React12.useRef(null);
1245
+ const [visivel, setVisivel] = React12.useState(false);
1246
+ const { url, erro } = useUrlAssinada(m, visivel);
1247
+ React12.useEffect(() => {
1248
+ const el = ref.current;
1249
+ if (!el || typeof IntersectionObserver === "undefined") {
1250
+ setVisivel(true);
1251
+ return;
1252
+ }
1253
+ const obs = new IntersectionObserver((entradas) => {
1254
+ if (entradas.some((e) => e.isIntersecting)) {
1255
+ setVisivel(true);
1256
+ obs.disconnect();
1257
+ }
1258
+ }, { rootMargin: "200px" });
1259
+ obs.observe(el);
1260
+ return () => obs.disconnect();
1261
+ }, []);
1262
+ const tipo = String(m.messageType);
1263
+ const p = m.payload ?? {};
1264
+ const legenda = p.caption ?? null;
1265
+ const rotulo = /* @__PURE__ */ jsx("p", { className: "whitespace-pre-wrap break-words", children: m.body });
1266
+ if (erro) {
1267
+ return /* @__PURE__ */ jsxs("div", { ref, className: "space-y-1", children: [
1268
+ rotulo,
1269
+ /* @__PURE__ */ jsx("p", { className: "text-xs opacity-60", children: "O arquivo n\xE3o carregou." })
1270
+ ] });
1271
+ }
1272
+ if (!url) {
1273
+ return /* @__PURE__ */ jsxs("div", { ref, className: "space-y-1", children: [
1274
+ rotulo,
1275
+ /* @__PURE__ */ jsx("div", { className: "h-1 w-24 animate-pulse rounded bg-current opacity-20" })
1276
+ ] });
1277
+ }
1278
+ if (tipo === "image" || tipo === "sticker") {
1279
+ return /* @__PURE__ */ jsxs("div", { ref, className: "space-y-1", children: [
1280
+ /* @__PURE__ */ jsx("a", { href: url, target: "_blank", rel: "noreferrer", children: /* @__PURE__ */ jsx(
1281
+ "img",
1282
+ {
1283
+ src: url,
1284
+ alt: legenda ?? (tipo === "sticker" ? "Figurinha" : "Imagem recebida"),
1285
+ className: tipo === "sticker" ? "max-h-32 w-auto" : "max-h-80 w-auto rounded-lg",
1286
+ loading: "lazy"
1287
+ }
1288
+ ) }),
1289
+ legenda ? /* @__PURE__ */ jsx("p", { className: "whitespace-pre-wrap break-words", children: legenda }) : null
1290
+ ] });
1291
+ }
1292
+ if (tipo === "video") {
1293
+ return /* @__PURE__ */ jsxs("div", { ref, className: "space-y-1", children: [
1294
+ /* @__PURE__ */ jsx("video", { src: url, controls: true, preload: "metadata", className: "max-h-80 w-auto rounded-lg" }),
1295
+ legenda ? /* @__PURE__ */ jsx("p", { className: "whitespace-pre-wrap break-words", children: legenda }) : null
1296
+ ] });
1297
+ }
1298
+ if (tipo === "audio") {
1299
+ return /* @__PURE__ */ jsxs("div", { ref, className: "space-y-1", children: [
1300
+ /* @__PURE__ */ jsx("audio", { src: url, controls: true, preload: "metadata", className: "h-9 w-64 max-w-full" }),
1301
+ /* @__PURE__ */ jsxs("p", { className: "text-xs opacity-60", children: [
1302
+ p.ptt ? "Mensagem de voz" : "\xC1udio",
1303
+ duracao(p.seconds) ? ` \xB7 ${duracao(p.seconds)}` : ""
1304
+ ] })
1305
+ ] });
1306
+ }
1307
+ const nome = p.fileName ?? "Documento";
1308
+ return /* @__PURE__ */ jsxs("div", { ref, className: "space-y-1", children: [
1309
+ /* @__PURE__ */ jsxs(
1310
+ "a",
1311
+ {
1312
+ href: url,
1313
+ target: "_blank",
1314
+ rel: "noreferrer",
1315
+ download: nome,
1316
+ className: `flex items-center gap-2 rounded-lg border p-2 ${outbound ? "border-white/30" : "border-black/10"}`,
1317
+ children: [
1318
+ /* @__PURE__ */ jsx(FileText, { className: "h-5 w-5 shrink-0 opacity-70" }),
1319
+ /* @__PURE__ */ jsxs("span", { className: "min-w-0 flex-1", children: [
1320
+ /* @__PURE__ */ jsx("span", { className: "block truncate text-sm", children: nome }),
1321
+ tamanho(p.fileLength) ? /* @__PURE__ */ jsx("span", { className: "block text-xs opacity-60", children: tamanho(p.fileLength) }) : null
1322
+ ] }),
1323
+ /* @__PURE__ */ jsx(Download, { className: "h-4 w-4 shrink-0 opacity-70" })
1324
+ ]
1325
+ }
1326
+ ),
1327
+ legenda ? /* @__PURE__ */ jsx("p", { className: "whitespace-pre-wrap break-words", children: legenda }) : null
1328
+ ] });
1329
+ }
1330
+ function ContactCard({ contacts, outbound }) {
1331
+ const [copied, setCopied] = React12__default.useState(null);
1332
+ const copy = (value) => {
1333
+ void navigator.clipboard?.writeText(value);
1334
+ setCopied(value);
1335
+ window.setTimeout(() => setCopied((c) => c === value ? null : c), 1500);
1336
+ };
1337
+ return /* @__PURE__ */ jsx("div", { className: "flex flex-col gap-2", children: contacts.map((c, i) => /* @__PURE__ */ jsxs(
1338
+ "div",
1339
+ {
1340
+ className: cn(
1341
+ "flex items-start gap-2.5 rounded-xl px-2.5 py-2",
1342
+ outbound ? "bg-white/15" : "bg-muted"
1343
+ ),
1344
+ children: [
1345
+ /* @__PURE__ */ jsx(UserRound, { className: cn("mt-0.5 h-4 w-4 shrink-0", outbound ? "text-white/80" : "text-muted-foreground") }),
1346
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
1347
+ /* @__PURE__ */ jsx("p", { className: "truncate font-medium", children: c.name }),
1348
+ c.phones.map((p) => /* @__PURE__ */ jsxs(
1349
+ "button",
1350
+ {
1351
+ type: "button",
1352
+ onClick: () => copy(p.display),
1353
+ className: cn(
1354
+ "group/phone mt-0.5 flex items-center gap-1.5 text-left text-[13px] tabular-nums",
1355
+ outbound ? "text-white/90 hover:text-white" : "text-muted-foreground hover:text-foreground"
1356
+ ),
1357
+ children: [
1358
+ p.display,
1359
+ copied === p.display ? /* @__PURE__ */ jsx(Check, { className: "h-3 w-3" }) : /* @__PURE__ */ jsx(Copy, { className: "h-3 w-3 opacity-0 transition-opacity group-hover/phone:opacity-100" })
1360
+ ]
1361
+ },
1362
+ p.digits
1363
+ ))
1364
+ ] })
1365
+ ]
1366
+ },
1367
+ `${c.name}-${i}`
1368
+ )) });
1369
+ }
1370
+ function MessageBody({ m, outbound }) {
1371
+ const contacts = m.messageType === "contact" ? m.payload?.contacts ?? [] : [];
1372
+ if (contacts.length > 0) return /* @__PURE__ */ jsx(ContactCard, { contacts, outbound });
1373
+ if (temArquivo(m)) return /* @__PURE__ */ jsx(MediaBubble, { m, outbound });
1374
+ return /* @__PURE__ */ jsx("p", { className: "whitespace-pre-wrap break-words", children: m.body });
1375
+ }
1202
1376
  function buildRows(messages) {
1203
1377
  const rows = [];
1204
1378
  let lastDay = "";
@@ -1219,14 +1393,14 @@ function buildRows(messages) {
1219
1393
  function MessageThread({ selected, onTogglePanel, panelOpen, onBack, className }) {
1220
1394
  const t = useTranslation();
1221
1395
  const { messages, sending, send, setStatus, refreshMessages } = useConversationsStore((s) => s);
1222
- const [draft, setDraft] = React11__default.useState("");
1396
+ const [draft, setDraft] = React12__default.useState("");
1223
1397
  const win = useSendWindow(selected.id);
1224
1398
  const canType = win.mode.kind === "free";
1225
- const threadRef = React11__default.useRef(null);
1226
- React11__default.useEffect(() => {
1399
+ const threadRef = React12__default.useRef(null);
1400
+ React12__default.useEffect(() => {
1227
1401
  threadRef.current?.scrollTo({ top: threadRef.current.scrollHeight, behavior: "smooth" });
1228
1402
  }, [messages.length, selected.id]);
1229
- React11__default.useEffect(() => {
1403
+ React12__default.useEffect(() => {
1230
1404
  if (!selected.id) return;
1231
1405
  let timer = null;
1232
1406
  const start = () => {
@@ -1255,7 +1429,7 @@ function MessageThread({ selected, onTogglePanel, panelOpen, onBack, className }
1255
1429
  await send(body);
1256
1430
  }
1257
1431
  const accent = CHANNEL_ACCENT[selected.channel];
1258
- const rows = React11__default.useMemo(() => buildRows(messages), [messages]);
1432
+ const rows = React12__default.useMemo(() => buildRows(messages), [messages]);
1259
1433
  return /* @__PURE__ */ jsxs("section", { className: cn("flex min-w-0 flex-1 flex-col bg-muted/20", className), children: [
1260
1434
  /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2 border-b border-border bg-card px-3 py-2.5 md:px-5", children: [
1261
1435
  /* @__PURE__ */ jsxs("div", { className: "flex min-w-0 items-center gap-2 md:gap-3", children: [
@@ -1317,7 +1491,7 @@ function MessageThread({ selected, onTogglePanel, panelOpen, onBack, className }
1317
1491
  ),
1318
1492
  style: outbound ? { backgroundColor: failed ? "hsl(var(--destructive))" : accent.color } : void 0,
1319
1493
  children: [
1320
- /* @__PURE__ */ jsx("p", { className: "whitespace-pre-wrap break-words", children: m.body }),
1494
+ /* @__PURE__ */ jsx(MessageBody, { m, outbound }),
1321
1495
  /* @__PURE__ */ jsxs("div", { className: cn("mt-0.5 flex items-center justify-end gap-1 text-[10px]", outbound ? "text-white/70" : "text-muted-foreground"), children: [
1322
1496
  new Date(m.at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
1323
1497
  /* @__PURE__ */ jsx(DeliveryMark, { m })
@@ -1367,8 +1541,8 @@ var TEMP = {
1367
1541
  cold: { icon: Snowflake, cls: "bg-info-soft text-info-soft-foreground", label: "Frio" }
1368
1542
  };
1369
1543
  function LeadCard({ personId, handle }) {
1370
- const [lead, setLead] = React11__default.useState(void 0);
1371
- React11__default.useEffect(() => {
1544
+ const [lead, setLead] = React12__default.useState(void 0);
1545
+ React12__default.useEffect(() => {
1372
1546
  let alive = true;
1373
1547
  const sb = getSupabaseClientOptional();
1374
1548
  const tenantId = getActiveTenantId();
@@ -1535,14 +1709,14 @@ function NewConversationPanel({
1535
1709
  const create = useConversationsStore((s) => s.create);
1536
1710
  const config = useConversationsConfig();
1537
1711
  const guardConversations = useLimitGuard("conversations_month");
1538
- const [channel, setChannel] = React11__default.useState("whatsapp");
1539
- const [contact, setContact] = React11__default.useState(null);
1540
- const [typedHandle, setTypedHandle] = React11__default.useState("");
1541
- const [creatingContact, setCreatingContact] = React11__default.useState(false);
1542
- const [firstMessage, setFirstMessage] = React11__default.useState("");
1543
- const [submitting, setSubmitting] = React11__default.useState(false);
1544
- const [pickerKey, setPickerKey] = React11__default.useState(0);
1545
- React11__default.useEffect(() => {
1712
+ const [channel, setChannel] = React12__default.useState("whatsapp");
1713
+ const [contact, setContact] = React12__default.useState(null);
1714
+ const [typedHandle, setTypedHandle] = React12__default.useState("");
1715
+ const [creatingContact, setCreatingContact] = React12__default.useState(false);
1716
+ const [firstMessage, setFirstMessage] = React12__default.useState("");
1717
+ const [submitting, setSubmitting] = React12__default.useState(false);
1718
+ const [pickerKey, setPickerKey] = React12__default.useState(0);
1719
+ React12__default.useEffect(() => {
1546
1720
  if (open) {
1547
1721
  setChannel("whatsapp");
1548
1722
  setContact(null);
@@ -1661,9 +1835,9 @@ function useCaixaAoVivo() {
1661
1835
  const load = useConversationsStore((s) => s.load);
1662
1836
  const refreshMessages = useConversationsStore((s) => s.refreshMessages);
1663
1837
  const selectedId = useConversationsStore((s) => s.selectedId);
1664
- const abertoRef = React11.useRef(selectedId);
1838
+ const abertoRef = React12.useRef(selectedId);
1665
1839
  abertoRef.current = selectedId;
1666
- React11.useEffect(() => {
1840
+ React12.useEffect(() => {
1667
1841
  if (!tenantId) return;
1668
1842
  return assinarChegada(tenantId, ({ conversationId }) => {
1669
1843
  void load();
@@ -1678,10 +1852,10 @@ function InboxView() {
1678
1852
  const { conversations, selectedId, deselect } = useConversationsStore((s) => s);
1679
1853
  useCaixaAoVivo();
1680
1854
  const isWidePanel = useMediaQuery("(min-width: 1280px)");
1681
- const [panelOpen, setPanelOpen] = React11__default.useState(false);
1682
- const [newOpen, setNewOpen] = React11__default.useState(false);
1855
+ const [panelOpen, setPanelOpen] = React12__default.useState(false);
1856
+ const [newOpen, setNewOpen] = React12__default.useState(false);
1683
1857
  const selected = conversations.find((c) => c.id === selectedId) ?? null;
1684
- React11__default.useEffect(() => {
1858
+ React12__default.useEffect(() => {
1685
1859
  setPanelOpen(isWidePanel);
1686
1860
  }, [isWidePanel]);
1687
1861
  return /* @__PURE__ */ jsxs(Fragment, { children: [
@@ -1754,7 +1928,7 @@ function requestedContact() {
1754
1928
  }
1755
1929
  }
1756
1930
  function ConversationsPage({ store: store2, config }) {
1757
- React11__default.useEffect(() => {
1931
+ React12__default.useEffect(() => {
1758
1932
  const wanted = requestedContact();
1759
1933
  if (!wanted) {
1760
1934
  void store2.getState().load();
@@ -2078,7 +2252,13 @@ function mapMessage(r) {
2078
2252
  at: r.at ?? "",
2079
2253
  author: r.author ?? "",
2080
2254
  deliveryStatus: r.delivery_status ?? null,
2081
- providerMessageId: r.provider_message_id ?? null
2255
+ providerMessageId: r.provider_message_id ?? null,
2256
+ // NULL stays null rather than defaulting to 'text'. A row written before
2257
+ // migration 012 genuinely does not know what it was, and the rows that most
2258
+ // need saying so are the empty ones that a 'text' default would call fine.
2259
+ messageType: r.message_type ?? null,
2260
+ payload: r.payload ?? null,
2261
+ mediaPath: r.media_path ?? null
2082
2262
  };
2083
2263
  }
2084
2264
  function createSupabaseConversationsProvider(config) {
@@ -2837,22 +3017,22 @@ function NumberRow({ n }) {
2837
3017
  ] });
2838
3018
  }
2839
3019
  function WhatsAppProviders() {
2840
- const [rows, setRows] = React11__default.useState(null);
2841
- const [draft, setDraft] = React11__default.useState({});
2842
- const [busy, setBusy] = React11__default.useState(null);
2843
- const [numbers, setNumbers] = React11__default.useState({});
2844
- const [qr, setQr] = React11__default.useState(null);
2845
- const load = React11__default.useCallback(async () => {
3020
+ const [rows, setRows] = React12__default.useState(null);
3021
+ const [draft, setDraft] = React12__default.useState({});
3022
+ const [busy, setBusy] = React12__default.useState(null);
3023
+ const [numbers, setNumbers] = React12__default.useState({});
3024
+ const [qr, setQr] = React12__default.useState(null);
3025
+ const load = React12__default.useCallback(async () => {
2846
3026
  try {
2847
3027
  setRows(await providersApi.list());
2848
3028
  } catch {
2849
3029
  setRows([]);
2850
3030
  }
2851
3031
  }, []);
2852
- React11__default.useEffect(() => {
3032
+ React12__default.useEffect(() => {
2853
3033
  void load();
2854
3034
  }, [load]);
2855
- const byId = React11__default.useMemo(
3035
+ const byId = React12__default.useMemo(
2856
3036
  () => Object.fromEntries((rows ?? []).map((r) => [r.provider, r])),
2857
3037
  [rows]
2858
3038
  );
@@ -3087,9 +3267,9 @@ function ChannelRow({ account }) {
3087
3267
  }
3088
3268
  function ConversationsGeneralSettings() {
3089
3269
  const t = useTranslation();
3090
- const [channels, setChannels] = React11.useState(null);
3091
- const [failed, setFailed] = React11.useState(false);
3092
- React11.useEffect(() => {
3270
+ const [channels, setChannels] = React12.useState(null);
3271
+ const [failed, setFailed] = React12.useState(false);
3272
+ React12.useEffect(() => {
3093
3273
  let cancelled = false;
3094
3274
  listMessagingChannels().then((rows) => {
3095
3275
  if (!cancelled) setChannels(rows);
@@ -5483,6 +5663,134 @@ END $$;
5483
5663
  COMMENT ON FUNCTION public.plg_conversations_anunciar_chegada() IS
5484
5664
  'Toca a campainha no t\xF3pico \`conversas:<tenant>\` \u2014 s\xF3 o id da conversa. O conte\xFAdo \xE9 lido da tabela, com RLS (011).';
5485
5665
  `;
5666
+ var MIGRATION_012_A_RICH_MESSAGE_KEEPS_ITS_SHAPE = `-- ---------------------------------------------------------------------------
5667
+ -- 012_a_rich_message_keeps_its_shape.sql
5668
+ --
5669
+ -- \u2500\u2500 what was lost, and how \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
5670
+ --
5671
+ -- \`plg_conversation_messages\` has exactly one content column: \`body text\`. A
5672
+ -- WhatsApp message that is not text therefore arrives flattened to whatever
5673
+ -- string the ingest could make of it \u2014 and when the ingest could make none, to
5674
+ -- the empty string, which renders as an empty bubble.
5675
+ --
5676
+ -- On 2026-09-22 a lead sent his finance director's contact card. It stored as
5677
+ -- \`body = ''\`. He then sent a screenshot of the same card; that stored as
5678
+ -- \`body = ''\` too. The fact the conversation existed to obtain \u2014 a name and a
5679
+ -- phone number \u2014 was in the payload both times and in the row neither time.
5680
+ --
5681
+ -- Recovering it meant going back to the Evolution instance and reading the
5682
+ -- provider's own copy. That worked, but it is not a recovery path: Evolution
5683
+ -- prunes, the instance is local-only, and a row that needs a second system to
5684
+ -- be legible is not a record.
5685
+ --
5686
+ -- \u2500\u2500 the two columns \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\u2500\u2500\u2500\u2500\u2500\u2500\u2500
5687
+ --
5688
+ -- \`message_type\` names what the provider sent \u2014 text, contact, image, audio,
5689
+ -- document, sticker, location, reaction, unknown. The inbox needs it to choose
5690
+ -- a bubble, and \`unknown\` is the one that makes the NEXT gap visible in a query
5691
+ -- instead of in a complaint.
5692
+ --
5693
+ -- \`payload\` keeps the part that does not survive flattening: the vCard's parsed
5694
+ -- name and numbers, a media mimetype and size, a location's coordinates. Not
5695
+ -- the whole provider envelope \u2014 that carries decryption keys and a base64
5696
+ -- thumbnail, neither of which belongs in a row that every member of the tenant
5697
+ -- can read.
5698
+ --
5699
+ -- Deliberately NOT stored here: delivery status. It changes after the fact \u2014
5700
+ -- sent, delivered, read \u2014 and \`conversations-provider\`'s \`message_detail\` reads
5701
+ -- it live for that reason. \`payload\` is the shape of the message, which does
5702
+ -- not change once it arrives.
5703
+ --
5704
+ -- \u2500\u2500 why both are nullable, and nothing is backfilled \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
5705
+ --
5706
+ -- Every row written before this migration has neither, and no default can
5707
+ -- invent them: \`message_type\` would have to guess, and guessing 'text' for the
5708
+ -- three empty bubbles already in the table would assert something false about
5709
+ -- exactly the rows this exists to fix. A reader that treats NULL as "written
5710
+ -- before 012" is correct; a reader that treats '' as text is the bug.
5711
+ --
5712
+ -- Table-level \`GRANT ALL ... TO authenticated\` already covers new columns, and
5713
+ -- the four tenant policies are row-scoped, so neither needs restating.
5714
+ --
5715
+ -- Idempotent and replay-safe.
5716
+ -- ---------------------------------------------------------------------------
5717
+
5718
+ ALTER TABLE public.plg_conversation_messages
5719
+ ADD COLUMN IF NOT EXISTS message_type text,
5720
+ ADD COLUMN IF NOT EXISTS payload jsonb;
5721
+
5722
+ COMMENT ON COLUMN public.plg_conversation_messages.message_type IS
5723
+ 'What the provider sent: text, contact, image, video, audio, document, sticker, location, reaction, unknown. NULL means the row predates 012 (#504).';
5724
+
5725
+ COMMENT ON COLUMN public.plg_conversation_messages.payload IS
5726
+ 'The structured remainder that \`body\` cannot hold \u2014 parsed vCards, media metadata, coordinates. Never credentials, thumbnails or decryption keys; never delivery status, which is read live (#504).';
5727
+ `;
5728
+ var MIGRATION_013_A_MIDIA_FICA_CONOSCO = `-- ---------------------------------------------------------------------------
5729
+ -- 013_a_midia_fica_conosco.sql
5730
+ --
5731
+ -- \u2500\u2500 o que a 012 resolveu, e o que ela n\xE3o resolveu \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
5732
+ --
5733
+ -- A 012 fez a mensagem que n\xE3o \xE9 texto parar de chegar como bolha vazia:
5734
+ -- \`message_type\` diz que \xE9 imagem, \`payload\` guarda mimetype, tamanho e
5735
+ -- dura\xE7\xE3o. Funcionou \u2014 um \xE1udio de 23 segundos entrou classificado como
5736
+ -- \`audio\`, \`ptt: true\`, \`audio/ogg; codecs=opus\`, 55.825 bytes.
5737
+ --
5738
+ -- E a tela mostra \`[\xE1udio]\`. Porque saber QUE existe um arquivo n\xE3o \xE9 ter o
5739
+ -- arquivo. Nada no banco aponta para bytes nenhuns: o WhatsApp entrega m\xEDdia
5740
+ -- cifrada num CDN pr\xF3prio, e quem decifra \xE9 o provedor.
5741
+ --
5742
+ -- \u2500\u2500 por que uma coluna, e n\xE3o um campo no payload \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
5743
+ --
5744
+ -- A 012 escreveu que \`payload\` \xE9 "a forma da mensagem, que n\xE3o muda depois que
5745
+ -- ela chega". Onde o arquivo foi parar \xE9 decidido DEPOIS \u2014 no upload, que pode
5746
+ -- falhar, ser refeito, ou ainda n\xE3o ter acontecido. Enfiar isso no \`payload\`
5747
+ -- contradiz a \xFAnica frase que diz quando \`payload\` pode ser lido sem medo.
5748
+ --
5749
+ -- Em coluna, a pergunta que importa vira consulta:
5750
+ --
5751
+ -- SELECT count(*) FROM plg_conversation_messages
5752
+ -- WHERE message_type IN ('image','video','audio','document','sticker')
5753
+ -- AND media_path IS NULL;
5754
+ --
5755
+ -- "quanta m\xEDdia eu recebi e n\xE3o guardei" \u2014 que num campo de JSON seria uma
5756
+ -- varredura e aqui \xE9 um \xEDndice.
5757
+ --
5758
+ -- \u2500\u2500 por que guardar, em vez de buscar no provedor quando algu\xE9m abrir \u2500\u2500\u2500
5759
+ --
5760
+ -- A Evolution decifra sob demanda, e seria mais barato n\xE3o copiar nada. Mas ela
5761
+ -- \xE9 local-only, poda o que \xE9 antigo, e a inst\xE2ncia roda numa m\xE1quina que nem
5762
+ -- sempre est\xE1 de p\xE9. Essa era exatamente a obje\xE7\xE3o da 012 contra depender dela
5763
+ -- para ler um vCard: uma linha que precisa de um segundo sistema para ser
5764
+ -- leg\xEDvel n\xE3o \xE9 registro. Vale igual para os bytes.
5765
+ --
5766
+ -- O destino \xE9 o balde \`attachments\` \u2014 privado, 25 MB, j\xE1 existente e j\xE1 em uso.
5767
+ -- Uma foto que um lead mandou no WhatsApp n\xE3o pode ser p\xFAblica, e um balde novo
5768
+ -- seria mais uma lista de mimetypes para algu\xE9m manter desatualizada.
5769
+ --
5770
+ -- \u2500\u2500 nulo \xE9 um estado leg\xEDtimo, n\xE3o uma 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
5771
+ --
5772
+ -- \`media_path\` \xE9 NULL em tr\xEAs situa\xE7\xF5es diferentes e todas normais: a mensagem
5773
+ -- \xE9 texto; a linha \xE9 anterior a esta migra\xE7\xE3o; ou o upload ainda n\xE3o aconteceu.
5774
+ -- Quem l\xEA distingue pela combina\xE7\xE3o com \`message_type\`, que \xE9 o que torna a
5775
+ -- consulta acima poss\xEDvel. Um default inventaria um caminho para arquivo que
5776
+ -- n\xE3o existe.
5777
+ --
5778
+ -- Idempotente e replay-safe.
5779
+ -- ---------------------------------------------------------------------------
5780
+
5781
+ ALTER TABLE public.plg_conversation_messages
5782
+ ADD COLUMN IF NOT EXISTS media_path text;
5783
+
5784
+ COMMENT ON COLUMN public.plg_conversation_messages.media_path IS
5785
+ 'Onde os bytes ficaram no balde \`attachments\`, ou NULL: texto, linha anterior \xE0 013, ou upload que ainda n\xE3o aconteceu. Distingue-se pela combina\xE7\xE3o com \`message_type\` (013).';
5786
+
5787
+ -- Parcial: s\xF3 a m\xEDdia sem arquivo interessa, e \xE9 a minoria. O \xEDndice fica do
5788
+ -- tamanho do problema em vez do tamanho da tabela.
5789
+ CREATE INDEX IF NOT EXISTS plg_conversation_messages_midia_sem_arquivo
5790
+ ON public.plg_conversation_messages (tenant_id, at DESC)
5791
+ WHERE media_path IS NULL
5792
+ AND message_type IN ('image', 'video', 'audio', 'document', 'sticker');
5793
+ `;
5486
5794
  var MIGRATIONS = [
5487
5795
  { id: "000_baseline", sql: MIGRATION_000_BASELINE },
5488
5796
  { id: "001_a_caixa_sabe_quem_falou_por_ultimo", sql: MIGRATION_001_A_CAIXA_SABE_QUEM_FALOU_POR_ULTIMO },
@@ -5495,7 +5803,9 @@ var MIGRATIONS = [
5495
5803
  { id: "008_dar_por_vista_e_uma_decisao", sql: MIGRATION_008_DAR_POR_VISTA_E_UMA_DECISAO },
5496
5804
  { id: "009_a_mensagem_inteira_atravessa_o_realtime", sql: MIGRATION_009_A_MENSAGEM_INTEIRA_ATRAVESSA_O_REALTIME },
5497
5805
  { id: "010_a_chegada_e_anunciada_por_conta", sql: MIGRATION_010_A_CHEGADA_E_ANUNCIADA_POR_CONTA },
5498
- { id: "011_o_anuncio_e_uma_campainha_nao_um_recado", sql: MIGRATION_011_O_ANUNCIO_E_UMA_CAMPAINHA_NAO_UM_RECADO }
5806
+ { id: "011_o_anuncio_e_uma_campainha_nao_um_recado", sql: MIGRATION_011_O_ANUNCIO_E_UMA_CAMPAINHA_NAO_UM_RECADO },
5807
+ { id: "012_a_rich_message_keeps_its_shape", sql: MIGRATION_012_A_RICH_MESSAGE_KEEPS_ITS_SHAPE },
5808
+ { id: "013_a_midia_fica_conosco", sql: MIGRATION_013_A_MIDIA_FICA_CONOSCO }
5499
5809
  ];
5500
5810
 
5501
5811
  // src/index.ts
@@ -5529,11 +5839,11 @@ function createConversationsPlugin(options) {
5529
5839
  contactEntityDef: options?.contactEntityDef
5530
5840
  };
5531
5841
  const dashboardWidgets = createConversationsDashboardWidgets({ store: store2, config });
5532
- const PageComponent = () => React11__default.createElement(ConversationsPage, { store: store2, config });
5842
+ const PageComponent = () => React12__default.createElement(ConversationsPage, { store: store2, config });
5533
5843
  PageComponent.displayName = "ConversationsPage";
5534
- const CampaignsPageComponent = () => React11__default.createElement(CampaignsView);
5844
+ const CampaignsPageComponent = () => React12__default.createElement(CampaignsView);
5535
5845
  CampaignsPageComponent.displayName = "ConversationsCampaignsPage";
5536
- const TemplatesPageComponent = () => React11__default.createElement(TemplatesView);
5846
+ const TemplatesPageComponent = () => React12__default.createElement(TemplatesView);
5537
5847
  TemplatesPageComponent.displayName = "ConversationsTemplatesPage";
5538
5848
  return {
5539
5849
  id: "conversations",
@@ -5609,7 +5919,7 @@ function createConversationsPlugin(options) {
5609
5919
  // quando ele serve para alguma coisa.
5610
5920
  zone: "shell.topbar.end",
5611
5921
  component: (() => {
5612
- const W = () => React11__default.createElement(InboxWatcher, {
5922
+ const W = () => React12__default.createElement(InboxWatcher, {
5613
5923
  label: options?.navLabel ?? "Conversations",
5614
5924
  section: options?.navSection ?? "main",
5615
5925
  position: options?.navPosition ?? 1