@bigbinary/neetoui-rn 3.4.0 → 3.5.1

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