@fayz-ai/plugin-conversations 0.18.0 → 0.20.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,10 +1,10 @@
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, UserRound, Copy } from 'lucide-react';
4
- import { toast, Button, Input, defineKpiWidget, defineTableWidget, PageHeaderActions, cn, Badge, FilterChip, Skeleton, KpiCard, TableWidget } from '@fayz-ai/ui';
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
+ import { toast, Button, Input, defineKpiWidget, defineTableWidget, PageHeaderActions, cn, Badge, Modal, ModalContent, ModalHeader, ModalTitle, ModalBody, ModalFooter, 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';
7
- import { SettingsGroup, PluginSettingsPanel, useNavContribution, PermissionGate, useLimitGuard, RightRailPage, ContactPicker, invalidateLimit } from '@fayz-ai/admin';
7
+ import { SettingsGroup, PluginSettingsPanel, useNavContribution, useRightRailStore, PageActions, useLimitGuard, ContactPicker, invalidateLimit } from '@fayz-ai/admin';
8
8
  import { useStore } from 'zustand';
9
9
  import { createStore } from 'zustand/vanilla';
10
10
 
@@ -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,8 +1199,136 @@ 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
+ }
1202
1330
  function ContactCard({ contacts, outbound }) {
1203
- const [copied, setCopied] = React11__default.useState(null);
1331
+ const [copied, setCopied] = React12__default.useState(null);
1204
1332
  const copy = (value) => {
1205
1333
  void navigator.clipboard?.writeText(value);
1206
1334
  setCopied(value);
@@ -1242,6 +1370,7 @@ function ContactCard({ contacts, outbound }) {
1242
1370
  function MessageBody({ m, outbound }) {
1243
1371
  const contacts = m.messageType === "contact" ? m.payload?.contacts ?? [] : [];
1244
1372
  if (contacts.length > 0) return /* @__PURE__ */ jsx(ContactCard, { contacts, outbound });
1373
+ if (temArquivo(m)) return /* @__PURE__ */ jsx(MediaBubble, { m, outbound });
1245
1374
  return /* @__PURE__ */ jsx("p", { className: "whitespace-pre-wrap break-words", children: m.body });
1246
1375
  }
1247
1376
  function buildRows(messages) {
@@ -1264,14 +1393,14 @@ function buildRows(messages) {
1264
1393
  function MessageThread({ selected, onTogglePanel, panelOpen, onBack, className }) {
1265
1394
  const t = useTranslation();
1266
1395
  const { messages, sending, send, setStatus, refreshMessages } = useConversationsStore((s) => s);
1267
- const [draft, setDraft] = React11__default.useState("");
1396
+ const [draft, setDraft] = React12__default.useState("");
1268
1397
  const win = useSendWindow(selected.id);
1269
1398
  const canType = win.mode.kind === "free";
1270
- const threadRef = React11__default.useRef(null);
1271
- React11__default.useEffect(() => {
1399
+ const threadRef = React12__default.useRef(null);
1400
+ React12__default.useEffect(() => {
1272
1401
  threadRef.current?.scrollTo({ top: threadRef.current.scrollHeight, behavior: "smooth" });
1273
1402
  }, [messages.length, selected.id]);
1274
- React11__default.useEffect(() => {
1403
+ React12__default.useEffect(() => {
1275
1404
  if (!selected.id) return;
1276
1405
  let timer = null;
1277
1406
  const start = () => {
@@ -1300,7 +1429,7 @@ function MessageThread({ selected, onTogglePanel, panelOpen, onBack, className }
1300
1429
  await send(body);
1301
1430
  }
1302
1431
  const accent = CHANNEL_ACCENT[selected.channel];
1303
- const rows = React11__default.useMemo(() => buildRows(messages), [messages]);
1432
+ const rows = React12__default.useMemo(() => buildRows(messages), [messages]);
1304
1433
  return /* @__PURE__ */ jsxs("section", { className: cn("flex min-w-0 flex-1 flex-col bg-muted/20", className), children: [
1305
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: [
1306
1435
  /* @__PURE__ */ jsxs("div", { className: "flex min-w-0 items-center gap-2 md:gap-3", children: [
@@ -1308,7 +1437,7 @@ function MessageThread({ selected, onTogglePanel, panelOpen, onBack, className }
1308
1437
  /* @__PURE__ */ jsx(Avatar, { name: selected.contactName, accent: selected.accent, size: "sm", channel: selected.channel }),
1309
1438
  /* @__PURE__ */ jsxs("div", { className: "min-w-0", children: [
1310
1439
  /* @__PURE__ */ jsx("p", { className: "truncate text-sm font-semibold text-foreground", children: selected.contactName }),
1311
- /* @__PURE__ */ jsxs("p", { className: "flex items-center gap-1.5 text-xs text-muted-foreground", children: [
1440
+ !panelOpen && /* @__PURE__ */ jsxs("p", { className: "flex items-center gap-1.5 text-xs text-muted-foreground", children: [
1312
1441
  /* @__PURE__ */ jsx(ChannelBadge, { channel: selected.channel }),
1313
1442
  /* @__PURE__ */ jsx("span", { className: "truncate", children: selected.contactHandle })
1314
1443
  ] })
@@ -1412,8 +1541,8 @@ var TEMP = {
1412
1541
  cold: { icon: Snowflake, cls: "bg-info-soft text-info-soft-foreground", label: "Frio" }
1413
1542
  };
1414
1543
  function LeadCard({ personId, handle }) {
1415
- const [lead, setLead] = React11__default.useState(void 0);
1416
- React11__default.useEffect(() => {
1544
+ const [lead, setLead] = React12__default.useState(void 0);
1545
+ React12__default.useEffect(() => {
1417
1546
  let alive = true;
1418
1547
  const sb = getSupabaseClientOptional();
1419
1548
  const tenantId = getActiveTenantId();
@@ -1580,14 +1709,14 @@ function NewConversationPanel({
1580
1709
  const create = useConversationsStore((s) => s.create);
1581
1710
  const config = useConversationsConfig();
1582
1711
  const guardConversations = useLimitGuard("conversations_month");
1583
- const [channel, setChannel] = React11__default.useState("whatsapp");
1584
- const [contact, setContact] = React11__default.useState(null);
1585
- const [typedHandle, setTypedHandle] = React11__default.useState("");
1586
- const [creatingContact, setCreatingContact] = React11__default.useState(false);
1587
- const [firstMessage, setFirstMessage] = React11__default.useState("");
1588
- const [submitting, setSubmitting] = React11__default.useState(false);
1589
- const [pickerKey, setPickerKey] = React11__default.useState(0);
1590
- 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(() => {
1591
1720
  if (open) {
1592
1721
  setChannel("whatsapp");
1593
1722
  setContact(null);
@@ -1624,14 +1753,10 @@ function NewConversationPanel({
1624
1753
  setSubmitting(false);
1625
1754
  }
1626
1755
  }
1627
- return /* @__PURE__ */ jsx(
1628
- RightRailPage,
1629
- {
1630
- title: t("conversations.new.title"),
1631
- open,
1632
- onClose: () => onOpenChange(false),
1633
- width: "max-w-md",
1634
- children: /* @__PURE__ */ jsxs("form", { onSubmit: handleSubmit, className: "flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-5 py-4", children: [
1756
+ return /* @__PURE__ */ jsx(Modal, { open, onOpenChange, children: /* @__PURE__ */ jsxs(ModalContent, { className: "max-w-md", children: [
1757
+ /* @__PURE__ */ jsx(ModalHeader, { children: /* @__PURE__ */ jsx(ModalTitle, { children: t("conversations.new.title") }) }),
1758
+ /* @__PURE__ */ jsxs("form", { onSubmit: handleSubmit, className: "flex min-h-0 flex-1 flex-col overflow-hidden", children: [
1759
+ /* @__PURE__ */ jsxs(ModalBody, { className: "space-y-4", children: [
1635
1760
  /* @__PURE__ */ jsxs("div", { children: [
1636
1761
  /* @__PURE__ */ jsx("label", { className: "mb-1.5 block text-xs font-medium text-muted-foreground", children: t("conversations.new.channel") }),
1637
1762
  /* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-1.5", children: CHANNELS.map((ch) => {
@@ -1695,20 +1820,20 @@ function NewConversationPanel({
1695
1820
  className: "max-h-40 min-h-[64px] w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary"
1696
1821
  }
1697
1822
  )
1698
- ] }),
1699
- /* @__PURE__ */ jsx("div", { className: "mt-auto pt-2", children: /* @__PURE__ */ jsx(Button, { type: "submit", disabled: !canSubmit, className: "w-full", children: submitting ? t("conversations.new.creating") : t("conversations.new.create") }) })
1700
- ] })
1701
- }
1702
- );
1823
+ ] })
1824
+ ] }),
1825
+ /* @__PURE__ */ jsx(ModalFooter, { children: /* @__PURE__ */ jsx(Button, { type: "submit", disabled: !canSubmit, className: "w-full", children: submitting ? t("conversations.new.creating") : t("conversations.new.create") }) })
1826
+ ] })
1827
+ ] }) });
1703
1828
  }
1704
1829
  function useCaixaAoVivo() {
1705
1830
  const tenantId = useActiveTenantId();
1706
1831
  const load = useConversationsStore((s) => s.load);
1707
1832
  const refreshMessages = useConversationsStore((s) => s.refreshMessages);
1708
1833
  const selectedId = useConversationsStore((s) => s.selectedId);
1709
- const abertoRef = React11.useRef(selectedId);
1834
+ const abertoRef = React12.useRef(selectedId);
1710
1835
  abertoRef.current = selectedId;
1711
- React11.useEffect(() => {
1836
+ React12.useEffect(() => {
1712
1837
  if (!tenantId) return;
1713
1838
  return assinarChegada(tenantId, ({ conversationId }) => {
1714
1839
  void load();
@@ -1723,26 +1848,26 @@ function InboxView() {
1723
1848
  const { conversations, selectedId, deselect } = useConversationsStore((s) => s);
1724
1849
  useCaixaAoVivo();
1725
1850
  const isWidePanel = useMediaQuery("(min-width: 1280px)");
1726
- const [panelOpen, setPanelOpen] = React11__default.useState(false);
1727
- const [newOpen, setNewOpen] = React11__default.useState(false);
1851
+ const railOpen = useRightRailStore((s) => s.open);
1852
+ const [panelOpen, setPanelOpen] = React12__default.useState(false);
1853
+ const [newOpen, setNewOpen] = React12__default.useState(false);
1728
1854
  const selected = conversations.find((c) => c.id === selectedId) ?? null;
1729
- React11__default.useEffect(() => {
1730
- setPanelOpen(isWidePanel);
1731
- }, [isWidePanel]);
1855
+ React12__default.useEffect(() => {
1856
+ setPanelOpen(isWidePanel && !railOpen);
1857
+ }, [isWidePanel, railOpen]);
1858
+ const newActions = React12__default.useMemo(() => [{
1859
+ id: "new-conversation",
1860
+ label: t("conversations.list.new"),
1861
+ icon: SquarePen,
1862
+ intent: "primary",
1863
+ // Starting a conversation creates a thread — the FAB is a "+".
1864
+ creates: true,
1865
+ testId: "conversations-new",
1866
+ permission: { feature: "conversations", action: "create" },
1867
+ onSelect: () => setNewOpen(true)
1868
+ }], [t]);
1732
1869
  return /* @__PURE__ */ jsxs(Fragment, { children: [
1733
- /* @__PURE__ */ jsx(PermissionGate, { feature: "conversations", action: "create", children: /* @__PURE__ */ jsx(PageHeaderActions, { children: /* @__PURE__ */ jsxs(
1734
- Button,
1735
- {
1736
- size: "sm",
1737
- onClick: () => setNewOpen(true),
1738
- "aria-label": t("conversations.list.new"),
1739
- "data-testid": "conversations-new",
1740
- children: [
1741
- /* @__PURE__ */ jsx(SquarePen, { className: "h-3.5 w-3.5 sm:mr-1.5" }),
1742
- /* @__PURE__ */ jsx("span", { className: "hidden sm:inline", children: t("conversations.list.new") })
1743
- ]
1744
- }
1745
- ) }) }),
1870
+ /* @__PURE__ */ jsx(PageHeaderActions, { children: /* @__PURE__ */ jsx(PageActions, { actions: newActions }) }),
1746
1871
  /* @__PURE__ */ jsx(NewConversationPanel, { open: newOpen, onOpenChange: setNewOpen }),
1747
1872
  /* @__PURE__ */ jsxs("div", { className: "relative flex h-full min-h-0 overflow-hidden bg-background", children: [
1748
1873
  /* @__PURE__ */ jsx(ConversationList, { className: cn(selected ? "hidden lg:flex" : "flex") }),
@@ -1799,7 +1924,7 @@ function requestedContact() {
1799
1924
  }
1800
1925
  }
1801
1926
  function ConversationsPage({ store: store2, config }) {
1802
- React11__default.useEffect(() => {
1927
+ React12__default.useEffect(() => {
1803
1928
  const wanted = requestedContact();
1804
1929
  if (!wanted) {
1805
1930
  void store2.getState().load();
@@ -2128,7 +2253,8 @@ function mapMessage(r) {
2128
2253
  // migration 012 genuinely does not know what it was, and the rows that most
2129
2254
  // need saying so are the empty ones that a 'text' default would call fine.
2130
2255
  messageType: r.message_type ?? null,
2131
- payload: r.payload ?? null
2256
+ payload: r.payload ?? null,
2257
+ mediaPath: r.media_path ?? null
2132
2258
  };
2133
2259
  }
2134
2260
  function createSupabaseConversationsProvider(config) {
@@ -2887,22 +3013,22 @@ function NumberRow({ n }) {
2887
3013
  ] });
2888
3014
  }
2889
3015
  function WhatsAppProviders() {
2890
- const [rows, setRows] = React11__default.useState(null);
2891
- const [draft, setDraft] = React11__default.useState({});
2892
- const [busy, setBusy] = React11__default.useState(null);
2893
- const [numbers, setNumbers] = React11__default.useState({});
2894
- const [qr, setQr] = React11__default.useState(null);
2895
- const load = React11__default.useCallback(async () => {
3016
+ const [rows, setRows] = React12__default.useState(null);
3017
+ const [draft, setDraft] = React12__default.useState({});
3018
+ const [busy, setBusy] = React12__default.useState(null);
3019
+ const [numbers, setNumbers] = React12__default.useState({});
3020
+ const [qr, setQr] = React12__default.useState(null);
3021
+ const load = React12__default.useCallback(async () => {
2896
3022
  try {
2897
3023
  setRows(await providersApi.list());
2898
3024
  } catch {
2899
3025
  setRows([]);
2900
3026
  }
2901
3027
  }, []);
2902
- React11__default.useEffect(() => {
3028
+ React12__default.useEffect(() => {
2903
3029
  void load();
2904
3030
  }, [load]);
2905
- const byId = React11__default.useMemo(
3031
+ const byId = React12__default.useMemo(
2906
3032
  () => Object.fromEntries((rows ?? []).map((r) => [r.provider, r])),
2907
3033
  [rows]
2908
3034
  );
@@ -3137,9 +3263,9 @@ function ChannelRow({ account }) {
3137
3263
  }
3138
3264
  function ConversationsGeneralSettings() {
3139
3265
  const t = useTranslation();
3140
- const [channels, setChannels] = React11.useState(null);
3141
- const [failed, setFailed] = React11.useState(false);
3142
- React11.useEffect(() => {
3266
+ const [channels, setChannels] = React12.useState(null);
3267
+ const [failed, setFailed] = React12.useState(false);
3268
+ React12.useEffect(() => {
3143
3269
  let cancelled = false;
3144
3270
  listMessagingChannels().then((rows) => {
3145
3271
  if (!cancelled) setChannels(rows);
@@ -5595,6 +5721,72 @@ COMMENT ON COLUMN public.plg_conversation_messages.message_type IS
5595
5721
  COMMENT ON COLUMN public.plg_conversation_messages.payload IS
5596
5722
  '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).';
5597
5723
  `;
5724
+ var MIGRATION_013_A_MIDIA_FICA_CONOSCO = `-- ---------------------------------------------------------------------------
5725
+ -- 013_a_midia_fica_conosco.sql
5726
+ --
5727
+ -- \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
5728
+ --
5729
+ -- A 012 fez a mensagem que n\xE3o \xE9 texto parar de chegar como bolha vazia:
5730
+ -- \`message_type\` diz que \xE9 imagem, \`payload\` guarda mimetype, tamanho e
5731
+ -- dura\xE7\xE3o. Funcionou \u2014 um \xE1udio de 23 segundos entrou classificado como
5732
+ -- \`audio\`, \`ptt: true\`, \`audio/ogg; codecs=opus\`, 55.825 bytes.
5733
+ --
5734
+ -- E a tela mostra \`[\xE1udio]\`. Porque saber QUE existe um arquivo n\xE3o \xE9 ter o
5735
+ -- arquivo. Nada no banco aponta para bytes nenhuns: o WhatsApp entrega m\xEDdia
5736
+ -- cifrada num CDN pr\xF3prio, e quem decifra \xE9 o provedor.
5737
+ --
5738
+ -- \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
5739
+ --
5740
+ -- A 012 escreveu que \`payload\` \xE9 "a forma da mensagem, que n\xE3o muda depois que
5741
+ -- ela chega". Onde o arquivo foi parar \xE9 decidido DEPOIS \u2014 no upload, que pode
5742
+ -- falhar, ser refeito, ou ainda n\xE3o ter acontecido. Enfiar isso no \`payload\`
5743
+ -- contradiz a \xFAnica frase que diz quando \`payload\` pode ser lido sem medo.
5744
+ --
5745
+ -- Em coluna, a pergunta que importa vira consulta:
5746
+ --
5747
+ -- SELECT count(*) FROM plg_conversation_messages
5748
+ -- WHERE message_type IN ('image','video','audio','document','sticker')
5749
+ -- AND media_path IS NULL;
5750
+ --
5751
+ -- "quanta m\xEDdia eu recebi e n\xE3o guardei" \u2014 que num campo de JSON seria uma
5752
+ -- varredura e aqui \xE9 um \xEDndice.
5753
+ --
5754
+ -- \u2500\u2500 por que guardar, em vez de buscar no provedor quando algu\xE9m abrir \u2500\u2500\u2500
5755
+ --
5756
+ -- A Evolution decifra sob demanda, e seria mais barato n\xE3o copiar nada. Mas ela
5757
+ -- \xE9 local-only, poda o que \xE9 antigo, e a inst\xE2ncia roda numa m\xE1quina que nem
5758
+ -- sempre est\xE1 de p\xE9. Essa era exatamente a obje\xE7\xE3o da 012 contra depender dela
5759
+ -- para ler um vCard: uma linha que precisa de um segundo sistema para ser
5760
+ -- leg\xEDvel n\xE3o \xE9 registro. Vale igual para os bytes.
5761
+ --
5762
+ -- O destino \xE9 o balde \`attachments\` \u2014 privado, 25 MB, j\xE1 existente e j\xE1 em uso.
5763
+ -- Uma foto que um lead mandou no WhatsApp n\xE3o pode ser p\xFAblica, e um balde novo
5764
+ -- seria mais uma lista de mimetypes para algu\xE9m manter desatualizada.
5765
+ --
5766
+ -- \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
5767
+ --
5768
+ -- \`media_path\` \xE9 NULL em tr\xEAs situa\xE7\xF5es diferentes e todas normais: a mensagem
5769
+ -- \xE9 texto; a linha \xE9 anterior a esta migra\xE7\xE3o; ou o upload ainda n\xE3o aconteceu.
5770
+ -- Quem l\xEA distingue pela combina\xE7\xE3o com \`message_type\`, que \xE9 o que torna a
5771
+ -- consulta acima poss\xEDvel. Um default inventaria um caminho para arquivo que
5772
+ -- n\xE3o existe.
5773
+ --
5774
+ -- Idempotente e replay-safe.
5775
+ -- ---------------------------------------------------------------------------
5776
+
5777
+ ALTER TABLE public.plg_conversation_messages
5778
+ ADD COLUMN IF NOT EXISTS media_path text;
5779
+
5780
+ COMMENT ON COLUMN public.plg_conversation_messages.media_path IS
5781
+ '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).';
5782
+
5783
+ -- Parcial: s\xF3 a m\xEDdia sem arquivo interessa, e \xE9 a minoria. O \xEDndice fica do
5784
+ -- tamanho do problema em vez do tamanho da tabela.
5785
+ CREATE INDEX IF NOT EXISTS plg_conversation_messages_midia_sem_arquivo
5786
+ ON public.plg_conversation_messages (tenant_id, at DESC)
5787
+ WHERE media_path IS NULL
5788
+ AND message_type IN ('image', 'video', 'audio', 'document', 'sticker');
5789
+ `;
5598
5790
  var MIGRATIONS = [
5599
5791
  { id: "000_baseline", sql: MIGRATION_000_BASELINE },
5600
5792
  { id: "001_a_caixa_sabe_quem_falou_por_ultimo", sql: MIGRATION_001_A_CAIXA_SABE_QUEM_FALOU_POR_ULTIMO },
@@ -5608,7 +5800,8 @@ var MIGRATIONS = [
5608
5800
  { id: "009_a_mensagem_inteira_atravessa_o_realtime", sql: MIGRATION_009_A_MENSAGEM_INTEIRA_ATRAVESSA_O_REALTIME },
5609
5801
  { id: "010_a_chegada_e_anunciada_por_conta", sql: MIGRATION_010_A_CHEGADA_E_ANUNCIADA_POR_CONTA },
5610
5802
  { id: "011_o_anuncio_e_uma_campainha_nao_um_recado", sql: MIGRATION_011_O_ANUNCIO_E_UMA_CAMPAINHA_NAO_UM_RECADO },
5611
- { id: "012_a_rich_message_keeps_its_shape", sql: MIGRATION_012_A_RICH_MESSAGE_KEEPS_ITS_SHAPE }
5803
+ { id: "012_a_rich_message_keeps_its_shape", sql: MIGRATION_012_A_RICH_MESSAGE_KEEPS_ITS_SHAPE },
5804
+ { id: "013_a_midia_fica_conosco", sql: MIGRATION_013_A_MIDIA_FICA_CONOSCO }
5612
5805
  ];
5613
5806
 
5614
5807
  // src/index.ts
@@ -5642,11 +5835,11 @@ function createConversationsPlugin(options) {
5642
5835
  contactEntityDef: options?.contactEntityDef
5643
5836
  };
5644
5837
  const dashboardWidgets = createConversationsDashboardWidgets({ store: store2, config });
5645
- const PageComponent = () => React11__default.createElement(ConversationsPage, { store: store2, config });
5838
+ const PageComponent = () => React12__default.createElement(ConversationsPage, { store: store2, config });
5646
5839
  PageComponent.displayName = "ConversationsPage";
5647
- const CampaignsPageComponent = () => React11__default.createElement(CampaignsView);
5840
+ const CampaignsPageComponent = () => React12__default.createElement(CampaignsView);
5648
5841
  CampaignsPageComponent.displayName = "ConversationsCampaignsPage";
5649
- const TemplatesPageComponent = () => React11__default.createElement(TemplatesView);
5842
+ const TemplatesPageComponent = () => React12__default.createElement(TemplatesView);
5650
5843
  TemplatesPageComponent.displayName = "ConversationsTemplatesPage";
5651
5844
  return {
5652
5845
  id: "conversations",
@@ -5722,7 +5915,7 @@ function createConversationsPlugin(options) {
5722
5915
  // quando ele serve para alguma coisa.
5723
5916
  zone: "shell.topbar.end",
5724
5917
  component: (() => {
5725
- const W = () => React11__default.createElement(InboxWatcher, {
5918
+ const W = () => React12__default.createElement(InboxWatcher, {
5726
5919
  label: options?.navLabel ?? "Conversations",
5727
5920
  section: options?.navSection ?? "main",
5728
5921
  position: options?.navPosition ?? 1