@bigbinary/neetoui-rn 3.4.0 → 3.5.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.
Files changed (88) hide show
  1. package/lib/typescript/src/components/ChatInput/AttachmentsView.d.ts +2 -1
  2. package/lib/typescript/src/components/ChatInput/AttachmentsView.d.ts.map +1 -1
  3. package/lib/typescript/src/components/ChatInput/ChatInput.d.ts +85 -0
  4. package/lib/typescript/src/components/ChatInput/ChatInput.d.ts.map +1 -1
  5. package/lib/typescript/src/components/ChatInput/MentionSuggestionList.d.ts +9 -0
  6. package/lib/typescript/src/components/ChatInput/MentionSuggestionList.d.ts.map +1 -0
  7. package/lib/typescript/src/components/ChatInput/MentionsInput.d.ts +3 -1
  8. package/lib/typescript/src/components/ChatInput/MentionsInput.d.ts.map +1 -1
  9. package/lib/typescript/src/components/ChatInput/SegmentedOptions.d.ts +10 -0
  10. package/lib/typescript/src/components/ChatInput/SegmentedOptions.d.ts.map +1 -0
  11. package/lib/typescript/src/components/ChatInput/constants.d.ts +3 -0
  12. package/lib/typescript/src/components/ChatInput/constants.d.ts.map +1 -1
  13. package/lib/typescript/src/components/ChatInput/index.d.ts +1 -1
  14. package/lib/typescript/src/components/ChatInput/index.d.ts.map +1 -1
  15. package/lib/typescript/src/components/ChatInput/mentions.d.ts +2 -0
  16. package/lib/typescript/src/components/ChatInput/mentions.d.ts.map +1 -1
  17. package/lib/typescript/src/components/RichTextEditor/LazyRichTextEditor.d.ts +3 -0
  18. package/lib/typescript/src/components/RichTextEditor/LazyRichTextEditor.d.ts.map +1 -0
  19. package/lib/typescript/src/components/RichTextEditor/MentionSuggestionBar.d.ts +8 -0
  20. package/lib/typescript/src/components/RichTextEditor/MentionSuggestionBar.d.ts.map +1 -0
  21. package/lib/typescript/src/components/RichTextEditor/RichTextEditor.d.ts +45 -0
  22. package/lib/typescript/src/components/RichTextEditor/RichTextEditor.d.ts.map +1 -0
  23. package/lib/typescript/src/components/RichTextEditor/RichTextToolbar.d.ts +11 -0
  24. package/lib/typescript/src/components/RichTextEditor/RichTextToolbar.d.ts.map +1 -0
  25. package/lib/typescript/src/components/RichTextEditor/bridges/CodeBlockBridge.d.ts +13 -0
  26. package/lib/typescript/src/components/RichTextEditor/bridges/CodeBlockBridge.d.ts.map +1 -0
  27. package/lib/typescript/src/components/RichTextEditor/bridges/ComposerBridge.d.ts +27 -0
  28. package/lib/typescript/src/components/RichTextEditor/bridges/ComposerBridge.d.ts.map +1 -0
  29. package/lib/typescript/src/components/RichTextEditor/bridges/FigureBridge.d.ts +16 -0
  30. package/lib/typescript/src/components/RichTextEditor/bridges/FigureBridge.d.ts.map +1 -0
  31. package/lib/typescript/src/components/RichTextEditor/bridges/MentionBridge.d.ts +35 -0
  32. package/lib/typescript/src/components/RichTextEditor/bridges/MentionBridge.d.ts.map +1 -0
  33. package/lib/typescript/src/components/RichTextEditor/bridges/VariableBridge.d.ts +3 -0
  34. package/lib/typescript/src/components/RichTextEditor/bridges/VariableBridge.d.ts.map +1 -0
  35. package/lib/typescript/src/components/RichTextEditor/bridges/index.d.ts +8 -0
  36. package/lib/typescript/src/components/RichTextEditor/bridges/index.d.ts.map +1 -0
  37. package/lib/typescript/src/components/RichTextEditor/bridges/nodes/Figure.d.ts +3 -0
  38. package/lib/typescript/src/components/RichTextEditor/bridges/nodes/Figure.d.ts.map +1 -0
  39. package/lib/typescript/src/components/RichTextEditor/bridges/nodes/Mention.d.ts +3 -0
  40. package/lib/typescript/src/components/RichTextEditor/bridges/nodes/Mention.d.ts.map +1 -0
  41. package/lib/typescript/src/components/RichTextEditor/bridges/nodes/Variable.d.ts +3 -0
  42. package/lib/typescript/src/components/RichTextEditor/bridges/nodes/Variable.d.ts.map +1 -0
  43. package/lib/typescript/src/components/RichTextEditor/bridges/types.d.ts +8 -0
  44. package/lib/typescript/src/components/RichTextEditor/bridges/types.d.ts.map +1 -0
  45. package/lib/typescript/src/components/RichTextEditor/editorCss.d.ts +21 -0
  46. package/lib/typescript/src/components/RichTextEditor/editorCss.d.ts.map +1 -0
  47. package/lib/typescript/src/components/RichTextEditor/icons/index.d.ts +9 -0
  48. package/lib/typescript/src/components/RichTextEditor/icons/index.d.ts.map +1 -0
  49. package/lib/typescript/src/components/RichTextEditor/index.d.ts +4 -0
  50. package/lib/typescript/src/components/RichTextEditor/index.d.ts.map +1 -0
  51. package/lib/typescript/src/components/RichTextEditor/injectableText.d.ts +2 -0
  52. package/lib/typescript/src/components/RichTextEditor/injectableText.d.ts.map +1 -0
  53. package/lib/typescript/src/components/RichTextEditor/types.d.ts +6 -0
  54. package/lib/typescript/src/components/RichTextEditor/types.d.ts.map +1 -0
  55. package/lib/typescript/src/components/RichTextEditor/web/build/editorHtml.d.ts +2 -0
  56. package/lib/typescript/src/components/RichTextEditor/web/build/editorHtml.d.ts.map +1 -0
  57. package/lib/typescript/src/index.d.ts +2 -1
  58. package/lib/typescript/src/index.d.ts.map +1 -1
  59. package/package.json +30 -7
  60. package/src/components/ChatInput/AttachmentsView.tsx +6 -0
  61. package/src/components/ChatInput/ChatInput.tsx +569 -91
  62. package/src/components/ChatInput/MentionSuggestionList.tsx +71 -0
  63. package/src/components/ChatInput/MentionsInput.tsx +34 -46
  64. package/src/components/ChatInput/SegmentedOptions.tsx +84 -0
  65. package/src/components/ChatInput/constants.ts +10 -0
  66. package/src/components/ChatInput/index.ts +3 -0
  67. package/src/components/ChatInput/mentions.ts +22 -0
  68. package/src/components/RichTextEditor/LazyRichTextEditor.tsx +19 -0
  69. package/src/components/RichTextEditor/MentionSuggestionBar.tsx +79 -0
  70. package/src/components/RichTextEditor/RichTextEditor.tsx +227 -0
  71. package/src/components/RichTextEditor/RichTextToolbar.tsx +476 -0
  72. package/src/components/RichTextEditor/bridges/CodeBlockBridge.ts +29 -0
  73. package/src/components/RichTextEditor/bridges/ComposerBridge.ts +107 -0
  74. package/src/components/RichTextEditor/bridges/FigureBridge.ts +38 -0
  75. package/src/components/RichTextEditor/bridges/MentionBridge.ts +79 -0
  76. package/src/components/RichTextEditor/bridges/VariableBridge.ts +7 -0
  77. package/src/components/RichTextEditor/bridges/index.ts +66 -0
  78. package/src/components/RichTextEditor/bridges/nodes/Figure.ts +127 -0
  79. package/src/components/RichTextEditor/bridges/nodes/Mention.ts +48 -0
  80. package/src/components/RichTextEditor/bridges/nodes/Variable.ts +68 -0
  81. package/src/components/RichTextEditor/bridges/types.ts +26 -0
  82. package/src/components/RichTextEditor/editorCss.ts +69 -0
  83. package/src/components/RichTextEditor/icons/index.tsx +41 -0
  84. package/src/components/RichTextEditor/index.ts +3 -0
  85. package/src/components/RichTextEditor/injectableText.ts +11 -0
  86. package/src/components/RichTextEditor/types.ts +1 -0
  87. package/src/components/RichTextEditor/web/build/editorHtml.ts +3 -0
  88. package/src/index.ts +9 -0
@@ -6,35 +6,54 @@ import {
6
6
  useState,
7
7
  type ReactNode,
8
8
  } from "react";
9
- import { type TextInput, View, type TextInputProps } from "react-native";
9
+ import {
10
+ Keyboard,
11
+ type TextInput,
12
+ View,
13
+ type TextInputProps,
14
+ } from "react-native";
10
15
  import Attachment from "@bigbinary/neeto-icons-rn/icons/Attachment";
11
16
  import BookOpen from "@bigbinary/neeto-icons-rn/icons/BookOpen";
12
17
  import CannedResponses from "@bigbinary/neeto-icons-rn/icons/CannedResponses";
13
- import ChatBubble from "@bigbinary/neeto-icons-rn/icons/ChatBubble";
14
18
  import Down from "@bigbinary/neeto-icons-rn/icons/Down";
15
19
  import Expand from "@bigbinary/neeto-icons-rn/icons/Expand";
16
- import Forward from "@bigbinary/neeto-icons-rn/icons/Forward";
17
20
  import Minimize from "@bigbinary/neeto-icons-rn/icons/Minimize";
18
- import Notes from "@bigbinary/neeto-icons-rn/icons/Notes";
19
21
  import SendPlane from "@bigbinary/neeto-icons-rn/icons/SendPlane";
22
+ import { Gesture, GestureDetector } from "react-native-gesture-handler";
23
+ import Animated, {
24
+ runOnJS,
25
+ useAnimatedStyle,
26
+ useSharedValue,
27
+ } from "react-native-reanimated";
20
28
  import { StyleSheet, useUnistyles } from "react-native-unistyles";
21
29
 
22
30
  import { moderateScale, scaleFor } from "../../utils/scale";
23
31
  import { BottomSheet } from "../BottomSheet";
32
+ import {
33
+ RichTextEditor,
34
+ type RichTextEditorProps,
35
+ type RichTextEditorRef,
36
+ } from "../RichTextEditor";
37
+ import { TextType } from "../RichTextEditor/icons";
24
38
  import { Skeleton } from "../Skeleton";
39
+ import { Toast } from "../Toast";
25
40
  import { Touchable } from "../Touchable";
26
41
  import { Typography } from "../Typography";
27
42
  import { AttachmentsView } from "./AttachmentsView";
28
43
  import { IconButton } from "./IconButton";
29
44
  import { MentionsInput } from "./MentionsInput";
45
+ import { SegmentedOptions } from "./SegmentedOptions";
30
46
  import {
31
47
  ACTION_LABELS,
48
+ REPLY_ACTION_LABEL,
49
+ NOTE_HINT,
32
50
  OPTION_TYPES,
33
51
  PLACEHOLDERS,
34
52
  type ChatInputOption,
35
53
  } from "./constants";
36
54
  import {
37
55
  convertMentionsToHTMLAndPlainText,
56
+ htmlToPlainText,
38
57
  type MentionSuggestion,
39
58
  } from "./mentions";
40
59
 
@@ -47,8 +66,77 @@ export type ChatInputPayload = {
47
66
  status?: string;
48
67
  };
49
68
 
69
+ export type ChatInputLayout = "compact" | "expanded";
70
+
71
+ // tentap's async messages never reject, so a WebView that stops answering
72
+ // would otherwise leave Send silently stuck.
73
+ const RICH_CONTENT_TIMEOUT = 5000;
74
+
75
+ const withTimeout = <T,>(promise: Promise<T>) =>
76
+ new Promise<T>((resolve, reject) => {
77
+ const timer = setTimeout(
78
+ () => reject(new Error("Editor did not respond")),
79
+ RICH_CONTENT_TIMEOUT
80
+ );
81
+ promise.then(
82
+ value => {
83
+ clearTimeout(timer);
84
+ resolve(value);
85
+ },
86
+ error => {
87
+ clearTimeout(timer);
88
+ reject(error);
89
+ }
90
+ );
91
+ });
92
+
93
+ // Each report is a WebView round trip; typing should not queue one per key.
94
+ const RICH_TEXT_REPORT_DELAY = 150;
95
+
96
+ export type ChatInputResizable = {
97
+ initialHeight: number;
98
+ minHeight: number;
99
+ maxHeight: number;
100
+ /** Called with the settled height after each drag, e.g. to persist it. */
101
+ onResizeEnd?: (height: number) => void;
102
+ };
103
+
104
+ // Notes get their own document: a draft reply and its signature must not
105
+ // follow the agent into an internal note, or the other way round.
106
+ type RichEditorKey = "EMAIL" | "NOTE";
107
+
108
+ const RICH_EDITOR_OPTIONS: Record<RichEditorKey, ChatInputOption[]> = {
109
+ EMAIL: [OPTION_TYPES.REPLY, OPTION_TYPES.FORWARD],
110
+ NOTE: [OPTION_TYPES.NOTE],
111
+ };
112
+
113
+ const RICH_EDITOR_DEFAULT_OPTION: Record<RichEditorKey, ChatInputOption> = {
114
+ EMAIL: OPTION_TYPES.REPLY,
115
+ NOTE: OPTION_TYPES.NOTE,
116
+ };
117
+
118
+ const editorKeyFor = (option: ChatInputOption): RichEditorKey =>
119
+ option === OPTION_TYPES.NOTE ? "NOTE" : "EMAIL";
120
+
121
+ const clampHeight = (height: number, min: number, max: number) => {
122
+ "worklet";
123
+
124
+ return Math.min(Math.max(height, min), max);
125
+ };
126
+
127
+ export type ChatInputFocusTarget = "start" | "end" | "emptyParagraph";
128
+
50
129
  export type ChatInputRef = {
51
130
  clearEmailFields: () => void;
131
+ /** Rich: replaces the document. Plain: replaces the text with the HTML's text via onChangeText. */
132
+ setHTML: (html: string) => void;
133
+ /**
134
+ * Rich: inserts at the cursor of the option's editor (default: the
135
+ * selected one). Plain: appends the HTML's text via onChangeText.
136
+ */
137
+ insertHTML: (html: string, option?: ChatInputOption) => void;
138
+ focus: (where?: ChatInputFocusTarget) => void;
139
+ getHTML: () => Promise<string>;
52
140
  };
53
141
 
54
142
  export type ChatInputProps = Omit<
@@ -79,6 +167,39 @@ export type ChatInputProps = Omit<
79
167
  suggestions?: MentionSuggestion[];
80
168
  disabled?: boolean;
81
169
  isLoading?: boolean;
170
+ /** "expanded" (tablets) moves the mode toggles into a header row and keeps attachments open. */
171
+ layout?: ChatInputLayout;
172
+ /** Minimum visible lines of text. */
173
+ minLines?: number;
174
+ /** Rendered between the input and the attachments. */
175
+ Signature?: ReactNode;
176
+ /** Rendered at the end of the toolbar's left group. */
177
+ ToolbarExtras?: ReactNode;
178
+ /** Header hint shown in expanded layout while a note is selected. */
179
+ noteHint?: string;
180
+ /**
181
+ * "rich" swaps the text input for RichTextEditor; it then ignores value and
182
+ * suggestions, reports the editor's plain text through onChangeText
183
+ * (debounced), and content is reset through the ref.
184
+ */
185
+ editor?: "plain" | "rich";
186
+ /** Adds a drag handle on top that resizes the composer within the bounds. */
187
+ resizable?: ChatInputResizable;
188
+ /** Rich only: options that keep the plain mention input. */
189
+ plainEditorFor?: ChatInputOption[];
190
+ /**
191
+ * Rich only: receives the plain text of the option's editor; defaults to
192
+ * onChangeText. Notes and replies keep separate documents.
193
+ */
194
+ onRichTextChange?: (text: string, option: ChatInputOption) => void;
195
+ /** Rich only: the signature at the end of initialHTML; first focus lands above it. */
196
+ signatureHTML?: string;
197
+ /** Rich only: the document the reply editor starts with; notes start empty. */
198
+ initialHTML?: string;
199
+ /** Rich only. */
200
+ onPickImage?: RichTextEditorProps["onPickImage"];
201
+ /** Rich only. */
202
+ onPickLink?: RichTextEditorProps["onPickLink"];
82
203
  };
83
204
 
84
205
  /**
@@ -114,12 +235,73 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
114
235
  suggestions = [],
115
236
  disabled = false,
116
237
  isLoading = false,
238
+ layout = "compact",
239
+ minLines,
240
+ Signature,
241
+ ToolbarExtras,
242
+ noteHint = NOTE_HINT,
243
+ editor = "plain",
244
+ plainEditorFor = [],
245
+ resizable,
246
+ onRichTextChange,
247
+ initialHTML,
248
+ signatureHTML,
249
+ onPickImage,
250
+ onPickLink,
117
251
  ...inputProps
118
252
  },
119
253
  ref
120
254
  ) => {
121
255
  const { theme } = useUnistyles();
122
256
  const inputRef = useRef<TextInput>(null);
257
+ const richRefs = useRef<Record<RichEditorKey, RichTextEditorRef | null>>({
258
+ EMAIL: null,
259
+ NOTE: null,
260
+ });
261
+ const isSendingRich = useRef(false);
262
+
263
+ const minHeight = resizable?.minHeight ?? 0;
264
+ const maxHeight = resizable?.maxHeight ?? 0;
265
+ const height = useSharedValue(resizable?.initialHeight ?? 0);
266
+ const dragStartHeight = useSharedValue(0);
267
+ const reportHeight = (settled: number) => resizable?.onResizeEnd?.(settled);
268
+
269
+ const resizeGesture = Gesture.Pan()
270
+ .withTestId("composer-resize")
271
+ .hitSlop({ top: 8, bottom: 8 })
272
+ .onStart(() => {
273
+ dragStartHeight.value = clampHeight(height.value, minHeight, maxHeight);
274
+ })
275
+ .onUpdate(({ translationY }) => {
276
+ height.value = clampHeight(
277
+ dragStartHeight.value - translationY,
278
+ minHeight,
279
+ maxHeight
280
+ );
281
+ })
282
+ .onEnd(() => {
283
+ runOnJS(reportHeight)(height.value);
284
+ });
285
+
286
+ // The bound is re-read here so a shrinking maxHeight (keyboard up)
287
+ // clamps a stored height without a drag.
288
+ const resizedStyle = useAnimatedStyle(() => ({
289
+ height: clampHeight(height.value, minHeight, maxHeight),
290
+ }));
291
+ const [isFormattingVisible, setIsFormattingVisible] = useState(true);
292
+ const richTextTimers = useRef<
293
+ Partial<Record<RichEditorKey, ReturnType<typeof setTimeout>>>
294
+ >({});
295
+
296
+ useEffect(
297
+ () => () =>
298
+ Object.values(richTextTimers.current).forEach(timer =>
299
+ clearTimeout(timer)
300
+ ),
301
+ []
302
+ );
303
+
304
+ const hasRichEditor = editor === "rich";
123
305
 
124
306
  const [selectedOption, setSelectedOption] = useState<ChatInputOption>(
125
307
  initialSelectedOption
@@ -135,6 +317,48 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
135
317
  const isReplySelected = selectedOption === OPTION_TYPES.REPLY;
136
318
  const isNoteSelected = selectedOption === OPTION_TYPES.NOTE;
137
319
  const isForwardSelected = selectedOption === OPTION_TYPES.FORWARD;
320
+ const isExpanded = layout === "expanded";
321
+ const isRich = hasRichEditor && !plainEditorFor.includes(selectedOption);
322
+ const activeKey = editorKeyFor(selectedOption);
323
+ const activeEditor = () => richRefs.current[activeKey];
324
+
325
+ // The editor a mode needs mounts on first use and then stays, so a
326
+ // draft survives switching back and forth.
327
+ const [editorSeeds, setEditorSeeds] = useState<
328
+ Partial<Record<RichEditorKey, string>>
329
+ >(() =>
330
+ hasRichEditor ? { [editorKeyFor(initialSelectedOption)]: "" } : {}
331
+ );
332
+ const [lateEditors, setLateEditors] = useState<RichEditorKey[]>([]);
333
+
334
+ const mountEditor = (key: RichEditorKey, html = "") => {
335
+ setEditorSeeds(seeds => ({ ...seeds, [key]: (seeds[key] ?? "") + html }));
336
+ setLateEditors(keys => (keys.includes(key) ? keys : [...keys, key]));
337
+ };
338
+
339
+ useEffect(() => {
340
+ if (isRich && editorSeeds[activeKey] === undefined) {
341
+ mountEditor(activeKey);
342
+ }
343
+ // eslint-disable-next-line react-hooks/exhaustive-deps
344
+ }, [isRich, activeKey]);
345
+
346
+ const optionInEditor = (key: RichEditorKey) =>
347
+ RICH_EDITOR_OPTIONS[key].includes(selectedOption)
348
+ ? selectedOption
349
+ : RICH_EDITOR_DEFAULT_OPTION[key];
350
+
351
+ const handleRichChange = (key: RichEditorKey) => {
352
+ clearTimeout(richTextTimers.current[key]);
353
+ const option = optionInEditor(key);
354
+ richTextTimers.current[key] = setTimeout(async () => {
355
+ const text = await richRefs.current[key]?.getText();
356
+ if (text === undefined) return;
357
+
358
+ if (onRichTextChange) onRichTextChange(text, option);
359
+ else onChangeText(text);
360
+ }, RICH_TEXT_REPORT_DELAY);
361
+ };
138
362
 
139
363
  useImperativeHandle(
140
364
  ref,
@@ -144,10 +368,44 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
144
368
  setToEmailsForForward([]);
145
369
  setCcEmails([]);
146
370
  setBccEmails([]);
147
- inputRef.current?.blur();
371
+ if (isRich) activeEditor()?.blur();
372
+ else inputRef.current?.blur();
373
+ },
374
+ setHTML: html =>
375
+ isRich
376
+ ? activeEditor()?.setHTML(html)
377
+ : onChangeText(htmlToPlainText(html)),
378
+ insertHTML: (html, option = selectedOption) => {
379
+ if (!hasRichEditor || plainEditorFor.includes(option)) {
380
+ onChangeText(`${value}${htmlToPlainText(html)}`);
381
+
382
+ return;
383
+ }
384
+
385
+ const key = editorKeyFor(option);
386
+ const target = richRefs.current[key];
387
+ if (target) target.insertHTML(html);
388
+ else mountEditor(key, html);
148
389
  },
390
+ focus: where =>
391
+ isRich ? activeEditor()?.focus(where) : inputRef.current?.focus(),
392
+ getHTML: async () =>
393
+ isRich
394
+ ? ((await activeEditor()?.getHTML()) ?? "")
395
+ : convertMentionsToHTMLAndPlainText({ suggestions, value }).html,
149
396
  }),
150
- [initialToEmails]
397
+ // eslint-disable-next-line react-hooks/exhaustive-deps
398
+ [
399
+ initialToEmails,
400
+ hasRichEditor,
401
+ isRich,
402
+ activeKey,
403
+ selectedOption,
404
+ plainEditorFor,
405
+ onChangeText,
406
+ suggestions,
407
+ value,
408
+ ]
151
409
  );
152
410
 
153
411
  useEffect(() => {
@@ -167,15 +425,22 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
167
425
 
168
426
  const selectOption = (option: ChatInputOption) => {
169
427
  setIsAttachmentsVisible(false);
170
- inputRef.current?.focus();
428
+ const target =
429
+ hasRichEditor && !plainEditorFor.includes(option)
430
+ ? richRefs.current[editorKeyFor(option)]
431
+ : inputRef.current;
432
+ target?.focus();
171
433
  setSelectedOption(option);
172
434
  };
173
435
 
174
- const buildPayload = (extra?: Partial<ChatInputPayload>) => ({
436
+ const buildPayload = (
437
+ content: Pick<ChatInputPayload, "html" | "plainText">,
438
+ extra?: Partial<ChatInputPayload>
439
+ ) => ({
175
440
  toEmails: isForwardSelected ? toEmailsForForward : toEmails,
176
441
  ccEmails,
177
442
  bccEmails,
178
- ...convertMentionsToHTMLAndPlainText({ suggestions, value }),
443
+ ...content,
179
444
  ...extra,
180
445
  });
181
446
 
@@ -188,42 +453,184 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
188
453
  FORWARD: onForward ?? (() => {}),
189
454
  };
190
455
 
191
- const handleAction = (extra?: Partial<ChatInputPayload>) => {
192
- actionHandlers[selectedOption](buildPayload(extra));
456
+ const handleAction = async (extra?: Partial<ChatInputPayload>) => {
457
+ const handler = actionHandlers[selectedOption];
193
458
  setIsAttachmentsVisible(false);
459
+
460
+ const editorRef = activeEditor();
461
+ if (!isRich || !editorRef) {
462
+ handler(
463
+ buildPayload(
464
+ convertMentionsToHTMLAndPlainText({ suggestions, value }),
465
+ extra
466
+ )
467
+ );
468
+
469
+ return;
470
+ }
471
+
472
+ if (isSendingRich.current) return;
473
+
474
+ isSendingRich.current = true;
475
+ let content: [string, string];
476
+ try {
477
+ content = await withTimeout(
478
+ Promise.all([editorRef.getHTML(), editorRef.getText()])
479
+ );
480
+ } catch {
481
+ Toast.show({
482
+ type: "error",
483
+ text1: "Couldn't read the message. Try sending again.",
484
+ });
485
+
486
+ return;
487
+ } finally {
488
+ isSendingRich.current = false;
489
+ }
490
+
491
+ const [html, plainText] = content;
492
+ handler(buildPayload({ html, plainText }, extra));
493
+ };
494
+
495
+ // Keyboard.dismiss() only reaches native inputs; the rich editor's
496
+ // keyboard belongs to its WebView and closes on blur.
497
+ const dismissKeyboard = () => {
498
+ if (isRich) activeEditor()?.blur();
499
+ Keyboard.dismiss();
500
+ };
501
+
502
+ const withKeyboardDismissed = (action?: () => void) => () => {
503
+ dismissKeyboard();
504
+ action?.();
194
505
  };
195
506
 
196
507
  const handleAttachmentPress = () => {
508
+ dismissKeyboard();
197
509
  // v2: when collapsed attachments already exist, first tap only
198
510
  // expands them instead of re-opening the upload flow.
199
- if (isAttachmentsVisible || attachmentsCount === 0) onAttachment();
511
+ if (isExpanded || isAttachmentsVisible || attachmentsCount === 0) {
512
+ onAttachment();
513
+ }
200
514
  setIsAttachmentsVisible(true);
201
515
  };
202
516
 
203
- const shouldShowExpandToggle =
204
- attachmentsCount > 0 || (toEmails.length > 0 && !isNoteSelected);
517
+ const shouldShowExpandToggle = !isExpanded && attachmentsCount > 0;
518
+
519
+ const recipients = toEmails.filter(Boolean).join(", ");
520
+ let headerHint: string | undefined;
521
+ if (isNoteSelected) headerHint = noteHint;
522
+ else if (isReplySelected && recipients) headerHint = `To: ${recipients}`;
205
523
 
206
524
  const isActionDisabled =
207
525
  disabled || (isForwardSelected && toEmailsForForward.length === 0);
208
526
 
527
+ // The formatting bar draws its own bottom line; a second one here would
528
+ // double it when nothing sits between them.
529
+ const isFooterBelowToolbar =
530
+ isRich && isFormattingVisible && attachmentsCount === 0;
531
+
532
+ const modeOptions: ChatInputOption[] = onForward
533
+ ? [OPTION_TYPES.REPLY, OPTION_TYPES.NOTE, OPTION_TYPES.FORWARD]
534
+ : [OPTION_TYPES.REPLY, OPTION_TYPES.NOTE];
535
+
536
+ const actionLabel =
537
+ isExpanded && isReplySelected
538
+ ? REPLY_ACTION_LABEL
539
+ : ACTION_LABELS[selectedOption];
540
+
209
541
  return (
210
- <View style={styles.root}>
542
+ <Animated.View
543
+ style={[styles.root, resizable && [styles.fixedHeight, resizedStyle]]}
544
+ >
545
+ {resizable && (
546
+ <GestureDetector gesture={resizeGesture}>
547
+ <View
548
+ accessibilityLabel="Resize composer"
549
+ accessibilityRole="adjustable"
550
+ style={styles.resizeHandleArea}
551
+ >
552
+ <View style={styles.resizeHandle} />
553
+ </View>
554
+ </GestureDetector>
555
+ )}
211
556
  {isLoading && (
212
557
  <Skeleton animated height={moderateScale(2)} variant="line" />
213
558
  )}
214
- <View style={[styles.container, isNoteSelected && styles.noteSurface]}>
215
- <View style={styles.inputRow}>
216
- <MentionsInput
217
- inputRef={inputRef}
218
- placeholder={PLACEHOLDERS[selectedOption]}
219
- suggestions={suggestions}
220
- value={value}
221
- shouldShowSuggestions={showSuggestionsFor.includes(
222
- selectedOption
223
- )}
224
- onChange={onChangeText}
225
- {...inputProps}
226
- />
559
+ <View
560
+ style={[
561
+ styles.card,
562
+ resizable && styles.resizableCard,
563
+ isNoteSelected && styles.noteSurface,
564
+ ]}
565
+ >
566
+ {headerHint && (
567
+ <View style={styles.headerBand}>
568
+ <Typography numberOfLines={1} size="s" style={styles.headerHint}>
569
+ {headerHint}
570
+ </Typography>
571
+ </View>
572
+ )}
573
+ <View
574
+ style={[
575
+ styles.inputRow,
576
+ resizable && styles.fill,
577
+ !isRich && styles.plainInputRow,
578
+ ]}
579
+ >
580
+ {(Object.keys(RICH_EDITOR_OPTIONS) as RichEditorKey[]).map(key => {
581
+ const seed = editorSeeds[key];
582
+ if (seed === undefined) return null;
583
+
584
+ const isNote = key === "NOTE";
585
+ const options = RICH_EDITOR_OPTIONS[key];
586
+ const isShown = isRich && key === activeKey;
587
+
588
+ return (
589
+ <RichTextEditor
590
+ autoFocus={
591
+ lateEditors.includes(key) || !!inputProps.autoFocus
592
+ }
593
+ backgroundColor={
594
+ isNote ? theme.colors.background.oldLace : undefined
595
+ }
596
+ initialHTML={isNote ? seed : `${initialHTML ?? ""}${seed}`}
597
+ key={key}
598
+ placeholder={PLACEHOLDERS[optionInEditor(key)]}
599
+ ref={instance => {
600
+ richRefs.current[key] = instance;
601
+ }}
602
+ style={[
603
+ styles.richEditor,
604
+ resizable && styles.resizableRichEditor,
605
+ !isShown && styles.hidden,
606
+ ]}
607
+ mentions={
608
+ options.some(option => showSuggestionsFor.includes(option))
609
+ ? suggestions
610
+ : undefined
611
+ }
612
+ toolbar={isFormattingVisible ? "fixed" : "none"}
613
+ trailingHTML={isNote ? undefined : signatureHTML}
614
+ onPickImage={onPickImage}
615
+ onChange={() => handleRichChange(key)}
616
+ onPickLink={onPickLink}
617
+ />
618
+ );
619
+ })}
620
+ {!isRich && (
621
+ <MentionsInput
622
+ inputRef={inputRef}
623
+ minLines={minLines}
624
+ placeholder={PLACEHOLDERS[selectedOption]}
625
+ suggestions={suggestions}
626
+ value={value}
627
+ shouldShowSuggestions={showSuggestionsFor.includes(
628
+ selectedOption
629
+ )}
630
+ onChange={onChangeText}
631
+ {...inputProps}
632
+ />
633
+ )}
227
634
  {shouldShowExpandToggle && (
228
635
  <IconButton
229
636
  accessibilityLabel={
@@ -235,52 +642,54 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
235
642
  />
236
643
  )}
237
644
  </View>
238
- <AttachmentsView
239
- Attachments={Attachments}
240
- attachmentsCount={attachmentsCount}
241
- isAttachmentsVisible={isAttachmentsVisible}
242
- isNoteOptionSelected={isNoteSelected}
243
- onExpand={() => setIsAttachmentsVisible(true)}
244
- />
245
- <View style={styles.toolbar}>
246
- <View style={styles.toolbarGroup}>
247
- <IconButton
248
- accessibilityLabel="Reply"
249
- Icon={ChatBubble}
250
- isActive={isReplySelected}
251
- onPress={() => selectOption(OPTION_TYPES.REPLY)}
252
- />
253
- <IconButton
254
- accessibilityLabel="Add note"
255
- Icon={Notes}
256
- isActive={isNoteSelected}
257
- onPress={() => selectOption(OPTION_TYPES.NOTE)}
258
- />
259
- {onForward && (
645
+ {Signature}
646
+ <View style={styles.attachments}>
647
+ <AttachmentsView
648
+ Attachments={Attachments}
649
+ attachmentsCount={attachmentsCount}
650
+ isAlwaysExpanded={isExpanded}
651
+ isAttachmentsVisible={isAttachmentsVisible}
652
+ isNoteOptionSelected={isNoteSelected}
653
+ onExpand={() => setIsAttachmentsVisible(true)}
654
+ />
655
+ </View>
656
+ <View
657
+ style={[
658
+ styles.footer,
659
+ !isExpanded && styles.compactFooter,
660
+ isFooterBelowToolbar && styles.footerBelowToolbar,
661
+ ]}
662
+ >
663
+ <SegmentedOptions
664
+ isCompact={!isExpanded}
665
+ options={modeOptions}
666
+ selected={selectedOption}
667
+ onSelect={selectOption}
668
+ />
669
+ <View style={styles.footerActions}>
670
+ {isRich && (
260
671
  <IconButton
261
- accessibilityLabel="Forward"
262
- Icon={Forward}
263
- isActive={isForwardSelected}
264
- onPress={() => selectOption(OPTION_TYPES.FORWARD)}
672
+ accessibilityLabel={
673
+ isFormattingVisible ? "Hide formatting" : "Show formatting"
674
+ }
675
+ Icon={TextType}
676
+ isActive={isFormattingVisible}
677
+ onPress={() => setIsFormattingVisible(visible => !visible)}
265
678
  />
266
679
  )}
267
- <View style={styles.toolbarDivider} />
268
- {/* isActive is the selected-option tint for the three mode
269
- toggles above. These two are actions, not modes, so they
270
- stay at full strength like the attachment button. */}
271
680
  {onCannedResponse &&
272
681
  showCannedResponsesFor.includes(selectedOption) && (
273
682
  <IconButton
274
683
  accessibilityLabel="Canned responses"
275
684
  Icon={CannedResponses}
276
- onPress={onCannedResponse}
685
+ onPress={withKeyboardDismissed(onCannedResponse)}
277
686
  />
278
687
  )}
279
688
  {onArticles && showArticlesFor.includes(selectedOption) && (
280
689
  <IconButton
281
690
  accessibilityLabel="Articles"
282
691
  Icon={BookOpen}
283
- onPress={onArticles}
692
+ onPress={withKeyboardDismissed(onArticles)}
284
693
  />
285
694
  )}
286
695
  <IconButton
@@ -289,11 +698,7 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
289
698
  Icon={Attachment}
290
699
  onPress={handleAttachmentPress}
291
700
  />
292
- </View>
293
- <View style={styles.toolbarGroup}>
294
- {/* Both actions share one filled pill, split by a divider, as
295
- on web. The send affordance is the plane alone on a reply;
296
- note and forward keep their labels. */}
701
+ {ToolbarExtras}
297
702
  <View
298
703
  style={[
299
704
  styles.sendGroup,
@@ -304,15 +709,17 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
304
709
  accessibilityLabel="Send"
305
710
  accessibilityRole="button"
306
711
  disabled={isActionDisabled}
307
- style={styles.sendAction}
308
- onPress={() => handleAction()}
712
+ style={[
713
+ styles.sendAction,
714
+ !isExpanded && styles.compactSendAction,
715
+ ]}
716
+ onPress={() => void handleAction()}
309
717
  >
310
- {!!ACTION_LABELS[selectedOption] && (
718
+ {actionLabel ? (
311
719
  <Typography size="m" style={styles.sendLabel}>
312
- {ACTION_LABELS[selectedOption]}
720
+ {actionLabel}
313
721
  </Typography>
314
- )}
315
- {isReplySelected && (
722
+ ) : (
316
723
  <SendPlane
317
724
  color={theme.colors.text.inverse}
318
725
  size={moderateScale(20)}
@@ -327,7 +734,9 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
327
734
  accessibilityRole="button"
328
735
  disabled={disabled}
329
736
  style={styles.sendMenu}
330
- onPress={() => setIsReplyMenuVisible(true)}
737
+ onPress={withKeyboardDismissed(() =>
738
+ setIsReplyMenuVisible(true)
739
+ )}
331
740
  >
332
741
  <Down
333
742
  color={theme.colors.text.inverse}
@@ -342,13 +751,13 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
342
751
  data={["Send and set as closed"]}
343
752
  hide={() => setIsReplyMenuVisible(false)}
344
753
  isVisible={isReplyMenuVisible}
345
- onItemPress={() => handleAction({ status: "closed" })}
754
+ onItemPress={() => void handleAction({ status: "closed" })}
346
755
  />
347
756
  )}
348
757
  </View>
349
758
  </View>
350
759
  </View>
351
- </View>
760
+ </Animated.View>
352
761
  );
353
762
  }
354
763
  );
@@ -361,51 +770,121 @@ const styles = StyleSheet.create((theme, rt) => {
361
770
  return {
362
771
  // A caller that caps this composer's height (desk clamps it to a share
363
772
  // of the screen) would otherwise have Yoga keep every child at its
364
- // natural height and let overflow clip the last one — the toolbar.
773
+ // natural height and let overflow clip the last one — the footer.
365
774
  // Declaring what may shrink makes the text area absorb the squeeze
366
775
  // instead, so the actions stay reachable however tall the attachments
367
776
  // and typed content get.
368
777
  root: {
369
778
  flexShrink: 1,
370
779
  },
371
- container: {
780
+ // A dragged height is the agent's choice; the conversation gives way.
781
+ fixedHeight: {
782
+ flexShrink: 0,
783
+ },
784
+ fill: {
785
+ flex: 1,
786
+ },
787
+ // The drag sets the height, so the editor gives way instead of pushing
788
+ // the footer out; the card clips while the pill above stays visible.
789
+ resizableCard: {
790
+ flex: 1,
791
+ overflow: "hidden",
792
+ },
793
+ resizableRichEditor: {
794
+ minHeight: 0,
795
+ },
796
+ // desk-web's handle: a pill on the line above the composer. It overlays
797
+ // the top edge instead of taking a row, so the To band starts flush; the
798
+ // pill's upper half shows only where the parent does not clip.
799
+ resizeHandleArea: {
800
+ alignItems: "center",
801
+ height: moderateScale(16),
802
+ left: 0,
803
+ position: "absolute",
804
+ right: 0,
805
+ top: 0,
806
+ zIndex: 1,
807
+ },
808
+ resizeHandle: {
809
+ backgroundColor: theme.colors.border.secondary,
810
+ borderRadius: moderateScale(3),
811
+ height: moderateScale(6),
812
+ marginTop: -moderateScale(3),
813
+ width: moderateScale(48),
814
+ },
815
+ card: {
816
+ backgroundColor: theme.colors.background.primary,
372
817
  flexShrink: 1,
373
- paddingBottom: moderateScale(5),
374
- paddingHorizontal: moderateScale(16),
375
818
  },
376
819
  noteSurface: {
377
820
  backgroundColor: theme.colors.background.oldLace,
378
821
  },
822
+ headerBand: {
823
+ backgroundColor: theme.colors.background.secondary,
824
+ borderBottomColor: theme.colors.border.primary,
825
+ borderBottomWidth: 1,
826
+ paddingHorizontal: moderateScale(14),
827
+ paddingVertical: moderateScale(10),
828
+ },
829
+ headerHint: {
830
+ color: theme.colors.text.secondary,
831
+ },
379
832
  inputRow: {
380
833
  flexDirection: "row",
381
834
  flexShrink: 1,
382
835
  justifyContent: "space-between",
383
836
  },
837
+ plainInputRow: {
838
+ paddingHorizontal: moderateScale(14),
839
+ paddingTop: moderateScale(8),
840
+ },
841
+ // Kept mounted while hidden so the reply draft survives a mode switch.
842
+ hidden: {
843
+ display: "none",
844
+ },
845
+ richEditor: {
846
+ flex: 1,
847
+ minHeight: moderateScale(160),
848
+ },
384
849
  expandButton: {
385
850
  alignSelf: "center",
386
851
  },
387
- toolbar: {
852
+ attachments: {
853
+ paddingHorizontal: moderateScale(14),
854
+ },
855
+ footer: {
388
856
  alignItems: "center",
857
+ borderTopColor: theme.colors.border.primary,
858
+ borderTopWidth: 1,
389
859
  flexDirection: "row",
390
860
  flexShrink: 0,
861
+ gap: moderateScale(8),
391
862
  justifyContent: "space-between",
392
- marginTop: moderateScale(10),
863
+ paddingHorizontal: moderateScale(10),
864
+ paddingVertical: moderateScale(8),
393
865
  },
394
- toolbarGroup: {
866
+ footerBelowToolbar: {
867
+ borderTopWidth: 0,
868
+ },
869
+ compactFooter: {
870
+ gap: moderateScale(4),
871
+ paddingHorizontal: moderateScale(6),
872
+ },
873
+ compactSendAction: {
874
+ paddingHorizontal: moderateScale(10),
875
+ },
876
+ footerActions: {
395
877
  alignItems: "center",
396
878
  flexDirection: "row",
397
- },
398
- toolbarDivider: {
399
- alignSelf: "stretch",
400
- backgroundColor: theme.colors.palette.grey400,
401
- marginHorizontal: moderateScale(5),
402
- width: moderateScale(2),
879
+ flexShrink: 1,
880
+ gap: moderateScale(2),
403
881
  },
404
882
  sendGroup: {
405
883
  alignItems: "center",
406
884
  backgroundColor: theme.colors.primary,
407
885
  borderRadius: moderateScale(8),
408
886
  flexDirection: "row",
887
+ marginLeft: moderateScale(4),
409
888
  // Clips the divider to the rounded corners.
410
889
  overflow: "hidden",
411
890
  },
@@ -415,10 +894,9 @@ const styles = StyleSheet.create((theme, rt) => {
415
894
  sendAction: {
416
895
  alignItems: "center",
417
896
  flexDirection: "row",
418
- gap: moderateScale(6),
419
897
  justifyContent: "center",
420
- minHeight: moderateScale(34),
421
- paddingHorizontal: moderateScale(12),
898
+ minHeight: moderateScale(36),
899
+ paddingHorizontal: moderateScale(14),
422
900
  },
423
901
  sendGroupDivider: {
424
902
  alignSelf: "stretch",
@@ -429,7 +907,7 @@ const styles = StyleSheet.create((theme, rt) => {
429
907
  sendMenu: {
430
908
  alignItems: "center",
431
909
  justifyContent: "center",
432
- minHeight: moderateScale(34),
910
+ minHeight: moderateScale(36),
433
911
  paddingHorizontal: moderateScale(8),
434
912
  },
435
913
  sendLabel: {