@sesionario/blocks 1.42.0-rc.2 → 1.42.0-rc.4

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.cjs CHANGED
@@ -7675,12 +7675,225 @@ function MessageScrollerButton({
7675
7675
  }
7676
7676
  );
7677
7677
  }
7678
+
7679
+ // src/lib/support-chat.ts
7680
+ var CHAT_ATTACHMENT_MIME_TYPES = [
7681
+ "application/pdf",
7682
+ "image/jpeg",
7683
+ "image/png",
7684
+ "image/webp"
7685
+ ];
7686
+ var CHAT_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024;
7687
+ var CHAT_ATTACHMENT_MAX_COUNT = 5;
7688
+ var CHAT_ATTACHMENT_ACCEPT = "application/pdf,image/jpeg,image/png,image/webp,.pdf,.jpg,.jpeg,.png,.webp";
7689
+ var EXTENSIONS = {
7690
+ "application/pdf": ["pdf"],
7691
+ "image/jpeg": ["jpg", "jpeg"],
7692
+ "image/png": ["png"],
7693
+ "image/webp": ["webp"]
7694
+ };
7695
+ function isChatAttachmentMime(value) {
7696
+ return CHAT_ATTACHMENT_MIME_TYPES.includes(value);
7697
+ }
7698
+ function isChatImageAttachment(contentType) {
7699
+ return contentType === "image/jpeg" || contentType === "image/png" || contentType === "image/webp";
7700
+ }
7701
+ function filenameMatchesChatMime(filename, mime) {
7702
+ if (!isChatAttachmentMime(mime)) return false;
7703
+ const base = filename.split(/[/\\]/).pop() ?? "";
7704
+ const dot = base.lastIndexOf(".");
7705
+ if (dot <= 0) return false;
7706
+ const ext = base.slice(dot + 1).toLowerCase();
7707
+ return EXTENSIONS[mime].includes(ext);
7708
+ }
7709
+ function validateChatAttachmentFile(file) {
7710
+ if (file.size <= 0 || file.size > CHAT_ATTACHMENT_MAX_BYTES) {
7711
+ return "too_large";
7712
+ }
7713
+ if (!isChatAttachmentMime(file.type) || !filenameMatchesChatMime(file.name, file.type)) {
7714
+ return "bad_type";
7715
+ }
7716
+ return null;
7717
+ }
7718
+ async function parseJson(res) {
7719
+ const body = await res.json();
7720
+ if (!res.ok) {
7721
+ throw new Error(
7722
+ typeof body === "object" && body && "error" in body && body.error ? String(body.error) : `Chat request failed (${res.status})`
7723
+ );
7724
+ }
7725
+ return body;
7726
+ }
7727
+ function createSupportChatClient(basePath = "/api/chat-svc") {
7728
+ const root = basePath.replace(/\/$/, "");
7729
+ const client = {
7730
+ basePath: root,
7731
+ async listConversations() {
7732
+ const res = await fetch(`${root}/me/conversations`);
7733
+ const body = await parseJson(res);
7734
+ return body.result ?? [];
7735
+ },
7736
+ async findOrCreateConversation(input) {
7737
+ const res = await fetch(`${root}/conversations`, {
7738
+ method: "POST",
7739
+ headers: { "content-type": "application/json" },
7740
+ body: JSON.stringify(input)
7741
+ });
7742
+ const body = await parseJson(res);
7743
+ return body.result;
7744
+ },
7745
+ async listMessages(conversationId, opts) {
7746
+ const params = new URLSearchParams();
7747
+ if (opts?.cursor) params.set("cursor", opts.cursor);
7748
+ if (opts?.limit != null) params.set("limit", String(opts.limit));
7749
+ const qs = params.toString();
7750
+ const res = await fetch(
7751
+ `${root}/conversations/${encodeURIComponent(conversationId)}/messages${qs ? `?${qs}` : ""}`
7752
+ );
7753
+ const body = await parseJson(res);
7754
+ return {
7755
+ messages: body.result ?? [],
7756
+ nextCursor: body.nextCursor ?? null
7757
+ };
7758
+ },
7759
+ async sendMessage(conversationId, input) {
7760
+ const body = (input.body ?? "").trim();
7761
+ const attachments = input.attachments ?? [];
7762
+ const payload = {};
7763
+ if (body) payload.body = body;
7764
+ if (attachments.length > 0) payload.attachments = attachments;
7765
+ const res = await fetch(
7766
+ `${root}/conversations/${encodeURIComponent(conversationId)}/messages`,
7767
+ {
7768
+ method: "POST",
7769
+ headers: { "content-type": "application/json" },
7770
+ body: JSON.stringify(payload)
7771
+ }
7772
+ );
7773
+ const parsed = await parseJson(res);
7774
+ return parsed.result;
7775
+ },
7776
+ async createUpload(conversationId, input) {
7777
+ const res = await fetch(
7778
+ `${root}/conversations/${encodeURIComponent(conversationId)}/uploads`,
7779
+ {
7780
+ method: "POST",
7781
+ headers: { "content-type": "application/json" },
7782
+ body: JSON.stringify(input)
7783
+ }
7784
+ );
7785
+ const body = await parseJson(res);
7786
+ return body.result;
7787
+ },
7788
+ async getAttachmentUrls(conversationId, keys) {
7789
+ if (keys.length === 0) return [];
7790
+ const res = await fetch(
7791
+ `${root}/conversations/${encodeURIComponent(conversationId)}/attachment-urls`,
7792
+ {
7793
+ method: "POST",
7794
+ headers: { "content-type": "application/json" },
7795
+ body: JSON.stringify({ keys })
7796
+ }
7797
+ );
7798
+ const body = await parseJson(res);
7799
+ return body.result?.urls ?? [];
7800
+ },
7801
+ async uploadAttachment(conversationId, file) {
7802
+ const validation = validateChatAttachmentFile(file);
7803
+ if (validation === "too_large") {
7804
+ throw new Error("File too large");
7805
+ }
7806
+ if (validation) {
7807
+ throw new Error("File type not allowed");
7808
+ }
7809
+ const upload = await client.createUpload(conversationId, {
7810
+ contentType: file.type,
7811
+ fileName: file.name,
7812
+ sizeBytes: file.size
7813
+ });
7814
+ const put = await fetch(upload.uploadUrl, {
7815
+ method: "PUT",
7816
+ headers: {
7817
+ "content-type": file.type,
7818
+ "content-disposition": upload.contentDisposition
7819
+ },
7820
+ body: file
7821
+ });
7822
+ if (!put.ok) {
7823
+ throw new Error(`upload failed: ${put.status}`);
7824
+ }
7825
+ return {
7826
+ key: upload.key,
7827
+ fileName: file.name,
7828
+ contentType: file.type,
7829
+ sizeBytes: file.size
7830
+ };
7831
+ },
7832
+ async markRead(conversationId) {
7833
+ await fetch(
7834
+ `${root}/conversations/${encodeURIComponent(conversationId)}/read`,
7835
+ { method: "POST" }
7836
+ );
7837
+ }
7838
+ };
7839
+ return client;
7840
+ }
7841
+ function MessageAttachments({
7842
+ attachments
7843
+ }) {
7844
+ if (attachments.length === 0) return null;
7845
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "mt-1.5 flex flex-col gap-1.5", children: attachments.map((att) => {
7846
+ const url = att.url ?? void 0;
7847
+ if (isChatImageAttachment(att.contentType)) {
7848
+ return /* @__PURE__ */ jsxRuntime.jsx(
7849
+ "a",
7850
+ {
7851
+ href: url,
7852
+ target: "_blank",
7853
+ rel: "noopener noreferrer",
7854
+ className: "block overflow-hidden rounded-md",
7855
+ "aria-label": att.fileName,
7856
+ children: /* @__PURE__ */ jsxRuntime.jsx(
7857
+ SafeImage,
7858
+ {
7859
+ src: url,
7860
+ alt: att.fileName,
7861
+ className: "max-h-48 max-w-full rounded-md object-contain",
7862
+ fallback: /* @__PURE__ */ jsxRuntime.jsx("span", { className: "bg-muted text-muted-foreground flex h-24 w-36 items-center justify-center rounded-md text-xs", children: att.fileName })
7863
+ }
7864
+ )
7865
+ },
7866
+ att.key
7867
+ );
7868
+ }
7869
+ return /* @__PURE__ */ jsxRuntime.jsxs(
7870
+ "a",
7871
+ {
7872
+ href: url,
7873
+ download: att.fileName,
7874
+ target: "_blank",
7875
+ rel: "noopener noreferrer",
7876
+ className: "bg-background/60 hover:bg-background/80 inline-flex max-w-full items-center gap-2 rounded-md border px-2.5 py-1.5 text-xs font-medium",
7877
+ children: [
7878
+ /* @__PURE__ */ jsxRuntime.jsx(lucideReact.FileTextIcon, { className: "size-3.5 shrink-0" }),
7879
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "truncate", children: att.fileName })
7880
+ ]
7881
+ },
7882
+ att.key
7883
+ );
7884
+ }) });
7885
+ }
7678
7886
  function ChatMessageList({
7679
7887
  messages,
7680
7888
  currentUserId,
7681
7889
  emptyTitle = "Sin mensajes",
7682
7890
  emptyDescription = "Escribe para iniciar la conversaci\xF3n.",
7683
- compactEmpty = false
7891
+ compactEmpty = false,
7892
+ hasOlder = false,
7893
+ loadingOlder = false,
7894
+ onLoadOlder,
7895
+ loadOlderLabel = "Cargar mensajes anteriores",
7896
+ loadingOlderLabel = "Cargando\u2026"
7684
7897
  }) {
7685
7898
  if (messages.length === 0) {
7686
7899
  return /* @__PURE__ */ jsxRuntime.jsxs(
@@ -7701,35 +7914,52 @@ function ChatMessageList({
7701
7914
  );
7702
7915
  }
7703
7916
  return /* @__PURE__ */ jsxRuntime.jsx(MessageScrollerProvider, { autoScroll: true, children: /* @__PURE__ */ jsxRuntime.jsxs(MessageScroller, { children: [
7704
- /* @__PURE__ */ jsxRuntime.jsx(MessageScrollerViewport, { children: /* @__PURE__ */ jsxRuntime.jsx(MessageScrollerContent, { className: "gap-4 p-4", children: messages.map((m) => {
7705
- const mine = Boolean(currentUserId) && m.senderId === currentUserId;
7706
- const align = mine ? "end" : "start";
7707
- return /* @__PURE__ */ jsxRuntime.jsx(MessageScrollerItem, { scrollAnchor: mine, children: m.deleted ? /* @__PURE__ */ jsxRuntime.jsx(Marker, { variant: "separator", children: /* @__PURE__ */ jsxRuntime.jsxs(MarkerContent, { children: [
7708
- "Mensaje eliminado \xB7 ",
7709
- m.senderName
7710
- ] }) }) : /* @__PURE__ */ jsxRuntime.jsxs(Message, { align, children: [
7711
- !mine ? /* @__PURE__ */ jsxRuntime.jsx(MessageAvatar, { children: /* @__PURE__ */ jsxRuntime.jsx(
7712
- "span",
7713
- {
7714
- className: "flex size-8 items-center justify-center rounded-full text-xs font-medium",
7715
- style: avatarFallbackStyle(m.senderName),
7716
- children: getInitials(m.senderName)
7717
- }
7718
- ) }) : null,
7719
- /* @__PURE__ */ jsxRuntime.jsxs(MessageContent, { children: [
7720
- !mine ? /* @__PURE__ */ jsxRuntime.jsx(MessageHeader, { children: m.senderName }) : null,
7721
- /* @__PURE__ */ jsxRuntime.jsx(
7722
- Bubble,
7917
+ /* @__PURE__ */ jsxRuntime.jsx(MessageScrollerViewport, { children: /* @__PURE__ */ jsxRuntime.jsxs(MessageScrollerContent, { className: "gap-4 p-4", children: [
7918
+ hasOlder && onLoadOlder ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex justify-center pb-2", children: /* @__PURE__ */ jsxRuntime.jsx(
7919
+ Button,
7920
+ {
7921
+ type: "button",
7922
+ variant: "ghost",
7923
+ size: "sm",
7924
+ disabled: loadingOlder,
7925
+ onClick: onLoadOlder,
7926
+ children: loadingOlder ? loadingOlderLabel : loadOlderLabel
7927
+ }
7928
+ ) }) : null,
7929
+ messages.map((m) => {
7930
+ const mine = Boolean(currentUserId) && m.senderId === currentUserId;
7931
+ const align = mine ? "end" : "start";
7932
+ const attachments = m.deleted || !m.attachments ? [] : m.attachments;
7933
+ return /* @__PURE__ */ jsxRuntime.jsx(MessageScrollerItem, { scrollAnchor: mine, children: m.deleted ? /* @__PURE__ */ jsxRuntime.jsx(Marker, { variant: "separator", children: /* @__PURE__ */ jsxRuntime.jsxs(MarkerContent, { children: [
7934
+ "Mensaje eliminado \xB7 ",
7935
+ m.senderName
7936
+ ] }) }) : /* @__PURE__ */ jsxRuntime.jsxs(Message, { align, children: [
7937
+ !mine ? /* @__PURE__ */ jsxRuntime.jsx(MessageAvatar, { children: /* @__PURE__ */ jsxRuntime.jsx(
7938
+ "span",
7723
7939
  {
7724
- variant: mine ? "default" : "muted",
7725
- align,
7726
- children: /* @__PURE__ */ jsxRuntime.jsx(BubbleContent, { children: m.body })
7940
+ className: "flex size-8 items-center justify-center rounded-full text-xs font-medium",
7941
+ style: avatarFallbackStyle(m.senderName),
7942
+ children: getInitials(m.senderName)
7727
7943
  }
7728
- ),
7729
- /* @__PURE__ */ jsxRuntime.jsx(MessageFooter, { children: new Date(m.createdAt).toLocaleString("es-MX") })
7730
- ] })
7731
- ] }) }, m.id);
7732
- }) }) }),
7944
+ ) }) : null,
7945
+ /* @__PURE__ */ jsxRuntime.jsxs(MessageContent, { children: [
7946
+ !mine ? /* @__PURE__ */ jsxRuntime.jsx(MessageHeader, { children: m.senderName }) : null,
7947
+ /* @__PURE__ */ jsxRuntime.jsx(
7948
+ Bubble,
7949
+ {
7950
+ variant: mine ? "default" : "muted",
7951
+ align,
7952
+ children: /* @__PURE__ */ jsxRuntime.jsxs(BubbleContent, { children: [
7953
+ m.body ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "whitespace-pre-wrap", children: m.body }) : null,
7954
+ /* @__PURE__ */ jsxRuntime.jsx(MessageAttachments, { attachments })
7955
+ ] })
7956
+ }
7957
+ ),
7958
+ /* @__PURE__ */ jsxRuntime.jsx(MessageFooter, { children: new Date(m.createdAt).toLocaleString("es-MX") })
7959
+ ] })
7960
+ ] }) }, m.id);
7961
+ })
7962
+ ] }) }),
7733
7963
  /* @__PURE__ */ jsxRuntime.jsx(MessageScrollerButton, {})
7734
7964
  ] }) });
7735
7965
  }
@@ -7864,13 +8094,89 @@ var MessageComposer = React2__namespace.forwardRef(function MessageComposer2({
7864
8094
  );
7865
8095
  });
7866
8096
  MessageComposer.displayName = "MessageComposer";
7867
- var RECONNECT_DELAY_MS = 2e3;
8097
+ var SupportMessengerContext = React2.createContext(null);
8098
+ function useSupportMessenger() {
8099
+ const ctx = React2.useContext(SupportMessengerContext);
8100
+ if (!ctx) {
8101
+ throw new Error(
8102
+ "useSupportMessenger must be used within SupportMessengerProvider"
8103
+ );
8104
+ }
8105
+ return ctx;
8106
+ }
8107
+ function useSupportMessengerOptional() {
8108
+ return React2.useContext(SupportMessengerContext);
8109
+ }
8110
+ function SupportMessengerProvider({
8111
+ children,
8112
+ items,
8113
+ onOpenList,
8114
+ onOpenThread
8115
+ }) {
8116
+ const totalUnread = React2.useMemo(
8117
+ () => items.reduce((sum, item) => sum + (item.unreadCount ?? 0), 0),
8118
+ [items]
8119
+ );
8120
+ const openList = React2.useCallback(() => {
8121
+ onOpenList?.();
8122
+ }, [onOpenList]);
8123
+ const openThread = React2.useCallback(
8124
+ (req) => {
8125
+ onOpenThread?.(req);
8126
+ },
8127
+ [onOpenThread]
8128
+ );
8129
+ const noop = React2.useCallback(() => {
8130
+ }, []);
8131
+ const ctx = React2.useMemo(
8132
+ () => ({
8133
+ listOpen: false,
8134
+ openList,
8135
+ closeList: noop,
8136
+ toggleList: openList,
8137
+ openThread,
8138
+ closeThread: noop,
8139
+ closeAll: noop,
8140
+ activeThreadKey: null,
8141
+ totalUnread
8142
+ }),
8143
+ [openList, openThread, noop, totalUnread]
8144
+ );
8145
+ return /* @__PURE__ */ jsxRuntime.jsx(SupportMessengerContext.Provider, { value: ctx, children });
8146
+ }
8147
+ var RECONNECT_BASE_MS = 1e3;
8148
+ var RECONNECT_MAX_MS = 3e4;
8149
+ function optimisticId() {
8150
+ return `optimistic:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`;
8151
+ }
8152
+ function attachmentKeySignature(attachments) {
8153
+ return (attachments ?? []).map((a) => a.key).filter((k) => !k.startsWith("local:")).join("\0");
8154
+ }
8155
+ function mergeAttachmentUrls(messages, urlByKey) {
8156
+ let changed = false;
8157
+ const next = messages.map((m) => {
8158
+ if (m.deleted || !m.attachments?.length) return m;
8159
+ let attChanged = false;
8160
+ const attachments = m.attachments.map((a) => {
8161
+ if (a.url || a.key.startsWith("local:")) return a;
8162
+ const url = urlByKey.get(a.key);
8163
+ if (!url) return a;
8164
+ attChanged = true;
8165
+ return { ...a, url };
8166
+ });
8167
+ if (!attChanged) return m;
8168
+ changed = true;
8169
+ return { ...m, attachments };
8170
+ });
8171
+ return changed ? next : messages;
8172
+ }
7868
8173
  function useSupportConversation({
7869
8174
  client,
7870
8175
  getWsUrl,
7871
8176
  conversationId: conversationIdProp,
7872
8177
  open = null,
7873
8178
  enabled = true,
8179
+ currentUserId = null,
7874
8180
  onConversationReady,
7875
8181
  onIncomingMessage
7876
8182
  }) {
@@ -7878,20 +8184,29 @@ function useSupportConversation({
7878
8184
  conversationIdProp
7879
8185
  );
7880
8186
  const [messages, setMessages] = React2.useState([]);
8187
+ const [nextCursor, setNextCursor] = React2.useState(null);
7881
8188
  const [loading, setLoading] = React2.useState(false);
8189
+ const [loadingOlder, setLoadingOlder] = React2.useState(false);
7882
8190
  const [sending, setSending] = React2.useState(false);
7883
8191
  const [error, setError] = React2.useState(null);
8192
+ const [sendError, setSendError] = React2.useState(null);
7884
8193
  const socketRef = React2.useRef(null);
7885
8194
  const reconnectRef = React2.useRef(null);
8195
+ const reconnectAttempt = React2.useRef(0);
7886
8196
  const closedByUser = React2.useRef(false);
7887
8197
  const onReadyRef = React2.useRef(onConversationReady);
7888
8198
  const onIncomingRef = React2.useRef(onIncomingMessage);
8199
+ const focusedRef = React2.useRef(enabled);
8200
+ const resolvedKeysRef = React2.useRef(/* @__PURE__ */ new Set());
7889
8201
  onReadyRef.current = onConversationReady;
7890
8202
  onIncomingRef.current = onIncomingMessage;
8203
+ focusedRef.current = enabled;
7891
8204
  const load = React2.useCallback(async () => {
7892
8205
  if (!enabled) return;
7893
8206
  setLoading(true);
7894
8207
  setError(null);
8208
+ setSendError(null);
8209
+ resolvedKeysRef.current = /* @__PURE__ */ new Set();
7895
8210
  try {
7896
8211
  let id = conversationIdProp;
7897
8212
  if (!id && open) {
@@ -7901,12 +8216,14 @@ function useSupportConversation({
7901
8216
  if (!id) {
7902
8217
  setConversationId(null);
7903
8218
  setMessages([]);
8219
+ setNextCursor(null);
7904
8220
  return;
7905
8221
  }
7906
8222
  setConversationId(id);
7907
8223
  onReadyRef.current?.(id);
7908
- const msgs = await client.listMessages(id);
7909
- setMessages(msgs);
8224
+ const page = await client.listMessages(id);
8225
+ setMessages(page.messages);
8226
+ setNextCursor(page.nextCursor);
7910
8227
  await client.markRead(id);
7911
8228
  } catch (err) {
7912
8229
  setError(err instanceof Error ? err.message : "Chat load failed");
@@ -7917,8 +8234,44 @@ function useSupportConversation({
7917
8234
  React2.useEffect(() => {
7918
8235
  void load();
7919
8236
  }, [load]);
8237
+ React2.useEffect(() => {
8238
+ if (!enabled || !conversationId) return;
8239
+ const missing = [];
8240
+ for (const m of messages) {
8241
+ if (m.deleted) continue;
8242
+ for (const a of m.attachments ?? []) {
8243
+ if (a.url || a.key.startsWith("local:")) continue;
8244
+ if (resolvedKeysRef.current.has(a.key)) continue;
8245
+ missing.push(a.key);
8246
+ }
8247
+ }
8248
+ if (missing.length === 0) return;
8249
+ let cancelled = false;
8250
+ const unique = [...new Set(missing)];
8251
+ void client.getAttachmentUrls(conversationId, unique).then((urls) => {
8252
+ if (cancelled) return;
8253
+ const map = /* @__PURE__ */ new Map();
8254
+ for (const row of urls) {
8255
+ map.set(row.key, row.url);
8256
+ resolvedKeysRef.current.add(row.key);
8257
+ }
8258
+ for (const key of unique) {
8259
+ resolvedKeysRef.current.add(key);
8260
+ }
8261
+ setMessages((prev) => mergeAttachmentUrls(prev, map));
8262
+ }).catch(() => {
8263
+ if (cancelled) return;
8264
+ for (const key of unique) {
8265
+ resolvedKeysRef.current.add(key);
8266
+ }
8267
+ });
8268
+ return () => {
8269
+ cancelled = true;
8270
+ };
8271
+ }, [client, conversationId, enabled, messages]);
7920
8272
  React2.useEffect(() => {
7921
8273
  closedByUser.current = false;
8274
+ reconnectAttempt.current = 0;
7922
8275
  if (!enabled || !conversationId) return;
7923
8276
  let cancelled = false;
7924
8277
  const connect = async () => {
@@ -7926,6 +8279,9 @@ function useSupportConversation({
7926
8279
  if (!url || cancelled || closedByUser.current) return;
7927
8280
  const socket = new WebSocket(url);
7928
8281
  socketRef.current = socket;
8282
+ socket.onopen = () => {
8283
+ reconnectAttempt.current = 0;
8284
+ };
7929
8285
  socket.onmessage = (event) => {
7930
8286
  let data;
7931
8287
  try {
@@ -7936,22 +8292,46 @@ function useSupportConversation({
7936
8292
  if (data.type === "message") {
7937
8293
  setMessages((prev) => {
7938
8294
  if (prev.some((m) => m.id === data.id)) return prev;
7939
- return [...prev, data];
8295
+ const dataKeys = attachmentKeySignature(data.attachments);
8296
+ const withoutOptimistic = prev.filter((m) => {
8297
+ if (!m.id.startsWith("optimistic:")) return true;
8298
+ if (m.senderId !== data.senderId || m.body !== data.body) {
8299
+ return true;
8300
+ }
8301
+ const atts = m.attachments ?? [];
8302
+ const dataAtts = data.attachments ?? [];
8303
+ const onlyLocal = atts.every((a) => a.key.startsWith("local:"));
8304
+ const realKeys = attachmentKeySignature(atts);
8305
+ if (realKeys && realKeys === dataKeys) return false;
8306
+ if (atts.length === 0 && dataAtts.length === 0) return false;
8307
+ if (onlyLocal && atts.length === dataAtts.length) return false;
8308
+ return true;
8309
+ });
8310
+ return [...withoutOptimistic, data];
7940
8311
  });
7941
8312
  onIncomingRef.current?.(data);
8313
+ if (focusedRef.current) {
8314
+ void client.markRead(conversationId);
8315
+ }
7942
8316
  } else if (data.type === "deleted") {
7943
8317
  setMessages(
7944
8318
  (prev) => prev.map(
7945
- (m) => m.id === data.id ? { ...m, deleted: true, body: "" } : m
8319
+ (m) => m.id === data.id ? { ...m, deleted: true, body: "", attachments: [] } : m
7946
8320
  )
7947
8321
  );
7948
8322
  }
7949
8323
  };
7950
8324
  socket.onclose = () => {
7951
8325
  if (cancelled || closedByUser.current) return;
8326
+ const attempt = reconnectAttempt.current;
8327
+ reconnectAttempt.current = attempt + 1;
8328
+ const delay = Math.min(
8329
+ RECONNECT_BASE_MS * 2 ** attempt,
8330
+ RECONNECT_MAX_MS
8331
+ );
7952
8332
  reconnectRef.current = setTimeout(() => {
7953
8333
  void connect();
7954
- }, RECONNECT_DELAY_MS);
8334
+ }, delay);
7955
8335
  };
7956
8336
  };
7957
8337
  void connect();
@@ -7962,91 +8342,264 @@ function useSupportConversation({
7962
8342
  socketRef.current?.close();
7963
8343
  socketRef.current = null;
7964
8344
  };
7965
- }, [conversationId, enabled, getWsUrl]);
8345
+ }, [client, conversationId, enabled, getWsUrl]);
8346
+ const loadOlder = React2.useCallback(async () => {
8347
+ if (!conversationId || !nextCursor || loadingOlder) return;
8348
+ setLoadingOlder(true);
8349
+ try {
8350
+ const page = await client.listMessages(conversationId, {
8351
+ cursor: nextCursor
8352
+ });
8353
+ setMessages((prev) => {
8354
+ const seen = new Set(prev.map((m) => m.id));
8355
+ const older = page.messages.filter((m) => !seen.has(m.id));
8356
+ return [...older, ...prev];
8357
+ });
8358
+ setNextCursor(page.nextCursor);
8359
+ } catch {
8360
+ } finally {
8361
+ setLoadingOlder(false);
8362
+ }
8363
+ }, [client, conversationId, loadingOlder, nextCursor]);
7966
8364
  const send = React2.useCallback(
7967
- async (text) => {
8365
+ async (text, files = []) => {
7968
8366
  const trimmed = text.trim();
7969
- if (!conversationId || !trimmed || sending) return;
8367
+ if (!conversationId || sending) return;
8368
+ if (!trimmed && files.length === 0) return;
8369
+ if (files.length > CHAT_ATTACHMENT_MAX_COUNT) {
8370
+ setSendError("Too many attachments");
8371
+ throw new Error("Too many attachments");
8372
+ }
8373
+ for (const file of files) {
8374
+ const issue = validateChatAttachmentFile(file);
8375
+ if (issue === "too_large") {
8376
+ setSendError("File too large");
8377
+ throw new Error("File too large");
8378
+ }
8379
+ if (issue) {
8380
+ setSendError("File type not allowed");
8381
+ throw new Error("File type not allowed");
8382
+ }
8383
+ }
7970
8384
  setSending(true);
7971
- setError(null);
8385
+ setSendError(null);
8386
+ const tempId = optimisticId();
8387
+ const localAttachments = files.map(
8388
+ (file, index) => ({
8389
+ key: `local:${tempId}:${index}`,
8390
+ fileName: file.name,
8391
+ contentType: file.type,
8392
+ sizeBytes: file.size,
8393
+ url: URL.createObjectURL(file)
8394
+ })
8395
+ );
8396
+ const optimistic = {
8397
+ id: tempId,
8398
+ conversationId,
8399
+ senderId: currentUserId ?? "me",
8400
+ senderName: "",
8401
+ senderSide: "member",
8402
+ body: trimmed,
8403
+ attachments: localAttachments,
8404
+ deleted: false,
8405
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
8406
+ };
8407
+ setMessages((prev) => [...prev, optimistic]);
7972
8408
  try {
7973
- const msg = await client.sendMessage(conversationId, trimmed);
8409
+ const uploaded = [];
8410
+ for (const file of files) {
8411
+ uploaded.push(await client.uploadAttachment(conversationId, file));
8412
+ }
8413
+ const msg = await client.sendMessage(conversationId, {
8414
+ body: trimmed || void 0,
8415
+ attachments: uploaded.length > 0 ? uploaded : void 0
8416
+ });
8417
+ for (const att of localAttachments) {
8418
+ if (att.url?.startsWith("blob:")) URL.revokeObjectURL(att.url);
8419
+ }
7974
8420
  setMessages((prev) => {
7975
- if (prev.some((m) => m.id === msg.id)) return prev;
7976
- return [...prev, msg];
8421
+ const withoutTemp = prev.filter((m) => m.id !== tempId);
8422
+ if (withoutTemp.some((m) => m.id === msg.id)) return withoutTemp;
8423
+ return [...withoutTemp, msg];
7977
8424
  });
7978
8425
  await client.markRead(conversationId);
7979
8426
  } catch (err) {
7980
- setError(err instanceof Error ? err.message : "Send failed");
8427
+ for (const att of localAttachments) {
8428
+ if (att.url?.startsWith("blob:")) URL.revokeObjectURL(att.url);
8429
+ }
8430
+ setMessages((prev) => prev.filter((m) => m.id !== tempId));
8431
+ const message = err instanceof Error ? err.message : "Send failed";
8432
+ setSendError(message);
7981
8433
  throw err;
7982
8434
  } finally {
7983
8435
  setSending(false);
7984
8436
  }
7985
8437
  },
7986
- [client, conversationId, sending]
8438
+ [client, conversationId, currentUserId, sending]
7987
8439
  );
7988
8440
  return {
7989
8441
  conversationId,
7990
8442
  messages,
7991
8443
  loading,
8444
+ loadingOlder,
8445
+ hasOlder: Boolean(nextCursor),
7992
8446
  sending,
7993
8447
  error,
8448
+ sendError,
7994
8449
  send,
8450
+ loadOlder,
7995
8451
  reload: load
7996
8452
  };
7997
8453
  }
7998
- var SupportMessengerContext = React2.createContext(null);
7999
- function useSupportMessenger() {
8000
- const ctx = React2.useContext(SupportMessengerContext);
8001
- if (!ctx) {
8002
- throw new Error(
8003
- "useSupportMessenger must be used within SupportMessengerProvider"
8004
- );
8454
+ function formatAttachError(code, labels) {
8455
+ switch (code) {
8456
+ case "too_large":
8457
+ return labels.attachTooLarge ?? "El archivo supera 10 MB";
8458
+ case "bad_type":
8459
+ return labels.attachBadType ?? "Solo PDF, JPEG, PNG o WebP";
8460
+ case "too_many":
8461
+ return labels.attachMaxCount ?? "M\xE1ximo 5 archivos por mensaje";
8462
+ default: {
8463
+ const _exhaustive = code;
8464
+ return _exhaustive;
8465
+ }
8005
8466
  }
8006
- return ctx;
8007
8467
  }
8008
- function useSupportMessengerOptional() {
8009
- return React2.useContext(SupportMessengerContext);
8010
- }
8011
- function ThreadPanel({
8012
- thread,
8468
+ function ThreadPane({
8469
+ item,
8013
8470
  client,
8014
8471
  getWsUrl,
8015
8472
  currentUserId,
8016
8473
  labels,
8017
8474
  emojiLocale,
8018
- onClose,
8019
- onBack,
8020
8475
  showBack,
8476
+ onBack,
8021
8477
  onConversationReady,
8478
+ composerTools,
8479
+ composerAttachments,
8022
8480
  className
8023
8481
  }) {
8024
8482
  const [draft, setDraft] = React2.useState("");
8025
- const { messages, loading, sending, error, send } = useSupportConversation({
8483
+ const [queued, setQueued] = React2.useState([]);
8484
+ const [attachError, setAttachError] = React2.useState(null);
8485
+ const fileInputRef = React2.useRef(null);
8486
+ const {
8487
+ messages,
8488
+ loading,
8489
+ loadingOlder,
8490
+ hasOlder,
8491
+ sending,
8492
+ error,
8493
+ sendError,
8494
+ send,
8495
+ loadOlder
8496
+ } = useSupportConversation({
8026
8497
  client,
8027
8498
  getWsUrl,
8028
- conversationId: thread.conversationId ?? null,
8029
- open: thread.open ?? null,
8499
+ conversationId: item.conversationId ?? null,
8500
+ open: item.open ?? null,
8501
+ currentUserId,
8030
8502
  onConversationReady
8031
8503
  });
8504
+ const queuedRef = React2.useRef(queued);
8505
+ queuedRef.current = queued;
8506
+ React2.useEffect(() => {
8507
+ return () => {
8508
+ for (const q of queuedRef.current) {
8509
+ if (q.previewUrl) URL.revokeObjectURL(q.previewUrl);
8510
+ }
8511
+ };
8512
+ }, []);
8513
+ function onPickFiles(fileList) {
8514
+ if (!fileList || fileList.length === 0) return;
8515
+ setAttachError(null);
8516
+ const additions = [];
8517
+ for (const file of Array.from(fileList)) {
8518
+ if (queued.length + additions.length >= CHAT_ATTACHMENT_MAX_COUNT) {
8519
+ setAttachError(formatAttachError("too_many", labels));
8520
+ break;
8521
+ }
8522
+ const issue = validateChatAttachmentFile(file);
8523
+ if (issue) {
8524
+ setAttachError(formatAttachError(issue, labels));
8525
+ continue;
8526
+ }
8527
+ additions.push({
8528
+ id: `q:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`,
8529
+ file,
8530
+ previewUrl: isChatImageAttachment(file.type) ? URL.createObjectURL(file) : null
8531
+ });
8532
+ }
8533
+ if (additions.length > 0) {
8534
+ setQueued((prev) => [...prev, ...additions]);
8535
+ }
8536
+ if (fileInputRef.current) fileInputRef.current.value = "";
8537
+ }
8538
+ function removeQueued(id) {
8539
+ setQueued((prev) => {
8540
+ const target = prev.find((q) => q.id === id);
8541
+ if (target?.previewUrl) URL.revokeObjectURL(target.previewUrl);
8542
+ return prev.filter((q) => q.id !== id);
8543
+ });
8544
+ setAttachError(null);
8545
+ }
8032
8546
  async function onSubmit(e) {
8033
8547
  e.preventDefault();
8034
8548
  const text = draft.trim();
8035
- if (!text) return;
8549
+ const files = queued.map((q) => q.file);
8550
+ if (!text && files.length === 0) return;
8551
+ const snapshot = queued;
8036
8552
  setDraft("");
8553
+ setQueued([]);
8554
+ setAttachError(null);
8037
8555
  try {
8038
- await send(text);
8556
+ await send(text, files);
8557
+ for (const q of snapshot) {
8558
+ if (q.previewUrl) URL.revokeObjectURL(q.previewUrl);
8559
+ }
8039
8560
  } catch {
8040
8561
  setDraft(text);
8562
+ setQueued(snapshot);
8041
8563
  }
8042
8564
  }
8565
+ const previewStrip = queued.length > 0 || composerAttachments ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "space-y-2", children: [
8566
+ queued.length > 0 ? /* @__PURE__ */ jsxRuntime.jsx("ul", { className: "flex flex-wrap gap-2", children: queued.map((q) => /* @__PURE__ */ jsxRuntime.jsxs(
8567
+ "li",
8568
+ {
8569
+ className: "bg-muted/40 relative flex max-w-[10rem] items-center gap-2 rounded-md border p-1.5",
8570
+ children: [
8571
+ q.previewUrl ? /* @__PURE__ */ jsxRuntime.jsx(
8572
+ SafeImage,
8573
+ {
8574
+ src: q.previewUrl,
8575
+ alt: "",
8576
+ className: "size-10 rounded object-cover",
8577
+ fallback: /* @__PURE__ */ jsxRuntime.jsx("span", { className: "bg-muted flex size-10 items-center justify-center rounded", children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.FileText, { className: "size-4 text-muted-foreground" }) })
8578
+ }
8579
+ ) : /* @__PURE__ */ jsxRuntime.jsx("span", { className: "bg-muted flex size-10 items-center justify-center rounded", children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.FileText, { className: "size-4 text-muted-foreground" }) }),
8580
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "min-w-0 flex-1 truncate text-[11px]", children: q.file.name }),
8581
+ /* @__PURE__ */ jsxRuntime.jsx(
8582
+ Button,
8583
+ {
8584
+ type: "button",
8585
+ variant: "ghost",
8586
+ size: "icon-sm",
8587
+ className: "size-6 shrink-0",
8588
+ "aria-label": labels.removeAttachmentAria ?? "Quitar archivo",
8589
+ onClick: () => removeQueued(q.id),
8590
+ children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.X, { className: "size-3.5" })
8591
+ }
8592
+ )
8593
+ ]
8594
+ },
8595
+ q.id
8596
+ )) }) : null,
8597
+ composerAttachments
8598
+ ] }) : void 0;
8043
8599
  return /* @__PURE__ */ jsxRuntime.jsxs(
8044
8600
  "div",
8045
8601
  {
8046
- className: cn(
8047
- "flex h-full min-h-0 flex-col overflow-hidden rounded-xl border bg-card shadow-lg",
8048
- className
8049
- ),
8602
+ className: cn("flex h-full min-h-0 flex-col overflow-hidden", className),
8050
8603
  children: [
8051
8604
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-2 border-b bg-primary/10 px-2 py-2", children: [
8052
8605
  showBack && onBack ? /* @__PURE__ */ jsxRuntime.jsx(
@@ -8064,72 +8617,98 @@ function ThreadPanel({
8064
8617
  "span",
8065
8618
  {
8066
8619
  className: "flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-full bg-primary text-[11px] font-semibold text-primary-foreground",
8067
- style: thread.avatarUrl ? void 0 : avatarFallbackStyle(thread.title),
8620
+ style: item.avatarUrl ? void 0 : avatarFallbackStyle(item.title),
8068
8621
  children: /* @__PURE__ */ jsxRuntime.jsx(
8069
8622
  SafeImage,
8070
8623
  {
8071
- src: thread.avatarUrl,
8624
+ src: item.avatarUrl,
8072
8625
  alt: "",
8073
8626
  className: "size-full object-cover",
8074
- fallback: getInitials(thread.eyebrow ?? thread.title)
8627
+ fallback: getInitials(item.eyebrow ?? item.title)
8075
8628
  }
8076
8629
  )
8077
8630
  }
8078
8631
  ),
8079
8632
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "min-w-0 flex-1 px-1", children: [
8080
- thread.eyebrow ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "truncate text-[11px] font-semibold tracking-wide text-primary", children: thread.eyebrow }) : null,
8081
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "truncate text-sm font-semibold", children: thread.title }),
8082
- thread.subtitle ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "truncate text-xs text-muted-foreground", children: thread.subtitle }) : null
8083
- ] }),
8084
- /* @__PURE__ */ jsxRuntime.jsx(
8085
- Button,
8086
- {
8087
- type: "button",
8088
- variant: "ghost",
8089
- size: "icon-sm",
8090
- onClick: onClose,
8091
- "aria-label": labels.close,
8092
- children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.X, { className: "size-4" })
8093
- }
8094
- )
8633
+ item.eyebrow ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "truncate text-[11px] font-semibold tracking-wide text-primary", children: item.eyebrow }) : null,
8634
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "truncate text-sm font-semibold", children: item.title }),
8635
+ item.subtitle ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "truncate text-xs text-muted-foreground", children: item.subtitle }) : null
8636
+ ] })
8095
8637
  ] }),
8096
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "min-h-0 flex-1", children: loading ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex h-full items-center justify-center p-6", children: /* @__PURE__ */ jsxRuntime.jsx(LoadingState, { label: labels.loading, className: "py-0" }) }) : error ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "p-4 text-sm text-destructive", role: "alert", children: error }) : /* @__PURE__ */ jsxRuntime.jsx(
8638
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "min-h-0 flex-1", children: loading ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex h-full items-center justify-center p-6", children: /* @__PURE__ */ jsxRuntime.jsx(LoadingState, { label: labels.loading, className: "py-0" }) }) : error && messages.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "p-4 text-sm text-destructive", role: "alert", children: error }) : /* @__PURE__ */ jsxRuntime.jsx(
8097
8639
  ChatMessageList,
8098
8640
  {
8099
8641
  messages,
8100
8642
  currentUserId,
8101
8643
  emptyDescription: labels.emptyThread,
8102
- compactEmpty: true
8644
+ compactEmpty: true,
8645
+ hasOlder,
8646
+ loadingOlder,
8647
+ onLoadOlder: () => void loadOlder(),
8648
+ loadOlderLabel: labels.loadOlder,
8649
+ loadingOlderLabel: labels.loadingOlder
8103
8650
  }
8104
8651
  ) }),
8105
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "border-t p-3", children: /* @__PURE__ */ jsxRuntime.jsx(
8106
- MessageComposer,
8107
- {
8108
- locale: emojiLocale,
8109
- value: draft,
8110
- onChange: (e) => setDraft(e.target.value.slice(0, 2e3)),
8111
- onSubmit,
8112
- sendLabel: labels.send,
8113
- placeholder: labels.placeholder,
8114
- "aria-label": labels.placeholder,
8115
- disabled: loading,
8116
- sendDisabled: loading || sending,
8117
- maxLength: 2e3,
8118
- rows: 2
8119
- }
8120
- ) })
8652
+ attachError || sendError ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "border-t px-3 py-2 text-sm text-destructive", role: "alert", children: attachError ?? sendError }) : null,
8653
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "border-t p-3", children: [
8654
+ /* @__PURE__ */ jsxRuntime.jsx(
8655
+ "input",
8656
+ {
8657
+ ref: fileInputRef,
8658
+ type: "file",
8659
+ className: "sr-only",
8660
+ accept: CHAT_ATTACHMENT_ACCEPT,
8661
+ multiple: true,
8662
+ onChange: (e) => onPickFiles(e.target.files)
8663
+ }
8664
+ ),
8665
+ /* @__PURE__ */ jsxRuntime.jsx(
8666
+ MessageComposer,
8667
+ {
8668
+ locale: emojiLocale,
8669
+ value: draft,
8670
+ onChange: (e) => setDraft(e.target.value.slice(0, 2e3)),
8671
+ onSubmit,
8672
+ sendLabel: labels.send,
8673
+ placeholder: labels.placeholder,
8674
+ "aria-label": labels.placeholder,
8675
+ disabled: loading,
8676
+ sendDisabled: loading || sending,
8677
+ allowEmptySubmit: queued.length > 0,
8678
+ maxLength: 2e3,
8679
+ rows: 2,
8680
+ attachments: previewStrip,
8681
+ tools: /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
8682
+ /* @__PURE__ */ jsxRuntime.jsx(
8683
+ Button,
8684
+ {
8685
+ type: "button",
8686
+ variant: "ghost",
8687
+ size: "icon-sm",
8688
+ className: "size-7 text-muted-foreground",
8689
+ "aria-label": labels.attachAria ?? "Adjuntar archivo",
8690
+ disabled: loading || sending || queued.length >= CHAT_ATTACHMENT_MAX_COUNT,
8691
+ onClick: () => fileInputRef.current?.click(),
8692
+ children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Paperclip, { className: "size-4" })
8693
+ }
8694
+ ),
8695
+ composerTools
8696
+ ] })
8697
+ }
8698
+ )
8699
+ ] })
8121
8700
  ]
8122
8701
  }
8123
8702
  );
8124
8703
  }
8125
- function ListPanel({
8704
+ function ListPane({
8126
8705
  items,
8127
8706
  labels,
8128
8707
  tabs,
8129
8708
  activeTab,
8130
8709
  onTabChange,
8710
+ selectedKey,
8131
8711
  onSelect,
8132
- onClose,
8133
8712
  searchThreshold,
8134
8713
  className
8135
8714
  }) {
@@ -8145,26 +8724,10 @@ function ListPanel({
8145
8724
  return /* @__PURE__ */ jsxRuntime.jsxs(
8146
8725
  "div",
8147
8726
  {
8148
- className: cn(
8149
- "flex h-full min-h-0 flex-col overflow-hidden rounded-xl border bg-card shadow-lg",
8150
- className
8151
- ),
8727
+ className: cn("flex h-full min-h-0 flex-col overflow-hidden", className),
8152
8728
  children: [
8153
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center justify-between border-b bg-primary/10 px-3 py-2", children: [
8154
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm font-semibold", children: labels.listTitle }),
8155
- /* @__PURE__ */ jsxRuntime.jsx(
8156
- Button,
8157
- {
8158
- type: "button",
8159
- variant: "ghost",
8160
- size: "icon-sm",
8161
- onClick: onClose,
8162
- "aria-label": labels.close,
8163
- children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.X, { className: "size-4" })
8164
- }
8165
- )
8166
- ] }),
8167
- tabs && tabs.length > 0 ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex gap-1 border-b p-2", children: tabs.map((tab) => /* @__PURE__ */ jsxRuntime.jsx(
8729
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "border-b bg-primary/10 px-3 py-2", children: /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm font-semibold", children: labels.listTitle }) }),
8730
+ tabs && tabs.length > 0 ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex flex-wrap gap-1 border-b p-2", children: tabs.map((tab) => /* @__PURE__ */ jsxRuntime.jsx(
8168
8731
  Button,
8169
8732
  {
8170
8733
  type: "button",
@@ -8187,353 +8750,126 @@ function ListPanel({
8187
8750
  }
8188
8751
  )
8189
8752
  ] }) }) : null,
8190
- /* @__PURE__ */ jsxRuntime.jsx("ul", { className: "min-h-0 flex-1 overflow-y-auto p-1", children: filtered.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx("li", { className: "px-3 py-4 text-center text-xs text-muted-foreground", children: labels.emptyList }) : filtered.map((item) => /* @__PURE__ */ jsxRuntime.jsx("li", { children: /* @__PURE__ */ jsxRuntime.jsxs(
8191
- "button",
8192
- {
8193
- type: "button",
8194
- className: "flex w-full items-center gap-3 rounded-lg px-2 py-2.5 text-left transition-colors hover:bg-muted/80",
8195
- onClick: () => onSelect(item),
8196
- children: [
8197
- /* @__PURE__ */ jsxRuntime.jsx(
8198
- "span",
8199
- {
8200
- className: "flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-full text-xs font-semibold text-white",
8201
- style: avatarFallbackStyle(item.title),
8202
- children: /* @__PURE__ */ jsxRuntime.jsx(
8203
- SafeImage,
8204
- {
8205
- src: item.avatarUrl,
8206
- alt: "",
8207
- className: "size-full object-cover",
8208
- fallback: getInitials(item.title)
8209
- }
8210
- )
8211
- }
8753
+ /* @__PURE__ */ jsxRuntime.jsx("ul", { className: "min-h-0 flex-1 overflow-y-auto p-1", children: filtered.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx("li", { className: "px-3 py-4 text-center text-xs text-muted-foreground", children: labels.emptyList }) : filtered.map((item) => {
8754
+ const selected = item.key === selectedKey;
8755
+ return /* @__PURE__ */ jsxRuntime.jsx("li", { children: /* @__PURE__ */ jsxRuntime.jsxs(
8756
+ "button",
8757
+ {
8758
+ type: "button",
8759
+ className: cn(
8760
+ "flex w-full items-center gap-3 rounded-lg px-2 py-2.5 text-left transition-colors hover:bg-muted/80",
8761
+ selected && "bg-muted"
8212
8762
  ),
8213
- /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "min-w-0 flex-1", children: [
8214
- item.eyebrow ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "block truncate text-[11px] font-semibold tracking-wide text-primary", children: item.eyebrow }) : null,
8215
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "block truncate text-sm font-medium", children: item.title }),
8216
- item.subtitle ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "block truncate text-xs text-muted-foreground", children: item.subtitle }) : null
8217
- ] }),
8218
- (item.unreadCount ?? 0) > 0 ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "rounded-full bg-primary px-2 py-0.5 text-[10px] font-semibold text-primary-foreground", children: (item.unreadCount ?? 0) > 99 ? "99+" : item.unreadCount }) : null
8219
- ]
8220
- }
8221
- ) }, item.key)) })
8763
+ onClick: () => onSelect(item),
8764
+ "aria-current": selected ? "true" : void 0,
8765
+ children: [
8766
+ /* @__PURE__ */ jsxRuntime.jsx(
8767
+ "span",
8768
+ {
8769
+ className: "flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-full text-xs font-semibold text-white",
8770
+ style: avatarFallbackStyle(item.title),
8771
+ children: /* @__PURE__ */ jsxRuntime.jsx(
8772
+ SafeImage,
8773
+ {
8774
+ src: item.avatarUrl,
8775
+ alt: "",
8776
+ className: "size-full object-cover",
8777
+ fallback: getInitials(item.title)
8778
+ }
8779
+ )
8780
+ }
8781
+ ),
8782
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "min-w-0 flex-1", children: [
8783
+ item.eyebrow ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "block truncate text-[11px] font-semibold tracking-wide text-primary", children: item.eyebrow }) : null,
8784
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "block truncate text-sm font-medium", children: item.title }),
8785
+ item.subtitle ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "block truncate text-xs text-muted-foreground", children: item.subtitle }) : null
8786
+ ] }),
8787
+ (item.unreadCount ?? 0) > 0 ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "rounded-full bg-primary px-2 py-0.5 text-[10px] font-semibold text-primary-foreground", children: (item.unreadCount ?? 0) > 99 ? "99+" : item.unreadCount }) : null
8788
+ ]
8789
+ }
8790
+ ) }, item.key);
8791
+ }) })
8222
8792
  ]
8223
8793
  }
8224
8794
  );
8225
8795
  }
8226
- function SupportMessengerProvider({
8227
- children,
8796
+ function SupportInboxLayout({
8228
8797
  client,
8229
8798
  getWsUrl,
8230
8799
  currentUserId,
8231
8800
  items,
8232
8801
  labels,
8233
- enabled = true,
8234
- forceMinimized = false,
8802
+ selectedKey,
8803
+ onSelect,
8804
+ onBackToList,
8235
8805
  emojiLocale = "es",
8236
8806
  tabs,
8237
8807
  activeTab,
8238
8808
  onTabChange,
8239
8809
  onRefreshItems,
8240
8810
  searchThreshold = 8,
8241
- className
8811
+ className,
8812
+ composerTools,
8813
+ composerAttachments
8242
8814
  }) {
8243
8815
  const isMobile = useMediaQuery("(max-width: 767px)");
8244
- const { height: viewportHeight, keyboardInset } = useVisualViewportMetrics();
8245
- const dockRef = React2.useRef(null);
8246
- const [listOpen, setListOpen] = React2.useState(false);
8247
- const [threads, setThreads] = React2.useState([]);
8248
- const [mobileThreadKey, setMobileThreadKey] = React2.useState(null);
8249
- const mobileSheetOpen = listOpen || Boolean(mobileThreadKey);
8250
- const mobileSheetHeight = viewportHeight > 0 ? Math.max(240, Math.min(viewportHeight * 0.85, viewportHeight)) : void 0;
8251
- const totalUnread = React2.useMemo(
8252
- () => items.reduce((sum, item) => sum + (item.unreadCount ?? 0), 0),
8253
- [items]
8254
- );
8255
- const isDesktopOpen = !isMobile && (listOpen || threads.length > 0);
8256
- React2.useEffect(() => {
8257
- if (!forceMinimized) return;
8258
- setListOpen(false);
8259
- setThreads([]);
8260
- setMobileThreadKey(null);
8261
- }, [forceMinimized]);
8262
- const openList = React2.useCallback(() => {
8263
- setListOpen(true);
8264
- void onRefreshItems?.();
8265
- }, [onRefreshItems]);
8266
- const closeList = React2.useCallback(() => setListOpen(false), []);
8267
- const toggleList = React2.useCallback(() => {
8268
- setListOpen((prev) => {
8269
- const next = !prev;
8270
- if (next) void onRefreshItems?.();
8271
- return next;
8272
- });
8273
- }, [onRefreshItems]);
8274
- const openThread = React2.useCallback((req) => {
8275
- setListOpen(false);
8276
- setThreads((prev) => {
8277
- const existing = prev.find((t) => t.key === req.key);
8278
- if (existing) {
8279
- return [
8280
- ...prev.filter((t) => t.key !== req.key),
8281
- { ...existing, ...req }
8282
- ];
8283
- }
8284
- const next = [...prev, req];
8285
- return next.slice(-2);
8286
- });
8287
- setMobileThreadKey(req.key);
8288
- }, []);
8289
- const closeThread = React2.useCallback((key) => {
8290
- setThreads((prev) => prev.filter((t) => t.key !== key));
8291
- setMobileThreadKey((prev) => prev === key ? null : prev);
8292
- }, []);
8293
- const closeAll = React2.useCallback(() => {
8294
- setListOpen(false);
8295
- setThreads([]);
8296
- setMobileThreadKey(null);
8297
- }, []);
8298
- React2.useEffect(() => {
8299
- if (!isDesktopOpen) return;
8300
- function onPointerDown(event) {
8301
- const target = event.target;
8302
- if (!(target instanceof Node)) return;
8303
- if (dockRef.current?.contains(target)) return;
8304
- if (target instanceof Element && target.closest('[data-slot="popover-content"]')) {
8305
- return;
8306
- }
8307
- closeAll();
8308
- }
8309
- document.addEventListener("pointerdown", onPointerDown);
8310
- return () => document.removeEventListener("pointerdown", onPointerDown);
8311
- }, [isDesktopOpen, closeAll]);
8312
- const ctx = React2.useMemo(
8313
- () => ({
8314
- listOpen,
8315
- openList,
8316
- closeList,
8317
- toggleList,
8318
- openThread,
8319
- closeThread,
8320
- closeAll,
8321
- activeThreadKey: mobileThreadKey ?? threads[threads.length - 1]?.key ?? null,
8322
- totalUnread
8323
- }),
8324
- [
8325
- listOpen,
8326
- openList,
8327
- closeList,
8328
- toggleList,
8329
- openThread,
8330
- closeThread,
8331
- closeAll,
8332
- mobileThreadKey,
8333
- threads,
8334
- totalUnread
8335
- ]
8336
- );
8337
- const handleSelectItem = React2.useCallback(
8338
- (item) => {
8339
- openThread({
8340
- key: item.key,
8341
- title: item.title,
8342
- subtitle: item.subtitle,
8343
- eyebrow: item.eyebrow,
8344
- avatarUrl: item.avatarUrl,
8345
- conversationId: item.conversationId,
8346
- open: item.open
8347
- });
8348
- },
8349
- [openThread]
8816
+ const selected = React2.useMemo(
8817
+ () => items.find((item) => item.key === selectedKey) ?? null,
8818
+ [items, selectedKey]
8350
8819
  );
8351
- const activeMobileThread = threads.find((t) => t.key === mobileThreadKey);
8352
- return /* @__PURE__ */ jsxRuntime.jsxs(SupportMessengerContext.Provider, { value: ctx, children: [
8353
- children,
8354
- enabled ? /* @__PURE__ */ jsxRuntime.jsxs(
8355
- "div",
8356
- {
8357
- ref: dockRef,
8358
- className: cn(
8359
- "pointer-events-none fixed right-4 bottom-4 z-50 flex flex-col items-end gap-3",
8360
- className
8361
- ),
8362
- children: [
8363
- !isMobile ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "pointer-events-auto flex items-end gap-3", children: [
8364
- threads.map((thread) => /* @__PURE__ */ jsxRuntime.jsx("div", { className: "h-[28rem] w-[22rem]", children: /* @__PURE__ */ jsxRuntime.jsx(
8365
- ThreadPanel,
8366
- {
8367
- thread,
8368
- client,
8369
- getWsUrl,
8370
- currentUserId,
8371
- labels,
8372
- emojiLocale,
8373
- onClose: () => closeThread(thread.key),
8374
- onConversationReady: () => void onRefreshItems?.()
8375
- }
8376
- ) }, thread.key)),
8377
- listOpen ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "h-[28rem] w-[20rem]", children: /* @__PURE__ */ jsxRuntime.jsx(
8378
- ListPanel,
8820
+ const showList = !isMobile || !selected;
8821
+ const showThread = Boolean(selected) && (!isMobile || Boolean(selectedKey));
8822
+ return /* @__PURE__ */ jsxRuntime.jsxs(
8823
+ "div",
8824
+ {
8825
+ className: cn(
8826
+ "flex h-full min-h-0 overflow-hidden rounded-xl border bg-card",
8827
+ className
8828
+ ),
8829
+ children: [
8830
+ showList ? /* @__PURE__ */ jsxRuntime.jsx(
8831
+ "div",
8832
+ {
8833
+ className: cn(
8834
+ "min-h-0 min-w-0 border-r",
8835
+ isMobile ? "w-full" : "w-full max-w-sm shrink-0"
8836
+ ),
8837
+ children: /* @__PURE__ */ jsxRuntime.jsx(
8838
+ ListPane,
8379
8839
  {
8380
8840
  items,
8381
8841
  labels,
8382
8842
  tabs,
8383
8843
  activeTab,
8384
8844
  onTabChange,
8385
- onSelect: handleSelectItem,
8386
- onClose: closeList,
8387
- searchThreshold
8845
+ selectedKey,
8846
+ onSelect,
8847
+ searchThreshold,
8848
+ className: "h-full"
8388
8849
  }
8389
- ) }) : null
8390
- ] }) : null,
8391
- isMobile ? /* @__PURE__ */ jsxRuntime.jsx(
8392
- Drawer,
8393
- {
8394
- open: mobileSheetOpen,
8395
- onOpenChange: (open) => {
8396
- if (!open) closeAll();
8397
- },
8398
- children: /* @__PURE__ */ jsxRuntime.jsx(
8399
- DrawerContent,
8400
- {
8401
- className: "pointer-events-auto mx-auto flex max-h-[85dvh] max-w-lg flex-col",
8402
- style: {
8403
- // Lift above the software keyboard without Vaul's
8404
- // repositionInputs focus hack (see drawer.tsx).
8405
- bottom: keyboardInset,
8406
- height: mobileSheetHeight ?? "85dvh",
8407
- maxHeight: viewportHeight > 0 ? viewportHeight : "85dvh"
8408
- },
8409
- children: activeMobileThread ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
8410
- /* @__PURE__ */ jsxRuntime.jsx(DrawerHeader, { className: "sr-only", children: /* @__PURE__ */ jsxRuntime.jsx(DrawerTitle, { children: activeMobileThread.title }) }),
8411
- /* @__PURE__ */ jsxRuntime.jsx(
8412
- ThreadPanel,
8413
- {
8414
- thread: activeMobileThread,
8415
- client,
8416
- getWsUrl,
8417
- currentUserId,
8418
- labels,
8419
- emojiLocale,
8420
- showBack: true,
8421
- onBack: () => {
8422
- closeThread(activeMobileThread.key);
8423
- setListOpen(true);
8424
- },
8425
- onClose: closeAll,
8426
- onConversationReady: () => void onRefreshItems?.(),
8427
- className: "h-full rounded-none border-0 shadow-none"
8428
- }
8429
- )
8430
- ] }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
8431
- /* @__PURE__ */ jsxRuntime.jsx(DrawerHeader, { className: "sr-only", children: /* @__PURE__ */ jsxRuntime.jsx(DrawerTitle, { children: labels.listTitle }) }),
8432
- /* @__PURE__ */ jsxRuntime.jsx(
8433
- ListPanel,
8434
- {
8435
- items,
8436
- labels,
8437
- tabs,
8438
- activeTab,
8439
- onTabChange,
8440
- onSelect: handleSelectItem,
8441
- onClose: closeAll,
8442
- searchThreshold,
8443
- className: "h-full rounded-none border-0 shadow-none"
8444
- }
8445
- )
8446
- ] })
8447
- }
8448
- )
8449
- }
8450
- ) : null,
8451
- /* @__PURE__ */ jsxRuntime.jsxs(
8452
- "button",
8453
- {
8454
- type: "button",
8455
- className: cn(
8456
- "pointer-events-auto relative flex size-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-[transform,opacity] motion-safe:hover:scale-105 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
8457
- listOpen && !forceMinimized && "ring-2 ring-ring ring-offset-2"
8458
- ),
8459
- "aria-label": totalUnread > 0 && labels.unreadBadgeAria ? `${labels.launcherAria}. ${labels.unreadBadgeAria.replace("{count}", String(totalUnread))}` : labels.launcherAria,
8460
- onClick: () => {
8461
- if (isMobile) {
8462
- if (activeMobileThread) {
8463
- closeAll();
8464
- } else {
8465
- toggleList();
8466
- }
8467
- return;
8468
- }
8469
- toggleList();
8470
- },
8471
- children: [
8472
- /* @__PURE__ */ jsxRuntime.jsx(lucideReact.MessageCircle, { className: "size-6" }),
8473
- totalUnread > 0 ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "absolute -top-0.5 -right-0.5 flex size-5 items-center justify-center rounded-full bg-destructive text-[10px] font-bold text-white", children: totalUnread > 99 ? "99+" : totalUnread }) : null
8474
- ]
8475
- }
8476
- )
8477
- ]
8478
- }
8479
- ) : null
8480
- ] });
8481
- }
8482
-
8483
- // src/lib/support-chat.ts
8484
- async function parseJson(res) {
8485
- const body = await res.json();
8486
- if (!res.ok) {
8487
- throw new Error(
8488
- typeof body === "object" && body && "error" in body && body.error ? String(body.error) : `Chat request failed (${res.status})`
8489
- );
8490
- }
8491
- return body;
8492
- }
8493
- function createSupportChatClient(basePath = "/api/chat-svc") {
8494
- const root = basePath.replace(/\/$/, "");
8495
- return {
8496
- basePath: root,
8497
- async listConversations() {
8498
- const res = await fetch(`${root}/me/conversations`);
8499
- const body = await parseJson(res);
8500
- return body.result ?? [];
8501
- },
8502
- async findOrCreateConversation(input) {
8503
- const res = await fetch(`${root}/conversations`, {
8504
- method: "POST",
8505
- headers: { "content-type": "application/json" },
8506
- body: JSON.stringify(input)
8507
- });
8508
- const body = await parseJson(res);
8509
- return body.result;
8510
- },
8511
- async listMessages(conversationId) {
8512
- const res = await fetch(
8513
- `${root}/conversations/${encodeURIComponent(conversationId)}/messages`
8514
- );
8515
- const body = await parseJson(res);
8516
- return body.result ?? [];
8517
- },
8518
- async sendMessage(conversationId, text) {
8519
- const res = await fetch(
8520
- `${root}/conversations/${encodeURIComponent(conversationId)}/messages`,
8521
- {
8522
- method: "POST",
8523
- headers: { "content-type": "application/json" },
8524
- body: JSON.stringify({ body: text })
8525
- }
8526
- );
8527
- const body = await parseJson(res);
8528
- return body.result;
8529
- },
8530
- async markRead(conversationId) {
8531
- await fetch(
8532
- `${root}/conversations/${encodeURIComponent(conversationId)}/read`,
8533
- { method: "POST" }
8534
- );
8850
+ )
8851
+ }
8852
+ ) : null,
8853
+ showThread && selected ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "min-h-0 min-w-0 flex-1", children: /* @__PURE__ */ jsxRuntime.jsx(
8854
+ ThreadPane,
8855
+ {
8856
+ item: selected,
8857
+ client,
8858
+ getWsUrl,
8859
+ currentUserId,
8860
+ labels,
8861
+ emojiLocale,
8862
+ showBack: isMobile,
8863
+ onBack: onBackToList,
8864
+ onConversationReady: () => void onRefreshItems?.(),
8865
+ composerTools,
8866
+ composerAttachments,
8867
+ className: "h-full"
8868
+ }
8869
+ ) }) : !isMobile ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex min-h-0 flex-1 items-center justify-center p-6 text-center text-sm text-muted-foreground", children: labels.selectThread ?? labels.emptyThread }) : null
8870
+ ]
8535
8871
  }
8536
- };
8872
+ );
8537
8873
  }
8538
8874
  var DEFAULT_RECONNECT_MS = 2e3;
8539
8875
  var MAX_RECONNECT_MS = 3e4;
@@ -9394,6 +9730,10 @@ exports.Bubble = Bubble;
9394
9730
  exports.BubbleContent = BubbleContent;
9395
9731
  exports.BubbleGroup = BubbleGroup;
9396
9732
  exports.BubbleReactions = BubbleReactions;
9733
+ exports.CHAT_ATTACHMENT_ACCEPT = CHAT_ATTACHMENT_ACCEPT;
9734
+ exports.CHAT_ATTACHMENT_MAX_BYTES = CHAT_ATTACHMENT_MAX_BYTES;
9735
+ exports.CHAT_ATTACHMENT_MAX_COUNT = CHAT_ATTACHMENT_MAX_COUNT;
9736
+ exports.CHAT_ATTACHMENT_MIME_TYPES = CHAT_ATTACHMENT_MIME_TYPES;
9397
9737
  exports.ChatMessageList = ChatMessageList;
9398
9738
  exports.ConfirmDestructiveDialog = ConfirmDestructiveDialog;
9399
9739
  exports.DEFAULT_EDITABLE_SECTION_LABELS = DEFAULT_EDITABLE_SECTION_LABELS;
@@ -9456,6 +9796,7 @@ exports.SafeImage = SafeImage;
9456
9796
  exports.ScrollRestorationProvider = ScrollRestorationProvider;
9457
9797
  exports.SearchCommand = SearchCommand;
9458
9798
  exports.SearchablePicker = SearchablePicker;
9799
+ exports.SupportInboxLayout = SupportInboxLayout;
9459
9800
  exports.SupportMessengerProvider = SupportMessengerProvider;
9460
9801
  exports.ThemeSwitcher = ThemeSwitcher;
9461
9802
  exports.VerifiedProfessionalBadge = VerifiedProfessionalBadge;
@@ -9469,12 +9810,15 @@ exports.bubbleVariants = bubbleVariants;
9469
9810
  exports.cn = cn;
9470
9811
  exports.createSupportChatClient = createSupportChatClient;
9471
9812
  exports.defaultLanguages = defaultLanguages;
9813
+ exports.filenameMatchesChatMime = filenameMatchesChatMime;
9472
9814
  exports.formatPaymentCardBrand = formatPaymentCardBrand;
9473
9815
  exports.formatRfcInput = formatRfcInput;
9474
9816
  exports.fuzzyMatch = fuzzyMatch;
9475
9817
  exports.getAvatarColors = getAvatarColors;
9476
9818
  exports.getInitials = getInitials;
9477
9819
  exports.insertAtCaret = insertAtCaret;
9820
+ exports.isChatAttachmentMime = isChatAttachmentMime;
9821
+ exports.isChatImageAttachment = isChatImageAttachment;
9478
9822
  exports.isValidPersonaFisicaRfc = isValidPersonaFisicaRfc;
9479
9823
  exports.isValidPersonaMoralRfc = isValidPersonaMoralRfc;
9480
9824
  exports.isValidRfc = isValidRfc;
@@ -9503,6 +9847,7 @@ exports.useSupportMessenger = useSupportMessenger;
9503
9847
  exports.useSupportMessengerOptional = useSupportMessengerOptional;
9504
9848
  exports.useVisualViewportMetrics = useVisualViewportMetrics;
9505
9849
  exports.useZoomPanGestures = useZoomPanGestures;
9850
+ exports.validateChatAttachmentFile = validateChatAttachmentFile;
9506
9851
  exports.zoomToward = zoomToward;
9507
9852
  //# sourceMappingURL=index.cjs.map
9508
9853
  //# sourceMappingURL=index.cjs.map