react-optimistic-chat 1.2.1 → 2.0.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.d.mts CHANGED
@@ -1,5 +1,7 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import React$1 from 'react';
3
+ import * as _tanstack_query_core from '@tanstack/query-core';
4
+ import { InfiniteData } from '@tanstack/react-query';
3
5
 
4
6
  type ChatRole = "AI" | "USER";
5
7
  type BaseMessage = {
@@ -89,28 +91,39 @@ type CommonProps = {
89
91
  disableVoice?: boolean;
90
92
  placeholder?: string;
91
93
  inputClassName?: string;
94
+ fetchNextPage?: () => void;
95
+ hasNextPage?: boolean;
96
+ isFetchingNextPage?: boolean;
92
97
  className?: string;
93
98
  };
94
99
  type Props<T> = CommonProps & (MessageProps | RawProps<T>);
95
100
  declare function ChatContainer<T>(props: Props<T>): react_jsx_runtime.JSX.Element;
96
101
 
97
102
  type ExtraFromRaw$1<TRaw> = Omit<TRaw, keyof BaseMessage>;
103
+ type CustomMessage$1<TCustom> = BaseMessage & {
104
+ custom: TCustom;
105
+ };
106
+ type MessageMapper$1<TRaw> = CustomMessage$1<ExtraFromRaw$1<TRaw>>;
98
107
  type MessageMapperResult$1 = Pick<BaseMessage, "id" | "role" | "content">;
99
- type MessageMapper$1<TRaw> = Message<ExtraFromRaw$1<TRaw>>;
100
108
  type Options$2<TRaw> = {
101
109
  queryKey: readonly unknown[];
102
- queryFn: () => Promise<TRaw[]>;
110
+ queryFn: (pageParam: unknown) => Promise<TRaw[]>;
111
+ initialPageParam: unknown;
112
+ getNextPageParam: (lastPage: MessageMapper$1<TRaw>[], allPages: MessageMapper$1<TRaw>[][]) => unknown;
103
113
  mutationFn: (content: string) => Promise<TRaw>;
104
114
  map: (raw: TRaw) => MessageMapperResult$1;
105
115
  onError?: (error: unknown) => void;
106
116
  staleTime?: number;
107
117
  gcTime?: number;
108
118
  };
109
- declare function useOptimisticChat<TRaw>({ queryKey, queryFn, mutationFn, map, onError, staleTime, gcTime, }: Options$2<TRaw>): {
119
+ declare function useChat<TRaw extends object>({ queryKey, queryFn, initialPageParam, getNextPageParam, mutationFn, map, onError, staleTime, gcTime, }: Options$2<TRaw>): {
110
120
  messages: MessageMapper$1<TRaw>[];
111
121
  sendUserMessage: (content: string) => void;
112
122
  isPending: boolean;
113
123
  isInitialLoading: boolean;
124
+ fetchNextPage: (options?: _tanstack_query_core.FetchNextPageOptions) => Promise<_tanstack_query_core.InfiniteQueryObserverResult<InfiniteData<MessageMapper$1<TRaw>[], unknown>, Error>>;
125
+ hasNextPage: boolean;
126
+ isFetchingNextPage: boolean;
114
127
  };
115
128
 
116
129
  interface SpeechGrammar {
@@ -190,11 +203,16 @@ type VoiceRecognitionController = {
190
203
  onTranscript: (text: string) => void;
191
204
  };
192
205
  type ExtraFromRaw<TRaw> = Omit<TRaw, keyof BaseMessage>;
206
+ type CustomMessage<TCustom> = BaseMessage & {
207
+ custom: TCustom;
208
+ };
209
+ type MessageMapper<TRaw> = CustomMessage<ExtraFromRaw<TRaw>>;
193
210
  type MessageMapperResult = Pick<BaseMessage, "id" | "role" | "content">;
194
- type MessageMapper<TRaw> = Message<ExtraFromRaw<TRaw>>;
195
211
  type Options<TRaw> = {
196
212
  queryKey: readonly unknown[];
197
- queryFn: () => Promise<TRaw[]>;
213
+ queryFn: (pageParam: unknown) => Promise<TRaw[]>;
214
+ initialPageParam: unknown;
215
+ getNextPageParam: (lastPage: MessageMapper<TRaw>[], allPages: MessageMapper<TRaw>[][]) => unknown;
198
216
  mutationFn: (content: string) => Promise<TRaw>;
199
217
  map: (raw: TRaw) => MessageMapperResult;
200
218
  voice: VoiceRecognitionController;
@@ -202,12 +220,15 @@ type Options<TRaw> = {
202
220
  staleTime?: number;
203
221
  gcTime?: number;
204
222
  };
205
- declare function useVoiceOptimisticChat<TRaw>({ queryKey, queryFn, mutationFn, map, voice, onError, staleTime, gcTime, }: Options<TRaw>): {
223
+ declare function useVoiceChat<TRaw extends object>({ queryKey, queryFn, initialPageParam, getNextPageParam, mutationFn, map, voice, onError, staleTime, gcTime, }: Options<TRaw>): {
206
224
  messages: MessageMapper<TRaw>[];
207
225
  isPending: boolean;
208
226
  isInitialLoading: boolean;
209
227
  startRecording: () => Promise<void>;
210
228
  stopRecording: () => void;
229
+ fetchNextPage: (options?: _tanstack_query_core.FetchNextPageOptions) => Promise<_tanstack_query_core.InfiniteQueryObserverResult<InfiniteData<MessageMapper<TRaw>[], unknown>, Error>>;
230
+ hasNextPage: boolean;
231
+ isFetchingNextPage: boolean;
211
232
  };
212
233
 
213
- export { ChatContainer, ChatInput, ChatList, ChatMessage, LoadingSpinner, type Message, SendingDots, useBrowserSpeechRecognition, useOptimisticChat, useVoiceOptimisticChat };
234
+ export { ChatContainer, ChatInput, ChatList, ChatMessage, LoadingSpinner, type Message, SendingDots, useBrowserSpeechRecognition, useChat, useVoiceChat };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import React$1 from 'react';
3
+ import * as _tanstack_query_core from '@tanstack/query-core';
4
+ import { InfiniteData } from '@tanstack/react-query';
3
5
 
4
6
  type ChatRole = "AI" | "USER";
5
7
  type BaseMessage = {
@@ -89,28 +91,39 @@ type CommonProps = {
89
91
  disableVoice?: boolean;
90
92
  placeholder?: string;
91
93
  inputClassName?: string;
94
+ fetchNextPage?: () => void;
95
+ hasNextPage?: boolean;
96
+ isFetchingNextPage?: boolean;
92
97
  className?: string;
93
98
  };
94
99
  type Props<T> = CommonProps & (MessageProps | RawProps<T>);
95
100
  declare function ChatContainer<T>(props: Props<T>): react_jsx_runtime.JSX.Element;
96
101
 
97
102
  type ExtraFromRaw$1<TRaw> = Omit<TRaw, keyof BaseMessage>;
103
+ type CustomMessage$1<TCustom> = BaseMessage & {
104
+ custom: TCustom;
105
+ };
106
+ type MessageMapper$1<TRaw> = CustomMessage$1<ExtraFromRaw$1<TRaw>>;
98
107
  type MessageMapperResult$1 = Pick<BaseMessage, "id" | "role" | "content">;
99
- type MessageMapper$1<TRaw> = Message<ExtraFromRaw$1<TRaw>>;
100
108
  type Options$2<TRaw> = {
101
109
  queryKey: readonly unknown[];
102
- queryFn: () => Promise<TRaw[]>;
110
+ queryFn: (pageParam: unknown) => Promise<TRaw[]>;
111
+ initialPageParam: unknown;
112
+ getNextPageParam: (lastPage: MessageMapper$1<TRaw>[], allPages: MessageMapper$1<TRaw>[][]) => unknown;
103
113
  mutationFn: (content: string) => Promise<TRaw>;
104
114
  map: (raw: TRaw) => MessageMapperResult$1;
105
115
  onError?: (error: unknown) => void;
106
116
  staleTime?: number;
107
117
  gcTime?: number;
108
118
  };
109
- declare function useOptimisticChat<TRaw>({ queryKey, queryFn, mutationFn, map, onError, staleTime, gcTime, }: Options$2<TRaw>): {
119
+ declare function useChat<TRaw extends object>({ queryKey, queryFn, initialPageParam, getNextPageParam, mutationFn, map, onError, staleTime, gcTime, }: Options$2<TRaw>): {
110
120
  messages: MessageMapper$1<TRaw>[];
111
121
  sendUserMessage: (content: string) => void;
112
122
  isPending: boolean;
113
123
  isInitialLoading: boolean;
124
+ fetchNextPage: (options?: _tanstack_query_core.FetchNextPageOptions) => Promise<_tanstack_query_core.InfiniteQueryObserverResult<InfiniteData<MessageMapper$1<TRaw>[], unknown>, Error>>;
125
+ hasNextPage: boolean;
126
+ isFetchingNextPage: boolean;
114
127
  };
115
128
 
116
129
  interface SpeechGrammar {
@@ -190,11 +203,16 @@ type VoiceRecognitionController = {
190
203
  onTranscript: (text: string) => void;
191
204
  };
192
205
  type ExtraFromRaw<TRaw> = Omit<TRaw, keyof BaseMessage>;
206
+ type CustomMessage<TCustom> = BaseMessage & {
207
+ custom: TCustom;
208
+ };
209
+ type MessageMapper<TRaw> = CustomMessage<ExtraFromRaw<TRaw>>;
193
210
  type MessageMapperResult = Pick<BaseMessage, "id" | "role" | "content">;
194
- type MessageMapper<TRaw> = Message<ExtraFromRaw<TRaw>>;
195
211
  type Options<TRaw> = {
196
212
  queryKey: readonly unknown[];
197
- queryFn: () => Promise<TRaw[]>;
213
+ queryFn: (pageParam: unknown) => Promise<TRaw[]>;
214
+ initialPageParam: unknown;
215
+ getNextPageParam: (lastPage: MessageMapper<TRaw>[], allPages: MessageMapper<TRaw>[][]) => unknown;
198
216
  mutationFn: (content: string) => Promise<TRaw>;
199
217
  map: (raw: TRaw) => MessageMapperResult;
200
218
  voice: VoiceRecognitionController;
@@ -202,12 +220,15 @@ type Options<TRaw> = {
202
220
  staleTime?: number;
203
221
  gcTime?: number;
204
222
  };
205
- declare function useVoiceOptimisticChat<TRaw>({ queryKey, queryFn, mutationFn, map, voice, onError, staleTime, gcTime, }: Options<TRaw>): {
223
+ declare function useVoiceChat<TRaw extends object>({ queryKey, queryFn, initialPageParam, getNextPageParam, mutationFn, map, voice, onError, staleTime, gcTime, }: Options<TRaw>): {
206
224
  messages: MessageMapper<TRaw>[];
207
225
  isPending: boolean;
208
226
  isInitialLoading: boolean;
209
227
  startRecording: () => Promise<void>;
210
228
  stopRecording: () => void;
229
+ fetchNextPage: (options?: _tanstack_query_core.FetchNextPageOptions) => Promise<_tanstack_query_core.InfiniteQueryObserverResult<InfiniteData<MessageMapper<TRaw>[], unknown>, Error>>;
230
+ hasNextPage: boolean;
231
+ isFetchingNextPage: boolean;
211
232
  };
212
233
 
213
- export { ChatContainer, ChatInput, ChatList, ChatMessage, LoadingSpinner, type Message, SendingDots, useBrowserSpeechRecognition, useOptimisticChat, useVoiceOptimisticChat };
234
+ export { ChatContainer, ChatInput, ChatList, ChatMessage, LoadingSpinner, type Message, SendingDots, useBrowserSpeechRecognition, useChat, useVoiceChat };
package/dist/index.js CHANGED
@@ -54,8 +54,8 @@ __export(index_exports, {
54
54
  LoadingSpinner: () => LoadingSpinner,
55
55
  SendingDots: () => SendingDots,
56
56
  useBrowserSpeechRecognition: () => useBrowserSpeechRecognition,
57
- useOptimisticChat: () => useOptimisticChat,
58
- useVoiceOptimisticChat: () => useVoiceOptimisticChat
57
+ useChat: () => useChat,
58
+ useVoiceChat: () => useVoiceChat
59
59
  });
60
60
  module.exports = __toCommonJS(index_exports);
61
61
 
@@ -531,20 +531,30 @@ function ChatContainer(props) {
531
531
  disableVoice,
532
532
  placeholder,
533
533
  inputClassName,
534
+ fetchNextPage,
535
+ hasNextPage,
536
+ isFetchingNextPage,
534
537
  className
535
538
  } = props;
536
539
  const mappedMessages = typeof props.messageMapper === "function" ? props.messages.map(props.messageMapper) : messages;
537
540
  (0, import_react5.useEffect)(() => {
538
541
  const el = scrollRef.current;
539
542
  if (!el) return;
540
- el.scrollTop = el.scrollHeight;
541
- const handleScroll = () => {
543
+ const handleScroll = async () => {
542
544
  const isBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 10;
543
545
  setIsAtBottom(isBottom);
546
+ if (el.scrollTop === 0 && hasNextPage && !isFetchingNextPage && fetchNextPage) {
547
+ const prevScrollHeight = el.scrollHeight;
548
+ await fetchNextPage();
549
+ requestAnimationFrame(() => {
550
+ const newScrollHeight = el.scrollHeight;
551
+ el.scrollTop = newScrollHeight - prevScrollHeight;
552
+ });
553
+ }
544
554
  };
545
555
  el.addEventListener("scroll", handleScroll);
546
556
  return () => el.removeEventListener("scroll", handleScroll);
547
- }, []);
557
+ }, [fetchNextPage, hasNextPage, isFetchingNextPage]);
548
558
  (0, import_react5.useEffect)(() => {
549
559
  const el = scrollRef.current;
550
560
  if (!el) return;
@@ -572,17 +582,20 @@ function ChatContainer(props) {
572
582
  flex flex-col ${className || ""}
573
583
  `,
574
584
  children: [
575
- /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
585
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)(
576
586
  "div",
577
587
  {
578
588
  ref: scrollRef,
579
589
  className: `flex-1 overflow-y-auto chatContainer-scroll p-2`,
580
- children: /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
581
- ChatList,
582
- __spreadValues(__spreadValues(__spreadValues({
583
- messages: mappedMessages
584
- }, messageRenderer && { messageRenderer }), loadingRenderer && { loadingRenderer }), listClassName && { className: listClassName })
585
- )
590
+ children: [
591
+ hasNextPage && isFetchingNextPage && /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: "flex justify-center py-2", children: /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(LoadingSpinner, { size: "sm" }) }),
592
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
593
+ ChatList,
594
+ __spreadValues(__spreadValues(__spreadValues({
595
+ messages: mappedMessages
596
+ }, messageRenderer && { messageRenderer }), loadingRenderer && { loadingRenderer }), listClassName && { className: listClassName })
597
+ )
598
+ ]
586
599
  }
587
600
  ),
588
601
  /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "flex-shrink-0 relative", children: [
@@ -625,12 +638,26 @@ function ChatContainer(props) {
625
638
  ) });
626
639
  }
627
640
 
628
- // src/hooks/useOptimisticChat.ts
641
+ // src/hooks/useChat.ts
629
642
  var import_react_query = require("@tanstack/react-query");
630
643
  var import_react6 = require("react");
631
- function useOptimisticChat({
644
+ function splitRawToMessage(raw, mapped) {
645
+ const custom = {};
646
+ const mappedValues = new Set(Object.values(mapped));
647
+ for (const [key, value] of Object.entries(raw)) {
648
+ if (!mappedValues.has(value)) {
649
+ custom[key] = value;
650
+ }
651
+ }
652
+ return __spreadProps(__spreadValues({}, mapped), {
653
+ custom
654
+ });
655
+ }
656
+ function useChat({
632
657
  queryKey,
633
658
  queryFn,
659
+ initialPageParam,
660
+ getNextPageParam,
634
661
  mutationFn,
635
662
  map,
636
663
  onError,
@@ -640,17 +667,26 @@ function useOptimisticChat({
640
667
  const [isPending, setIsPending] = (0, import_react6.useState)(false);
641
668
  const queryClient = (0, import_react_query.useQueryClient)();
642
669
  const {
643
- data: messages = [],
644
- isLoading: isInitialLoading
645
- } = (0, import_react_query.useQuery)({
670
+ data,
671
+ isLoading: isInitialLoading,
672
+ fetchNextPage,
673
+ hasNextPage,
674
+ isFetchingNextPage
675
+ } = (0, import_react_query.useInfiniteQuery)({
646
676
  queryKey,
647
- queryFn: async () => {
648
- const raw = await queryFn();
649
- return raw.map((r) => __spreadValues(__spreadValues({}, map(r)), r));
677
+ initialPageParam,
678
+ queryFn: async ({ pageParam }) => {
679
+ const raw = await queryFn(pageParam);
680
+ return raw.map((r) => {
681
+ const mapped = map(r);
682
+ return splitRawToMessage(r, mapped);
683
+ });
650
684
  },
685
+ getNextPageParam,
651
686
  staleTime,
652
687
  gcTime
653
688
  });
689
+ const messages = data ? [...data.pages].reverse().flat() : [];
654
690
  const mutation = (0, import_react_query.useMutation)({
655
691
  mutationFn,
656
692
  // (content: string) => Promise<TMutationRaw>
@@ -661,38 +697,47 @@ function useOptimisticChat({
661
697
  await queryClient.cancelQueries({ queryKey });
662
698
  }
663
699
  queryClient.setQueryData(queryKey, (old) => {
664
- const base = old != null ? old : [];
665
- return [
666
- ...base,
667
- // user 메시지 추가
700
+ var _a;
701
+ if (!old) return old;
702
+ const pages = [...old.pages];
703
+ const firstPage = (_a = pages[0]) != null ? _a : [];
704
+ pages[0] = [
705
+ ...firstPage,
668
706
  {
669
707
  id: crypto.randomUUID(),
670
708
  role: "USER",
671
- content
709
+ content,
710
+ custom: {}
672
711
  },
673
- // AI placeholder 추가
674
712
  {
675
713
  id: crypto.randomUUID(),
676
714
  role: "AI",
677
715
  content: "",
678
- isLoading: true
716
+ isLoading: true,
717
+ custom: {}
679
718
  }
680
719
  ];
720
+ return __spreadProps(__spreadValues({}, old), {
721
+ pages
722
+ });
681
723
  });
682
724
  return prev ? { prev } : {};
683
725
  },
684
726
  onSuccess: (rawAiResponse) => {
685
- const aiMessage = __spreadValues(__spreadValues({}, map(rawAiResponse)), rawAiResponse);
727
+ const mapped = map(rawAiResponse);
728
+ const aiMessage = splitRawToMessage(rawAiResponse, mapped);
686
729
  queryClient.setQueryData(queryKey, (old) => {
687
- if (!old || old.length === 0) {
688
- return [aiMessage];
689
- }
690
- const next = [...old];
691
- const lastIndex = next.length - 1;
692
- next[lastIndex] = __spreadProps(__spreadValues(__spreadValues({}, next[lastIndex]), aiMessage), {
730
+ if (!old) return old;
731
+ const pages = [...old.pages];
732
+ const firstPage = [...pages[0]];
733
+ const lastIndex = firstPage.length - 1;
734
+ firstPage[lastIndex] = __spreadProps(__spreadValues(__spreadValues({}, firstPage[lastIndex]), aiMessage), {
693
735
  isLoading: false
694
736
  });
695
- return next;
737
+ pages[0] = firstPage;
738
+ return __spreadProps(__spreadValues({}, old), {
739
+ pages
740
+ });
696
741
  });
697
742
  setIsPending(false);
698
743
  },
@@ -715,17 +760,35 @@ function useOptimisticChat({
715
760
  // (content: string) => void
716
761
  isPending,
717
762
  // 사용자가 채팅 전송 후 AI 응답이 올 때까지의 로딩
718
- isInitialLoading
763
+ isInitialLoading,
719
764
  // 초기 로딩 상태
765
+ // infinite query용
766
+ fetchNextPage,
767
+ hasNextPage,
768
+ isFetchingNextPage
720
769
  };
721
770
  }
722
771
 
723
- // src/hooks/useVoiceOptimisticChat.ts
772
+ // src/hooks/useVoiceChat.ts
724
773
  var import_react_query2 = require("@tanstack/react-query");
725
774
  var import_react7 = require("react");
726
- function useVoiceOptimisticChat({
775
+ function splitRawToMessage2(raw, mapped) {
776
+ const custom = {};
777
+ const mappedValues = new Set(Object.values(mapped));
778
+ for (const [key, value] of Object.entries(raw)) {
779
+ if (!mappedValues.has(value)) {
780
+ custom[key] = value;
781
+ }
782
+ }
783
+ return __spreadProps(__spreadValues({}, mapped), {
784
+ custom
785
+ });
786
+ }
787
+ function useVoiceChat({
727
788
  queryKey,
728
789
  queryFn,
790
+ initialPageParam,
791
+ getNextPageParam,
729
792
  mutationFn,
730
793
  map,
731
794
  voice,
@@ -738,17 +801,26 @@ function useVoiceOptimisticChat({
738
801
  const currentTextRef = (0, import_react7.useRef)("");
739
802
  const rollbackRef = (0, import_react7.useRef)(void 0);
740
803
  const {
741
- data: messages = [],
742
- isLoading: isInitialLoading
743
- } = (0, import_react_query2.useQuery)({
804
+ data,
805
+ isLoading: isInitialLoading,
806
+ fetchNextPage,
807
+ hasNextPage,
808
+ isFetchingNextPage
809
+ } = (0, import_react_query2.useInfiniteQuery)({
744
810
  queryKey,
745
- queryFn: async () => {
746
- const raw = await queryFn();
747
- return raw.map((r) => __spreadValues(__spreadValues({}, map(r)), r));
811
+ initialPageParam,
812
+ queryFn: async ({ pageParam }) => {
813
+ const raw = await queryFn(pageParam);
814
+ return raw.map((r) => {
815
+ const mapped = map(r);
816
+ return splitRawToMessage2(r, mapped);
817
+ });
748
818
  },
819
+ getNextPageParam,
749
820
  staleTime,
750
821
  gcTime
751
822
  });
823
+ const messages = data ? data.pages.map((page) => [...page]).reverse().flat() : [];
752
824
  const mutation = (0, import_react_query2.useMutation)({
753
825
  mutationFn,
754
826
  // (content: string) => Promise<TMutationRaw>
@@ -759,32 +831,41 @@ function useVoiceOptimisticChat({
759
831
  await queryClient.cancelQueries({ queryKey });
760
832
  }
761
833
  queryClient.setQueryData(queryKey, (old) => {
762
- const base = old != null ? old : [];
763
- return [
764
- ...base,
765
- // AI placeholder 추가
834
+ var _a;
835
+ if (!old) return old;
836
+ const pages = [...old.pages];
837
+ const firstPage = (_a = pages[0]) != null ? _a : [];
838
+ pages[0] = [
839
+ ...firstPage,
766
840
  {
767
841
  id: crypto.randomUUID(),
768
842
  role: "AI",
769
843
  content: "",
770
- isLoading: true
844
+ isLoading: true,
845
+ custom: {}
771
846
  }
772
847
  ];
848
+ return __spreadProps(__spreadValues({}, old), {
849
+ pages
850
+ });
773
851
  });
774
852
  return prev ? { prev } : {};
775
853
  },
776
854
  onSuccess: (rawAiResponse) => {
777
- const aiMessage = __spreadValues(__spreadValues({}, map(rawAiResponse)), rawAiResponse);
855
+ const mapped = map(rawAiResponse);
856
+ const aiMessage = splitRawToMessage2(rawAiResponse, mapped);
778
857
  queryClient.setQueryData(queryKey, (old) => {
779
- if (!old || old.length === 0) {
780
- return [aiMessage];
781
- }
782
- const next = [...old];
783
- const lastIndex = next.length - 1;
784
- next[lastIndex] = __spreadProps(__spreadValues(__spreadValues({}, next[lastIndex]), aiMessage), {
858
+ if (!old) return old;
859
+ const pages = [...old.pages];
860
+ const firstPage = [...pages[0]];
861
+ const lastIndex = firstPage.length - 1;
862
+ firstPage[lastIndex] = __spreadProps(__spreadValues(__spreadValues({}, firstPage[lastIndex]), aiMessage), {
785
863
  isLoading: false
786
864
  });
787
- return next;
865
+ pages[0] = firstPage;
866
+ return __spreadProps(__spreadValues({}, old), {
867
+ pages
868
+ });
788
869
  });
789
870
  setIsPending(false);
790
871
  },
@@ -803,14 +884,24 @@ function useVoiceOptimisticChat({
803
884
  if (prev) {
804
885
  await queryClient.cancelQueries({ queryKey });
805
886
  }
806
- queryClient.setQueryData(queryKey, (old) => [
807
- ...old != null ? old : [],
808
- {
809
- id: crypto.randomUUID(),
810
- role: "USER",
811
- content: ""
812
- }
813
- ]);
887
+ queryClient.setQueryData(queryKey, (old) => {
888
+ var _a;
889
+ if (!old) return old;
890
+ const pages = [...old.pages];
891
+ const firstPage = (_a = pages[0]) != null ? _a : [];
892
+ pages[0] = [
893
+ ...firstPage,
894
+ {
895
+ id: crypto.randomUUID(),
896
+ role: "USER",
897
+ content: "",
898
+ custom: {}
899
+ }
900
+ ];
901
+ return __spreadProps(__spreadValues({}, old), {
902
+ pages
903
+ });
904
+ });
814
905
  voice.start();
815
906
  };
816
907
  const onTranscript = (text) => {
@@ -818,13 +909,17 @@ function useVoiceOptimisticChat({
818
909
  queryClient.setQueryData(queryKey, (old) => {
819
910
  var _a;
820
911
  if (!old) return old;
821
- const next = [...old];
822
- const last = next.length - 1;
823
- if (((_a = next[last]) == null ? void 0 : _a.role) !== "USER") return old;
824
- next[last] = __spreadProps(__spreadValues({}, next[last]), {
912
+ const pages = [...old.pages];
913
+ const firstPage = [...pages[0]];
914
+ const lastIndex = firstPage.length - 1;
915
+ if (((_a = firstPage[lastIndex]) == null ? void 0 : _a.role) !== "USER") return old;
916
+ firstPage[lastIndex] = __spreadProps(__spreadValues({}, firstPage[lastIndex]), {
825
917
  content: text
826
918
  });
827
- return next;
919
+ pages[0] = firstPage;
920
+ return __spreadProps(__spreadValues({}, old), {
921
+ pages
922
+ });
828
923
  });
829
924
  };
830
925
  (0, import_react7.useEffect)(() => {
@@ -850,8 +945,12 @@ function useVoiceOptimisticChat({
850
945
  // 초기 로딩 상태
851
946
  startRecording,
852
947
  // 음성 인식 시작 함수
853
- stopRecording
948
+ stopRecording,
854
949
  // 음성 인식 종료 함수
950
+ // infinite query용
951
+ fetchNextPage,
952
+ hasNextPage,
953
+ isFetchingNextPage
855
954
  };
856
955
  }
857
956
  // Annotate the CommonJS export names for ESM import in node:
@@ -863,6 +962,6 @@ function useVoiceOptimisticChat({
863
962
  LoadingSpinner,
864
963
  SendingDots,
865
964
  useBrowserSpeechRecognition,
866
- useOptimisticChat,
867
- useVoiceOptimisticChat
965
+ useChat,
966
+ useVoiceChat
868
967
  });
package/dist/index.mjs CHANGED
@@ -490,20 +490,30 @@ function ChatContainer(props) {
490
490
  disableVoice,
491
491
  placeholder,
492
492
  inputClassName,
493
+ fetchNextPage,
494
+ hasNextPage,
495
+ isFetchingNextPage,
493
496
  className
494
497
  } = props;
495
498
  const mappedMessages = typeof props.messageMapper === "function" ? props.messages.map(props.messageMapper) : messages;
496
499
  useEffect3(() => {
497
500
  const el = scrollRef.current;
498
501
  if (!el) return;
499
- el.scrollTop = el.scrollHeight;
500
- const handleScroll = () => {
502
+ const handleScroll = async () => {
501
503
  const isBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 10;
502
504
  setIsAtBottom(isBottom);
505
+ if (el.scrollTop === 0 && hasNextPage && !isFetchingNextPage && fetchNextPage) {
506
+ const prevScrollHeight = el.scrollHeight;
507
+ await fetchNextPage();
508
+ requestAnimationFrame(() => {
509
+ const newScrollHeight = el.scrollHeight;
510
+ el.scrollTop = newScrollHeight - prevScrollHeight;
511
+ });
512
+ }
503
513
  };
504
514
  el.addEventListener("scroll", handleScroll);
505
515
  return () => el.removeEventListener("scroll", handleScroll);
506
- }, []);
516
+ }, [fetchNextPage, hasNextPage, isFetchingNextPage]);
507
517
  useEffect3(() => {
508
518
  const el = scrollRef.current;
509
519
  if (!el) return;
@@ -531,17 +541,20 @@ function ChatContainer(props) {
531
541
  flex flex-col ${className || ""}
532
542
  `,
533
543
  children: [
534
- /* @__PURE__ */ jsx6(
544
+ /* @__PURE__ */ jsxs4(
535
545
  "div",
536
546
  {
537
547
  ref: scrollRef,
538
548
  className: `flex-1 overflow-y-auto chatContainer-scroll p-2`,
539
- children: /* @__PURE__ */ jsx6(
540
- ChatList,
541
- __spreadValues(__spreadValues(__spreadValues({
542
- messages: mappedMessages
543
- }, messageRenderer && { messageRenderer }), loadingRenderer && { loadingRenderer }), listClassName && { className: listClassName })
544
- )
549
+ children: [
550
+ hasNextPage && isFetchingNextPage && /* @__PURE__ */ jsx6("div", { className: "flex justify-center py-2", children: /* @__PURE__ */ jsx6(LoadingSpinner, { size: "sm" }) }),
551
+ /* @__PURE__ */ jsx6(
552
+ ChatList,
553
+ __spreadValues(__spreadValues(__spreadValues({
554
+ messages: mappedMessages
555
+ }, messageRenderer && { messageRenderer }), loadingRenderer && { loadingRenderer }), listClassName && { className: listClassName })
556
+ )
557
+ ]
545
558
  }
546
559
  ),
547
560
  /* @__PURE__ */ jsxs4("div", { className: "flex-shrink-0 relative", children: [
@@ -584,12 +597,26 @@ function ChatContainer(props) {
584
597
  ) });
585
598
  }
586
599
 
587
- // src/hooks/useOptimisticChat.ts
588
- import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
600
+ // src/hooks/useChat.ts
601
+ import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
589
602
  import { useState as useState4 } from "react";
590
- function useOptimisticChat({
603
+ function splitRawToMessage(raw, mapped) {
604
+ const custom = {};
605
+ const mappedValues = new Set(Object.values(mapped));
606
+ for (const [key, value] of Object.entries(raw)) {
607
+ if (!mappedValues.has(value)) {
608
+ custom[key] = value;
609
+ }
610
+ }
611
+ return __spreadProps(__spreadValues({}, mapped), {
612
+ custom
613
+ });
614
+ }
615
+ function useChat({
591
616
  queryKey,
592
617
  queryFn,
618
+ initialPageParam,
619
+ getNextPageParam,
593
620
  mutationFn,
594
621
  map,
595
622
  onError,
@@ -599,17 +626,26 @@ function useOptimisticChat({
599
626
  const [isPending, setIsPending] = useState4(false);
600
627
  const queryClient = useQueryClient();
601
628
  const {
602
- data: messages = [],
603
- isLoading: isInitialLoading
604
- } = useQuery({
629
+ data,
630
+ isLoading: isInitialLoading,
631
+ fetchNextPage,
632
+ hasNextPage,
633
+ isFetchingNextPage
634
+ } = useInfiniteQuery({
605
635
  queryKey,
606
- queryFn: async () => {
607
- const raw = await queryFn();
608
- return raw.map((r) => __spreadValues(__spreadValues({}, map(r)), r));
636
+ initialPageParam,
637
+ queryFn: async ({ pageParam }) => {
638
+ const raw = await queryFn(pageParam);
639
+ return raw.map((r) => {
640
+ const mapped = map(r);
641
+ return splitRawToMessage(r, mapped);
642
+ });
609
643
  },
644
+ getNextPageParam,
610
645
  staleTime,
611
646
  gcTime
612
647
  });
648
+ const messages = data ? [...data.pages].reverse().flat() : [];
613
649
  const mutation = useMutation({
614
650
  mutationFn,
615
651
  // (content: string) => Promise<TMutationRaw>
@@ -620,38 +656,47 @@ function useOptimisticChat({
620
656
  await queryClient.cancelQueries({ queryKey });
621
657
  }
622
658
  queryClient.setQueryData(queryKey, (old) => {
623
- const base = old != null ? old : [];
624
- return [
625
- ...base,
626
- // user 메시지 추가
659
+ var _a;
660
+ if (!old) return old;
661
+ const pages = [...old.pages];
662
+ const firstPage = (_a = pages[0]) != null ? _a : [];
663
+ pages[0] = [
664
+ ...firstPage,
627
665
  {
628
666
  id: crypto.randomUUID(),
629
667
  role: "USER",
630
- content
668
+ content,
669
+ custom: {}
631
670
  },
632
- // AI placeholder 추가
633
671
  {
634
672
  id: crypto.randomUUID(),
635
673
  role: "AI",
636
674
  content: "",
637
- isLoading: true
675
+ isLoading: true,
676
+ custom: {}
638
677
  }
639
678
  ];
679
+ return __spreadProps(__spreadValues({}, old), {
680
+ pages
681
+ });
640
682
  });
641
683
  return prev ? { prev } : {};
642
684
  },
643
685
  onSuccess: (rawAiResponse) => {
644
- const aiMessage = __spreadValues(__spreadValues({}, map(rawAiResponse)), rawAiResponse);
686
+ const mapped = map(rawAiResponse);
687
+ const aiMessage = splitRawToMessage(rawAiResponse, mapped);
645
688
  queryClient.setQueryData(queryKey, (old) => {
646
- if (!old || old.length === 0) {
647
- return [aiMessage];
648
- }
649
- const next = [...old];
650
- const lastIndex = next.length - 1;
651
- next[lastIndex] = __spreadProps(__spreadValues(__spreadValues({}, next[lastIndex]), aiMessage), {
689
+ if (!old) return old;
690
+ const pages = [...old.pages];
691
+ const firstPage = [...pages[0]];
692
+ const lastIndex = firstPage.length - 1;
693
+ firstPage[lastIndex] = __spreadProps(__spreadValues(__spreadValues({}, firstPage[lastIndex]), aiMessage), {
652
694
  isLoading: false
653
695
  });
654
- return next;
696
+ pages[0] = firstPage;
697
+ return __spreadProps(__spreadValues({}, old), {
698
+ pages
699
+ });
655
700
  });
656
701
  setIsPending(false);
657
702
  },
@@ -674,17 +719,35 @@ function useOptimisticChat({
674
719
  // (content: string) => void
675
720
  isPending,
676
721
  // 사용자가 채팅 전송 후 AI 응답이 올 때까지의 로딩
677
- isInitialLoading
722
+ isInitialLoading,
678
723
  // 초기 로딩 상태
724
+ // infinite query용
725
+ fetchNextPage,
726
+ hasNextPage,
727
+ isFetchingNextPage
679
728
  };
680
729
  }
681
730
 
682
- // src/hooks/useVoiceOptimisticChat.ts
683
- import { useMutation as useMutation2, useQuery as useQuery2, useQueryClient as useQueryClient2 } from "@tanstack/react-query";
731
+ // src/hooks/useVoiceChat.ts
732
+ import { useInfiniteQuery as useInfiniteQuery2, useMutation as useMutation2, useQueryClient as useQueryClient2 } from "@tanstack/react-query";
684
733
  import { useEffect as useEffect4, useRef as useRef4, useState as useState5 } from "react";
685
- function useVoiceOptimisticChat({
734
+ function splitRawToMessage2(raw, mapped) {
735
+ const custom = {};
736
+ const mappedValues = new Set(Object.values(mapped));
737
+ for (const [key, value] of Object.entries(raw)) {
738
+ if (!mappedValues.has(value)) {
739
+ custom[key] = value;
740
+ }
741
+ }
742
+ return __spreadProps(__spreadValues({}, mapped), {
743
+ custom
744
+ });
745
+ }
746
+ function useVoiceChat({
686
747
  queryKey,
687
748
  queryFn,
749
+ initialPageParam,
750
+ getNextPageParam,
688
751
  mutationFn,
689
752
  map,
690
753
  voice,
@@ -697,17 +760,26 @@ function useVoiceOptimisticChat({
697
760
  const currentTextRef = useRef4("");
698
761
  const rollbackRef = useRef4(void 0);
699
762
  const {
700
- data: messages = [],
701
- isLoading: isInitialLoading
702
- } = useQuery2({
763
+ data,
764
+ isLoading: isInitialLoading,
765
+ fetchNextPage,
766
+ hasNextPage,
767
+ isFetchingNextPage
768
+ } = useInfiniteQuery2({
703
769
  queryKey,
704
- queryFn: async () => {
705
- const raw = await queryFn();
706
- return raw.map((r) => __spreadValues(__spreadValues({}, map(r)), r));
770
+ initialPageParam,
771
+ queryFn: async ({ pageParam }) => {
772
+ const raw = await queryFn(pageParam);
773
+ return raw.map((r) => {
774
+ const mapped = map(r);
775
+ return splitRawToMessage2(r, mapped);
776
+ });
707
777
  },
778
+ getNextPageParam,
708
779
  staleTime,
709
780
  gcTime
710
781
  });
782
+ const messages = data ? data.pages.map((page) => [...page]).reverse().flat() : [];
711
783
  const mutation = useMutation2({
712
784
  mutationFn,
713
785
  // (content: string) => Promise<TMutationRaw>
@@ -718,32 +790,41 @@ function useVoiceOptimisticChat({
718
790
  await queryClient.cancelQueries({ queryKey });
719
791
  }
720
792
  queryClient.setQueryData(queryKey, (old) => {
721
- const base = old != null ? old : [];
722
- return [
723
- ...base,
724
- // AI placeholder 추가
793
+ var _a;
794
+ if (!old) return old;
795
+ const pages = [...old.pages];
796
+ const firstPage = (_a = pages[0]) != null ? _a : [];
797
+ pages[0] = [
798
+ ...firstPage,
725
799
  {
726
800
  id: crypto.randomUUID(),
727
801
  role: "AI",
728
802
  content: "",
729
- isLoading: true
803
+ isLoading: true,
804
+ custom: {}
730
805
  }
731
806
  ];
807
+ return __spreadProps(__spreadValues({}, old), {
808
+ pages
809
+ });
732
810
  });
733
811
  return prev ? { prev } : {};
734
812
  },
735
813
  onSuccess: (rawAiResponse) => {
736
- const aiMessage = __spreadValues(__spreadValues({}, map(rawAiResponse)), rawAiResponse);
814
+ const mapped = map(rawAiResponse);
815
+ const aiMessage = splitRawToMessage2(rawAiResponse, mapped);
737
816
  queryClient.setQueryData(queryKey, (old) => {
738
- if (!old || old.length === 0) {
739
- return [aiMessage];
740
- }
741
- const next = [...old];
742
- const lastIndex = next.length - 1;
743
- next[lastIndex] = __spreadProps(__spreadValues(__spreadValues({}, next[lastIndex]), aiMessage), {
817
+ if (!old) return old;
818
+ const pages = [...old.pages];
819
+ const firstPage = [...pages[0]];
820
+ const lastIndex = firstPage.length - 1;
821
+ firstPage[lastIndex] = __spreadProps(__spreadValues(__spreadValues({}, firstPage[lastIndex]), aiMessage), {
744
822
  isLoading: false
745
823
  });
746
- return next;
824
+ pages[0] = firstPage;
825
+ return __spreadProps(__spreadValues({}, old), {
826
+ pages
827
+ });
747
828
  });
748
829
  setIsPending(false);
749
830
  },
@@ -762,14 +843,24 @@ function useVoiceOptimisticChat({
762
843
  if (prev) {
763
844
  await queryClient.cancelQueries({ queryKey });
764
845
  }
765
- queryClient.setQueryData(queryKey, (old) => [
766
- ...old != null ? old : [],
767
- {
768
- id: crypto.randomUUID(),
769
- role: "USER",
770
- content: ""
771
- }
772
- ]);
846
+ queryClient.setQueryData(queryKey, (old) => {
847
+ var _a;
848
+ if (!old) return old;
849
+ const pages = [...old.pages];
850
+ const firstPage = (_a = pages[0]) != null ? _a : [];
851
+ pages[0] = [
852
+ ...firstPage,
853
+ {
854
+ id: crypto.randomUUID(),
855
+ role: "USER",
856
+ content: "",
857
+ custom: {}
858
+ }
859
+ ];
860
+ return __spreadProps(__spreadValues({}, old), {
861
+ pages
862
+ });
863
+ });
773
864
  voice.start();
774
865
  };
775
866
  const onTranscript = (text) => {
@@ -777,13 +868,17 @@ function useVoiceOptimisticChat({
777
868
  queryClient.setQueryData(queryKey, (old) => {
778
869
  var _a;
779
870
  if (!old) return old;
780
- const next = [...old];
781
- const last = next.length - 1;
782
- if (((_a = next[last]) == null ? void 0 : _a.role) !== "USER") return old;
783
- next[last] = __spreadProps(__spreadValues({}, next[last]), {
871
+ const pages = [...old.pages];
872
+ const firstPage = [...pages[0]];
873
+ const lastIndex = firstPage.length - 1;
874
+ if (((_a = firstPage[lastIndex]) == null ? void 0 : _a.role) !== "USER") return old;
875
+ firstPage[lastIndex] = __spreadProps(__spreadValues({}, firstPage[lastIndex]), {
784
876
  content: text
785
877
  });
786
- return next;
878
+ pages[0] = firstPage;
879
+ return __spreadProps(__spreadValues({}, old), {
880
+ pages
881
+ });
787
882
  });
788
883
  };
789
884
  useEffect4(() => {
@@ -809,8 +904,12 @@ function useVoiceOptimisticChat({
809
904
  // 초기 로딩 상태
810
905
  startRecording,
811
906
  // 음성 인식 시작 함수
812
- stopRecording
907
+ stopRecording,
813
908
  // 음성 인식 종료 함수
909
+ // infinite query용
910
+ fetchNextPage,
911
+ hasNextPage,
912
+ isFetchingNextPage
814
913
  };
815
914
  }
816
915
  export {
@@ -821,6 +920,6 @@ export {
821
920
  LoadingSpinner,
822
921
  SendingDots,
823
922
  useBrowserSpeechRecognition,
824
- useOptimisticChat,
825
- useVoiceOptimisticChat
923
+ useChat,
924
+ useVoiceChat
826
925
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-optimistic-chat",
3
- "version": "1.2.1",
3
+ "version": "2.0.0",
4
4
  "main": "./dist/index.js",
5
5
  "module": "./dist/index.mjs",
6
6
  "types": "./dist/index.d.ts",