@inploi/plugin-chatbot 1.0.7 → 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.
@@ -1,39 +1,33 @@
1
- import { Ref } from 'preact';
1
+ import { AnimatePresence, m } from 'framer-motion';
2
2
  import type { FieldError } from 'react-hook-form';
3
- import { Transition } from 'react-transition-group';
4
-
5
- import { TransitionState } from './transition';
6
3
 
7
4
  export const InputError = ({ error }: { error?: FieldError }) => {
8
5
  return (
9
- <Transition in={Boolean(error)} timeout={15}>
10
- {(state: TransitionState) => {
11
- if (!error) return null;
12
-
13
- return (
14
- <div
15
- ref={error.ref as Ref<HTMLDivElement> | undefined}
16
- data-transition={state}
17
- role="alert"
18
- class="transition-all opacity-0 ease-expo-out duration-300 translate-y-1/2 data-[transition=entered]:opacity-100 data-[transition=entered]:translate-y-0 p-0.5 px-1 rounded-full overflow-hidden flex items-center max-w-full gap-1 text-error-11"
6
+ <AnimatePresence>
7
+ {error && (
8
+ <m.div
9
+ initial={{ scale: 0.5, opacity: 0 }}
10
+ animate={{ scale: 1, opacity: 1 }}
11
+ exit={{ scale: 0, opacity: 0 }}
12
+ role="alert"
13
+ class="opacity-0 p-0.5 px-1 rounded-full overflow-hidden flex items-center max-w-full gap-1 text-error-11"
14
+ >
15
+ <svg
16
+ class="text-error-10"
17
+ width="16"
18
+ height="16"
19
+ viewBox="0 0 16 16"
20
+ fill="none"
21
+ xmlns="http://www.w3.org/2000/svg"
19
22
  >
20
- <svg
21
- class="text-error-10"
22
- width="16"
23
- height="16"
24
- viewBox="0 0 16 16"
25
- fill="none"
26
- xmlns="http://www.w3.org/2000/svg"
27
- >
28
- <circle cx="8" cy="8" r="6.3" stroke="currentColor" stroke-width="1.4" />
29
- <rect x="7" y="4" width="2" height="5" fill="currentColor" />
30
- <rect x="7" y="10" width="2" height="2" fill="currentColor" />
31
- </svg>
23
+ <circle cx="8" cy="8" r="6.3" stroke="currentColor" stroke-width="1.4" />
24
+ <rect x="7" y="4" width="2" height="5" fill="currentColor" />
25
+ <rect x="7" y="10" width="2" height="2" fill="currentColor" />
26
+ </svg>
32
27
 
33
- <p class="text-sm truncate pr-1">{error.message}</p>
34
- </div>
35
- );
36
- }}
37
- </Transition>
28
+ <p class="text-sm truncate pr-1">{error.message}</p>
29
+ </m.div>
30
+ )}
31
+ </AnimatePresence>
38
32
  );
39
33
  };
@@ -1,11 +1,12 @@
1
- import { ApiClient, Logger } from '@inploi/sdk';
2
- import { useLayoutEffect, useRef } from 'react';
1
+ import { AnalyticsService, ApiClient, Logger } from '@inploi/sdk';
2
+ import { useFocusGuards } from '@radix-ui/react-focus-guards';
3
+ import { AnimatePresence } from 'framer-motion';
4
+ import { useEffect, useLayoutEffect } from 'react';
3
5
  import { match } from 'ts-pattern';
4
- import { JobApplication } from '~/chatbot.api';
6
+ import { StartedJobApplication, application, inputHeight, viewState } from '~/chatbot.state';
5
7
  import { submissionsToPayload } from '~/chatbot.utils';
6
8
 
7
9
  import { ERROR_MESSAGES } from '../chatbot.constants';
8
- import { useApplicationLocalState, useLocalState } from '../chatbot.state';
9
10
  import { createFlowInterpreter } from '../interpreter/interpreter';
10
11
  import { ChatInput } from './chat-input/chat-input';
11
12
  import { JobApplicationMessages } from './job-application-messages';
@@ -13,31 +14,55 @@ import { useChatService } from './useChatService';
13
14
 
14
15
  type JobApplicationContentProps = {
15
16
  apiClient: ApiClient;
16
- logger?: Logger;
17
- application: JobApplication;
17
+ logger: Logger;
18
+ currentApplication: StartedJobApplication;
19
+ analytics: AnalyticsService;
18
20
  };
19
21
 
20
- export const JobApplicationContent = ({ application, logger, apiClient }: JobApplicationContentProps) => {
21
- const currentApplicationLocalState = useApplicationLocalState(application);
22
- const chatRef = useRef<HTMLDivElement>(null);
23
- const { chatService, isBotTyping, onSubmitSuccessFn, scrollToEnd } = useChatService({ application, chatRef, logger });
22
+ export const JobApplicationContent = ({
23
+ currentApplication,
24
+ logger,
25
+ apiClient,
26
+ analytics,
27
+ }: JobApplicationContentProps) => {
28
+ const { chatRef, chatService, isBotTyping, onSubmitSuccessFn, scrollToEnd } = useChatService({
29
+ logger,
30
+ });
31
+
32
+ useFocusGuards();
33
+
34
+ const view = viewState.value;
35
+ useLayoutEffect(() => {
36
+ // This significantly improves performance for maximising the view
37
+ if (view === 'maximised') scrollToEnd({ behavior: 'instant' });
38
+ }, [scrollToEnd, view]);
39
+
40
+ useEffect(() => {
41
+ scrollToEnd({ behavior: 'smooth' });
42
+ }, [currentApplication.data.messages, scrollToEnd]);
24
43
 
25
44
  useLayoutEffect(() => {
26
- const { getApplicationLocalState, setInput, updateApplicationLocalState, updateApplicationCurrentNode } =
27
- useLocalState.getState();
28
- const applicationLocalState = getApplicationLocalState(application);
29
45
  scrollToEnd({ behavior: 'instant' });
30
- if (applicationLocalState?.isFinished) return;
46
+ const currentApplication = application.current$.peek();
47
+ if (!currentApplication || currentApplication.data.isFinished) return;
31
48
 
32
49
  const { interpret, abort } = createFlowInterpreter({
33
- flow: application.flow.nodes,
50
+ flow: currentApplication.flow.nodes,
34
51
  chatService,
35
- getSubmissions: () => getApplicationLocalState(application)?.submissions,
36
- beforeStart: async node => {
37
- setInput({ application, data: null });
52
+ getSubmissions: () => application.current$.peek()?.data.submissions,
53
+ beforeStart: async () => {
54
+ application.setInput(undefined);
38
55
 
39
- const isResuming = !node.isHead;
40
- if (isResuming) {
56
+ const fromBeginning = currentApplication.data.messages.length === 0;
57
+ if (fromBeginning) {
58
+ analytics.log({
59
+ event: 'APPLY_START',
60
+ properties: { job_id: currentApplication.job.id },
61
+ customProperties: {
62
+ flow_id: currentApplication.flow.id,
63
+ },
64
+ });
65
+ } else {
41
66
  await chatService.send({
42
67
  message: {
43
68
  type: 'system',
@@ -48,10 +73,10 @@ export const JobApplicationContent = ({ application, logger, apiClient }: JobApp
48
73
  }
49
74
  },
50
75
  onInterpret: node => {
51
- updateApplicationCurrentNode({ application, currentNodeId: node.id });
76
+ application.setCurrentNodeId(node.id);
52
77
  },
53
78
  onFlowEnd: async lastNode => {
54
- updateApplicationLocalState({ application, data: { isFinished: true } });
79
+ application.markAsFinished();
55
80
  return match(lastNode)
56
81
  .with({ type: 'abandon-flow' }, () => {
57
82
  chatService.send({
@@ -63,12 +88,12 @@ export const JobApplicationContent = ({ application, logger, apiClient }: JobApp
63
88
  });
64
89
  })
65
90
  .with({ type: 'complete-flow' }, async () => {
66
- const submissions = getApplicationLocalState(application)?.submissions;
91
+ const submissions = application.current$.peek()?.data.submissions;
67
92
  if (!submissions) throw new Error(ERROR_MESSAGES.no_submissions);
68
93
 
69
- const response = await apiClient.fetch(`/flow/job/${application.job.id}`, {
94
+ const response = await apiClient.fetch(`/flow/job/${currentApplication.job.id}`, {
70
95
  method: 'POST',
71
- body: JSON.stringify(submissionsToPayload(submissions)),
96
+ body: JSON.stringify(submissionsToPayload({ application: currentApplication, submissions })),
72
97
  });
73
98
 
74
99
  match(response)
@@ -82,7 +107,7 @@ export const JobApplicationContent = ({ application, logger, apiClient }: JobApp
82
107
  });
83
108
  })
84
109
  .otherwise(response => {
85
- logger?.error(response);
110
+ logger.error(response);
86
111
  chatService.send({
87
112
  message: {
88
113
  type: 'system',
@@ -93,30 +118,28 @@ export const JobApplicationContent = ({ application, logger, apiClient }: JobApp
93
118
  });
94
119
  })
95
120
  .otherwise(() => {
96
- logger?.error(ERROR_MESSAGES.invalid_end_node, lastNode);
121
+ logger.error(ERROR_MESSAGES.invalid_end_node, lastNode);
97
122
  });
98
123
  },
99
124
  });
100
125
 
101
- interpret(applicationLocalState?.currentNodeId);
126
+ interpret(currentApplication.data.currentNodeId);
102
127
 
103
128
  return abort;
104
- }, [apiClient, application, chatService, logger, scrollToEnd]);
129
+ }, [analytics, apiClient, chatService, logger, scrollToEnd]);
105
130
 
106
131
  return (
107
- <div
108
- ref={chatRef}
109
- class="relative max-w-full flex-grow flex flex-col hide-scrollbars overflow-y-scroll"
110
- style={{ WebkitOverflowScrolling: 'touch' }}
111
- >
112
- <JobApplicationMessages isBotTyping={isBotTyping} messages={currentApplicationLocalState?.messages ?? []} />
113
- <div class="sticky bottom-0 w-full p-2 border-t border-neutral-5 rounded-b-3xl bg-neutral-4/80 backdrop-blur-md backdrop-saturate-150">
114
- <ChatInput
115
- onInputChange={() => scrollToEnd({ behavior: 'smooth' })}
116
- application={application}
117
- onSubmit={onSubmitSuccessFn}
118
- />
132
+ <>
133
+ <div
134
+ ref={chatRef}
135
+ className="relative max-w-full flex-grow flex flex-col hide-scrollbars overflow-y-scroll"
136
+ style={{ WebkitOverflowScrolling: 'touch', paddingBottom: inputHeight.value }}
137
+ >
138
+ <AnimatePresence>
139
+ <JobApplicationMessages isBotTyping={isBotTyping} messages={currentApplication.data.messages} />
140
+ </AnimatePresence>
119
141
  </div>
120
- </div>
142
+ <ChatInput onInputChange={() => scrollToEnd({ behavior: 'smooth' })} onSubmit={onSubmitSuccessFn} />
143
+ </>
121
144
  );
122
145
  };
@@ -1,8 +1,10 @@
1
+ import { AnimatePresence } from 'framer-motion';
1
2
  import { P, match } from 'ts-pattern';
2
3
 
3
4
  import { ChatMessage } from '../chatbot.state';
4
5
  import { ChatBubble } from './chat-bubble';
5
6
  import { FileThumbnail } from './chat-input/chat-input.file';
7
+ // import { AnimatePresence } from './motion/animate-presence';
6
8
  import { TypingIndicator } from './typing-indicator';
7
9
 
8
10
  type JobApplicationMessagesProps = {
@@ -17,40 +19,46 @@ const authorToSide = {
17
19
 
18
20
  export const JobApplicationMessages = ({ messages, isBotTyping }: JobApplicationMessagesProps) => {
19
21
  return (
20
- <div class="p-2 pt-[calc(var(--header-height)+0.5rem)] flex flex-col gap-2 flex-grow">
21
- {messages.map((message, i) =>
22
- match(message)
23
- .with({ type: 'system' }, message => (
24
- <p key={i} class="uppercase text-[10px] text-neutral-10 tracking-widest text-center py-2">
25
- {message.text}
26
- </p>
27
- ))
28
- .with({ type: 'text', author: P.union('bot', 'user') }, message => {
29
- return (
30
- <ChatBubble key={i} side={authorToSide[message.author]}>
31
- {message.text}
32
- </ChatBubble>
33
- );
34
- })
35
- .with({ type: 'image' }, image => (
36
- <img
37
- key={i}
38
- class="max-w-[min(100%,24rem)] w-full rounded-2xl shadow-surface-md"
39
- src={image.url}
40
- style={{ aspectRatio: image.width / image.height }}
41
- />
42
- ))
43
- .with({ type: 'file' }, file => {
44
- return (
45
- <FileThumbnail
46
- class={file.author === 'bot' ? 'self-start' : 'self-end'}
47
- file={{ name: file.fileName, sizeKb: file.fileSizeKb }}
48
- />
49
- );
50
- })
51
- .exhaustive(),
52
- )}
22
+ <ol
23
+ aria-label="Chat messages"
24
+ class="p-2 justify-end pt-[calc(var(--header-height)+1rem)] flex flex-col gap-2 flex-grow"
25
+ >
26
+ <AnimatePresence initial={false}>
27
+ {messages.map((message, i) => (
28
+ <li class="flex" key={i}>
29
+ {match(message)
30
+ .with({ type: 'system' }, message => (
31
+ <p class="uppercase w-full drop-shadow-[0_1.5px_white] text-[10px] text-neutral-8 select-none tracking-widest text-center py-2">
32
+ {message.text}
33
+ </p>
34
+ ))
35
+ .with({ type: 'text', author: P.union('bot', 'user') }, message => {
36
+ return (
37
+ <ChatBubble key={i} side={authorToSide[message.author]}>
38
+ {message.text}
39
+ </ChatBubble>
40
+ );
41
+ })
42
+ .with({ type: 'image' }, image => (
43
+ <img
44
+ class="max-w-[min(100%,24rem)] w-full rounded-2xl shadow-surface-md"
45
+ src={image.url}
46
+ style={{ aspectRatio: image.width / image.height }}
47
+ />
48
+ ))
49
+ .with({ type: 'file' }, file => {
50
+ return (
51
+ <FileThumbnail
52
+ class={file.author === 'bot' ? '' : 'ml-auto'}
53
+ file={{ name: file.fileName, sizeKb: file.fileSizeKb }}
54
+ />
55
+ );
56
+ })
57
+ .exhaustive()}
58
+ </li>
59
+ ))}
60
+ </AnimatePresence>
53
61
  <aside aria-hidden>{isBotTyping && <TypingIndicator />}</aside>
54
- </div>
62
+ </ol>
55
63
  );
56
64
  };
@@ -1,8 +1,7 @@
1
1
  import { Logger } from '@inploi/sdk';
2
- import { Ref, useMemo, useState } from 'preact/hooks';
2
+ import { useMemo, useRef, useState } from 'preact/hooks';
3
3
  import { match } from 'ts-pattern';
4
- import { JobApplication } from '~/chatbot.api';
5
- import { useLocalState } from '~/chatbot.state';
4
+ import { application } from '~/chatbot.state';
6
5
  import { ChatService } from '~/interpreter/interpreter';
7
6
 
8
7
  import { SubmitSuccessFn } from './chat-input/chat-input';
@@ -10,12 +9,11 @@ import { SubmitSuccessFn } from './chat-input/chat-input';
10
9
  const TYPING_SPEED_MS_PER_CHARACTER = 25;
11
10
 
12
11
  type UseChatServiceParams = {
13
- chatRef: Ref<HTMLDivElement>;
14
- application: JobApplication;
15
12
  logger?: Logger;
16
13
  };
17
14
 
18
- export const useChatService = ({ logger, application, chatRef }: UseChatServiceParams) => {
15
+ export const useChatService = ({ logger }: UseChatServiceParams) => {
16
+ const chatRef = useRef<HTMLDivElement>(null);
19
17
  const [isBotTyping, setIsBotTyping] = useState(false);
20
18
  const [onSubmitSuccessFn, setOnSubmitSuccessFn] = useState<SubmitSuccessFn>(() => () => {});
21
19
  const scrollToEnd = useMemo(
@@ -25,7 +23,6 @@ export const useChatService = ({ logger, application, chatRef }: UseChatServiceP
25
23
  );
26
24
 
27
25
  const chatService = useMemo(() => {
28
- const { setInput, updateSubmission, addMessage } = useLocalState.getState();
29
26
  const chatService: ChatService = {
30
27
  send: async ({ message, signal }) => {
31
28
  await match(message)
@@ -49,20 +46,15 @@ export const useChatService = ({ logger, application, chatRef }: UseChatServiceP
49
46
  logger?.info(`Aborted sending message`);
50
47
  return;
51
48
  }
52
- addMessage({ application, data: message });
53
- scrollToEnd({ behavior: 'smooth' });
49
+ application.addMessage(message);
54
50
  },
55
51
  input: async input => {
56
- setInput({ application, data: input });
52
+ application.setInput(input);
57
53
 
58
54
  return await new Promise(resolve => {
59
55
  const submitFunction: SubmitSuccessFn = submission => {
60
- setInput({ application, data: null });
61
- updateSubmission({
62
- application,
63
- fieldKey: input.key,
64
- data: submission,
65
- });
56
+ application.setInput(undefined);
57
+ application.setSubmission(input.key, submission);
66
58
  resolve(submission as any);
67
59
  };
68
60
  setOnSubmitSuccessFn(() => submitFunction);
@@ -71,9 +63,10 @@ export const useChatService = ({ logger, application, chatRef }: UseChatServiceP
71
63
  };
72
64
 
73
65
  return chatService;
74
- }, [application, logger, scrollToEnd]);
66
+ }, [logger]);
75
67
 
76
68
  return {
69
+ chatRef,
77
70
  chatService,
78
71
  isBotTyping,
79
72
  onSubmitSuccessFn,
@@ -0,0 +1,10 @@
1
+ import { useEffect, useRef } from 'preact/hooks';
2
+
3
+ export const useFocusOnMount = () => {
4
+ const focusRef = useRef<any>(null);
5
+ useEffect(() => {
6
+ focusRef.current?.focus();
7
+ }, []);
8
+
9
+ return focusRef;
10
+ };