@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
@@ -0,0 +1,71 @@
1
+ import { ScrollView, View } from "react-native";
2
+
3
+ import { StyleSheet } from "react-native-unistyles";
4
+
5
+ import { scaleFor } from "../../utils/scale";
6
+ import { Avatar } from "../Avatar";
7
+ import { Touchable } from "../Touchable";
8
+ import { Typography } from "../Typography";
9
+ import type { MentionSuggestion } from "./mentions";
10
+
11
+ const MAX_VISIBLE_SUGGESTIONS = 4;
12
+
13
+ export const filterMentionSuggestions = (
14
+ suggestions: MentionSuggestion[],
15
+ keyword: string,
16
+ limit = MAX_VISIBLE_SUGGESTIONS
17
+ ) =>
18
+ suggestions
19
+ .filter(suggestion =>
20
+ suggestion.name.toLocaleLowerCase().includes(keyword.toLocaleLowerCase())
21
+ )
22
+ .slice(0, limit);
23
+
24
+ type MentionSuggestionListProps = {
25
+ suggestions: MentionSuggestion[];
26
+ onSelect: (suggestion: MentionSuggestion) => void;
27
+ };
28
+
29
+ export const MentionSuggestionList = ({
30
+ suggestions,
31
+ onSelect,
32
+ }: MentionSuggestionListProps) => (
33
+ <View style={styles.card} testID="mention-suggestions">
34
+ <ScrollView keyboardShouldPersistTaps="always">
35
+ {suggestions.map(suggestion => (
36
+ <Touchable
37
+ key={suggestion.id}
38
+ style={styles.row}
39
+ onPress={() => onSelect(suggestion)}
40
+ >
41
+ <Avatar
42
+ imageUrl={suggestion.imageUrl}
43
+ name={suggestion.name}
44
+ variant="small"
45
+ />
46
+ <Typography size="xs">{suggestion.name}</Typography>
47
+ </Touchable>
48
+ ))}
49
+ </ScrollView>
50
+ </View>
51
+ );
52
+
53
+ const styles = StyleSheet.create((theme, rt) => {
54
+ const moderateScale = scaleFor(rt.screen);
55
+
56
+ return {
57
+ card: {
58
+ backgroundColor: theme.colors.background.secondary,
59
+ borderRadius: moderateScale(10),
60
+ maxHeight: moderateScale(200),
61
+ ...theme.elevation.medium,
62
+ },
63
+ row: {
64
+ alignItems: "center",
65
+ flexDirection: "row",
66
+ gap: moderateScale(8),
67
+ height: moderateScale(50),
68
+ paddingHorizontal: moderateScale(10),
69
+ },
70
+ };
71
+ });
@@ -1,16 +1,21 @@
1
1
  import { useMemo, type Ref } from "react";
2
- import { ScrollView, TextInput, View, type TextInputProps } from "react-native";
2
+ import {
3
+ TextInput,
4
+ View,
5
+ useWindowDimensions,
6
+ type TextInputProps,
7
+ } from "react-native";
3
8
  import { useMentions } from "react-native-controlled-mentions";
4
9
  import { StyleSheet, useUnistyles } from "react-native-unistyles";
5
10
 
6
11
  import { scaleFor } from "../../utils/scale";
7
- import { Avatar } from "../Avatar";
8
- import { Touchable } from "../Touchable";
9
- import { Typography } from "../Typography";
12
+ import { LINE_HEIGHT_RATIO } from "../Typography";
13
+ import {
14
+ MentionSuggestionList,
15
+ filterMentionSuggestions,
16
+ } from "./MentionSuggestionList";
10
17
  import { buildMentionTriggerConfig, type MentionSuggestion } from "./mentions";
11
18
 
12
- const MAX_VISIBLE_SUGGESTIONS = 4;
13
-
14
19
  type MentionsInputProps = Omit<
15
20
  TextInputProps,
16
21
  "value" | "onChange" | "onChangeText" | "onSelectionChange"
@@ -20,6 +25,8 @@ type MentionsInputProps = Omit<
20
25
  suggestions?: MentionSuggestion[];
21
26
  shouldShowSuggestions?: boolean;
22
27
  inputRef?: Ref<TextInput>;
28
+ /** Minimum visible lines; replaces the default minHeight when set. */
29
+ minLines?: number;
23
30
  };
24
31
 
25
32
  export const MentionsInput = ({
@@ -28,10 +35,16 @@ export const MentionsInput = ({
28
35
  suggestions = [],
29
36
  shouldShowSuggestions = true,
30
37
  inputRef,
38
+ minLines,
31
39
  style,
32
40
  ...rest
33
41
  }: MentionsInputProps) => {
34
42
  const { theme } = useUnistyles();
43
+ const { fontScale } = useWindowDimensions();
44
+
45
+ // An explicit lineHeight keeps minHeight an exact multiple of a line.
46
+ // RN scales lineHeight by the OS font scale but not minHeight.
47
+ const lineHeight = Math.floor(theme.fontSizes.m * LINE_HEIGHT_RATIO);
35
48
 
36
49
  const triggersConfig = useMemo(
37
50
  () =>
@@ -52,42 +65,28 @@ export const MentionsInput = ({
52
65
 
53
66
  const filteredSuggestions =
54
67
  shouldShowSuggestions && keyword !== undefined
55
- ? suggestions
56
- .filter(suggestion =>
57
- suggestion.name
58
- .toLocaleLowerCase()
59
- .includes(keyword.toLocaleLowerCase())
60
- )
61
- .slice(0, MAX_VISIBLE_SUGGESTIONS)
68
+ ? filterMentionSuggestions(suggestions, keyword)
62
69
  : [];
63
70
 
64
71
  return (
65
72
  <View style={styles.container}>
66
73
  {filteredSuggestions.length > 0 && (
67
- <View style={styles.suggestionsCard} testID="mention-suggestions">
68
- <ScrollView keyboardShouldPersistTaps="always">
69
- {filteredSuggestions.map(suggestion => (
70
- <Touchable
71
- key={suggestion.id}
72
- style={styles.suggestionRow}
73
- onPress={() => onSelect(suggestion)}
74
- >
75
- <Avatar
76
- imageUrl={suggestion.imageUrl}
77
- name={suggestion.name}
78
- variant="small"
79
- />
80
- <Typography size="xs">{suggestion.name}</Typography>
81
- </Touchable>
82
- ))}
83
- </ScrollView>
84
- </View>
74
+ <MentionSuggestionList
75
+ suggestions={filteredSuggestions}
76
+ onSelect={onSelect}
77
+ />
85
78
  )}
86
79
  <TextInput
87
80
  multiline
88
81
  placeholderTextColor={theme.colors.text.muted}
89
82
  ref={inputRef}
90
- style={[styles.input, style]}
83
+ style={[
84
+ styles.input,
85
+ minLines
86
+ ? { lineHeight, minHeight: lineHeight * minLines * fontScale }
87
+ : styles.inputDefaultHeight,
88
+ style,
89
+ ]}
91
90
  {...rest}
92
91
  // Last on purpose: useMentions owns value, onChangeText and
93
92
  // onSelectionChange, and losing any of them to a caller prop kills
@@ -106,19 +105,6 @@ const styles = StyleSheet.create((theme, rt) => {
106
105
  flex: 1,
107
106
  marginVertical: moderateScale(10),
108
107
  },
109
- suggestionsCard: {
110
- backgroundColor: theme.colors.background.secondary,
111
- borderRadius: moderateScale(10),
112
- maxHeight: moderateScale(200),
113
- ...theme.elevation.medium,
114
- },
115
- suggestionRow: {
116
- alignItems: "center",
117
- flexDirection: "row",
118
- gap: moderateScale(8),
119
- height: moderateScale(50),
120
- paddingHorizontal: moderateScale(10),
121
- },
122
108
  input: {
123
109
  color: theme.colors.text.primary,
124
110
  fontFamily: theme.fonts.regular,
@@ -131,8 +117,10 @@ const styles = StyleSheet.create((theme, rt) => {
131
117
  // Sizing from ascent + descent on both sides removes it; iOS ignores
132
118
  // the flag.
133
119
  includeFontPadding: false,
134
- minHeight: moderateScale(50),
135
120
  padding: 0,
136
121
  },
122
+ inputDefaultHeight: {
123
+ minHeight: moderateScale(50),
124
+ },
137
125
  };
138
126
  });
@@ -0,0 +1,84 @@
1
+ import { View } from "react-native";
2
+ import { StyleSheet } from "react-native-unistyles";
3
+
4
+ import { scaleFor } from "../../utils/scale";
5
+ import { Touchable } from "../Touchable";
6
+ import { Typography } from "../Typography";
7
+ import { type ChatInputOption, OPTION_LABELS } from "./constants";
8
+
9
+ export type SegmentedOptionsProps = {
10
+ options: ChatInputOption[];
11
+ selected: ChatInputOption;
12
+ onSelect: (option: ChatInputOption) => void;
13
+ /** Tighter segments for a phone-width footer. */
14
+ isCompact?: boolean;
15
+ };
16
+
17
+ export const SegmentedOptions = ({
18
+ options,
19
+ selected,
20
+ onSelect,
21
+ isCompact = false,
22
+ }: SegmentedOptionsProps) => (
23
+ <View accessibilityRole="tablist" style={styles.group}>
24
+ {options.map(option => {
25
+ const isSelected = option === selected;
26
+
27
+ return (
28
+ <Touchable
29
+ accessibilityLabel={OPTION_LABELS[option]}
30
+ accessibilityRole="tab"
31
+ accessibilityState={{ selected: isSelected }}
32
+ key={option}
33
+ style={[
34
+ styles.segment,
35
+ isCompact && styles.compactSegment,
36
+ isSelected && styles.segmentSelected,
37
+ ]}
38
+ onPress={() => onSelect(option)}
39
+ >
40
+ <Typography
41
+ size="s"
42
+ style={isSelected ? styles.labelSelected : styles.label}
43
+ weight={isSelected ? "semibold" : "regular"}
44
+ >
45
+ {OPTION_LABELS[option]}
46
+ </Typography>
47
+ </Touchable>
48
+ );
49
+ })}
50
+ </View>
51
+ );
52
+
53
+ const styles = StyleSheet.create((theme, rt) => {
54
+ const moderateScale = scaleFor(rt.screen);
55
+
56
+ return {
57
+ group: {
58
+ backgroundColor: theme.colors.background.secondary,
59
+ borderRadius: moderateScale(8),
60
+ flexDirection: "row",
61
+ gap: moderateScale(2),
62
+ padding: moderateScale(2),
63
+ },
64
+ segment: {
65
+ borderRadius: moderateScale(6),
66
+ justifyContent: "center",
67
+ minHeight: moderateScale(32),
68
+ paddingHorizontal: moderateScale(14),
69
+ },
70
+ compactSegment: {
71
+ paddingHorizontal: moderateScale(10),
72
+ },
73
+ segmentSelected: {
74
+ backgroundColor: theme.colors.background.primary,
75
+ ...theme.elevation.low,
76
+ },
77
+ label: {
78
+ color: theme.colors.text.muted,
79
+ },
80
+ labelSelected: {
81
+ color: theme.colors.text.primary,
82
+ },
83
+ };
84
+ });
@@ -16,3 +16,13 @@ export const ACTION_LABELS: Partial<Record<ChatInputOption, string>> = {
16
16
  NOTE: "Add note",
17
17
  FORWARD: "Forward",
18
18
  };
19
+
20
+ export const REPLY_ACTION_LABEL = "Send reply";
21
+
22
+ export const OPTION_LABELS: Record<ChatInputOption, string> = {
23
+ REPLY: "Reply",
24
+ NOTE: "Note",
25
+ FORWARD: "Forward",
26
+ };
27
+
28
+ export const NOTE_HINT = "Internal note, not sent to the customer";
@@ -1,8 +1,11 @@
1
1
  export {
2
2
  ChatInput,
3
+ type ChatInputFocusTarget,
4
+ type ChatInputLayout,
3
5
  type ChatInputPayload,
4
6
  type ChatInputProps,
5
7
  type ChatInputRef,
8
+ type ChatInputResizable,
6
9
  } from "./ChatInput";
7
10
  export { OPTION_TYPES, type ChatInputOption } from "./constants";
8
11
  export {
@@ -89,3 +89,25 @@ export const convertMentionsToHTMLAndPlainText = ({
89
89
 
90
90
  return { html, plainText };
91
91
  };
92
+
93
+ const HTML_ENTITIES: Record<string, string> = {
94
+ amp: "&",
95
+ lt: "<",
96
+ gt: ">",
97
+ quot: '"',
98
+ "#39": "'",
99
+ nbsp: " ",
100
+ };
101
+
102
+ /** Block ends become line breaks, so a canned reply keeps its paragraphs. */
103
+ export const htmlToPlainText = (html: string) =>
104
+ html
105
+ .replace(/<br\s*\/?>/gi, "\n")
106
+ .replace(/<\/(p|div|li|h[1-6]|blockquote|pre)>/gi, "\n")
107
+ .replace(/<[^>]*>/g, "")
108
+ .replace(
109
+ /&(amp|lt|gt|quot|#39|nbsp);/g,
110
+ (_, entity: string) => HTML_ENTITIES[entity] ?? ""
111
+ )
112
+ .replace(/\n{3,}/g, "\n\n")
113
+ .replace(/\n+$/, "");
@@ -0,0 +1,19 @@
1
+ import { forwardRef } from "react";
2
+
3
+ import type * as Editor from "./RichTextEditor";
4
+ import type { RichTextEditorProps, RichTextEditorRef } from "./RichTextEditor";
5
+
6
+ // tentap's native entry reads `document` when evaluated. Metro's inline
7
+ // requires defer that on device, but an app's jest run imports the package
8
+ // root eagerly, so the editor module loads only once one renders.
9
+ export const RichTextEditor = forwardRef<
10
+ RichTextEditorRef,
11
+ RichTextEditorProps
12
+ >((props, ref) => {
13
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
14
+ const { RichTextEditor: Impl } = require("./RichTextEditor") as typeof Editor;
15
+
16
+ return <Impl ref={ref} {...props} />;
17
+ });
18
+
19
+ RichTextEditor.displayName = "LazyRichTextEditor";
@@ -0,0 +1,79 @@
1
+ import { ScrollView } from "react-native";
2
+
3
+ import { StyleSheet } from "react-native-unistyles";
4
+
5
+ import { scaleFor } from "../../utils/scale";
6
+ import { Avatar } from "../Avatar";
7
+ import type { MentionSuggestion } from "../ChatInput/mentions";
8
+ import { Touchable } from "../Touchable";
9
+ import { Typography } from "../Typography";
10
+
11
+ type MentionSuggestionBarProps = {
12
+ suggestions: MentionSuggestion[];
13
+ onSelect: (suggestion: MentionSuggestion) => void;
14
+ };
15
+
16
+ export const MentionSuggestionBar = ({
17
+ suggestions,
18
+ onSelect,
19
+ }: MentionSuggestionBarProps) => (
20
+ <ScrollView
21
+ horizontal
22
+ contentContainerStyle={styles.chips}
23
+ keyboardShouldPersistTaps="always"
24
+ showsHorizontalScrollIndicator={false}
25
+ style={styles.bar}
26
+ testID="mention-suggestions"
27
+ >
28
+ {suggestions.map(suggestion => (
29
+ <Touchable
30
+ accessibilityLabel={`Mention ${suggestion.name}`}
31
+ accessibilityRole="button"
32
+ key={suggestion.id}
33
+ style={styles.chip}
34
+ onPress={() => onSelect(suggestion)}
35
+ >
36
+ <Avatar
37
+ imageUrl={suggestion.imageUrl}
38
+ name={suggestion.name}
39
+ variant="extra-small"
40
+ />
41
+ <Typography numberOfLines={1} size="xs" style={styles.name}>
42
+ {suggestion.name}
43
+ </Typography>
44
+ </Touchable>
45
+ ))}
46
+ </ScrollView>
47
+ );
48
+
49
+ const styles = StyleSheet.create((theme, rt) => {
50
+ const moderateScale = scaleFor(rt.screen);
51
+
52
+ return {
53
+ bar: {
54
+ borderBottomColor: theme.colors.border.primary,
55
+ borderBottomWidth: 1,
56
+ borderTopColor: theme.colors.border.primary,
57
+ borderTopWidth: 1,
58
+ flexGrow: 0,
59
+ },
60
+ chips: {
61
+ alignItems: "center",
62
+ gap: moderateScale(8),
63
+ minHeight: 44,
64
+ paddingHorizontal: moderateScale(8),
65
+ },
66
+ chip: {
67
+ alignItems: "center",
68
+ backgroundColor: theme.colors.background.secondary,
69
+ borderRadius: moderateScale(16),
70
+ flexDirection: "row",
71
+ gap: moderateScale(6),
72
+ paddingHorizontal: moderateScale(8),
73
+ paddingVertical: moderateScale(4),
74
+ },
75
+ name: {
76
+ maxWidth: moderateScale(180),
77
+ },
78
+ };
79
+ });
@@ -0,0 +1,213 @@
1
+ import {
2
+ forwardRef,
3
+ useEffect,
4
+ useImperativeHandle,
5
+ useMemo,
6
+ useRef,
7
+ } from "react";
8
+ import { Platform, View, type StyleProp, type ViewStyle } from "react-native";
9
+
10
+ import {
11
+ CoreBridge,
12
+ PlaceholderBridge,
13
+ RichText,
14
+ useBridgeState,
15
+ useEditorBridge,
16
+ } from "@10play/tentap-editor";
17
+ import { StyleSheet, useUnistyles } from "react-native-unistyles";
18
+
19
+ import { filterMentionSuggestions } from "../ChatInput/MentionSuggestionList";
20
+ import type { MentionSuggestion } from "../ChatInput/mentions";
21
+ import { editorBridges } from "./bridges";
22
+ import type { NeetoBridgeState, NeetoEditorBridge } from "./bridges/types";
23
+ import { editorCss } from "./editorCss";
24
+ import { injectableText } from "./injectableText";
25
+ import { MentionSuggestionBar } from "./MentionSuggestionBar";
26
+ import { RichTextToolbar } from "./RichTextToolbar";
27
+ import { editorHtml } from "./web/build/editorHtml";
28
+
29
+ export interface RichTextEditorRef {
30
+ getHTML: () => Promise<string>;
31
+ getText: () => Promise<string>;
32
+ setHTML: (html: string) => void;
33
+ insertHTML: (html: string) => void;
34
+ focus: (where?: "start" | "end" | "emptyParagraph") => void;
35
+ blur: () => void;
36
+ }
37
+
38
+ export interface RichTextEditorProps {
39
+ initialHTML?: string;
40
+ placeholder?: string;
41
+ editable?: boolean;
42
+ autoFocus?: boolean;
43
+ /** Fires on every content change; read HTML lazily through the ref. */
44
+ onChange?: () => void;
45
+ onFocus?: () => void;
46
+ onBlur?: () => void;
47
+ /** Default "fixed". */
48
+ toolbar?: "fixed" | "none";
49
+ /**
50
+ * Trailing part of initialHTML (a signature). The first focus puts the
51
+ * cursor just above it, as desk-web does.
52
+ */
53
+ trailingHTML?: string;
54
+ /** Edit {{variables}} as chips (signature editors); replies keep them as text. */
55
+ variables?: boolean;
56
+ /** Palette for the text colour menu. */
57
+ colors?: string[];
58
+ /** People offered while an "@name" is typed; inserted as mention chips. */
59
+ mentions?: MentionSuggestion[];
60
+ /** Behind the text; defaults to the theme's primary background. */
61
+ backgroundColor?: string;
62
+ style?: StyleProp<ViewStyle>;
63
+ testID?: string;
64
+ }
65
+
66
+ export const RichTextEditor = forwardRef<
67
+ RichTextEditorRef,
68
+ RichTextEditorProps
69
+ >(
70
+ (
71
+ {
72
+ initialHTML,
73
+ placeholder,
74
+ editable = true,
75
+ autoFocus = false,
76
+ onChange,
77
+ onFocus,
78
+ onBlur,
79
+ toolbar = "fixed",
80
+ colors,
81
+ mentions = [],
82
+ backgroundColor,
83
+ variables = false,
84
+ trailingHTML,
85
+ style,
86
+ testID,
87
+ },
88
+ ref
89
+ ) => {
90
+ const { theme } = useUnistyles();
91
+ const palette = useMemo(
92
+ () => colors ?? defaultColors(theme.colors.web),
93
+ [colors, theme.colors.web]
94
+ );
95
+
96
+ const editor = useEditorBridge({
97
+ customSource: editorHtml,
98
+ bridgeExtensions: [
99
+ ...editorBridges({ variables }),
100
+ CoreBridge.configureCSS(editorCss(theme, backgroundColor)),
101
+ PlaceholderBridge.configureExtension({
102
+ placeholder: placeholder && injectableText(placeholder),
103
+ }),
104
+ ],
105
+ initialContent: initialHTML,
106
+ editable,
107
+ autofocus: autoFocus,
108
+ avoidIosKeyboard: false,
109
+ webviewBaseURL:
110
+ Platform.OS === "android" ? "file:///android_asset/" : undefined,
111
+ onChange,
112
+ }) as NeetoEditorBridge;
113
+
114
+ const { isFocused, mentionQuery } = useBridgeState(
115
+ editor
116
+ ) as NeetoBridgeState;
117
+ // Android's WebView reports isFocused false while typing, so the caret's
118
+ // "@word" alone decides.
119
+ const mentionMatches = mentionQuery
120
+ ? filterMentionSuggestions(
121
+ mentions,
122
+ mentionQuery.query,
123
+ MAX_MENTION_CHIPS
124
+ )
125
+ : [];
126
+ const wasFocused = useRef(false);
127
+ const hasPlacedCursor = useRef(false);
128
+
129
+ useEffect(() => {
130
+ if (isFocused === wasFocused.current) return;
131
+
132
+ wasFocused.current = isFocused;
133
+ if (isFocused && trailingHTML && !hasPlacedCursor.current) {
134
+ hasPlacedCursor.current = true;
135
+ editor.focusBeforeTrailing(trailingHTML);
136
+ }
137
+ (isFocused ? onFocus : onBlur)?.();
138
+ }, [editor, isFocused, onFocus, onBlur, trailingHTML]);
139
+
140
+ useImperativeHandle(
141
+ ref,
142
+ () => ({
143
+ getHTML: () => editor.getHTML(),
144
+ getText: () => editor.getText(),
145
+ setHTML: html => {
146
+ hasPlacedCursor.current = false;
147
+ editor.setContent(html);
148
+ },
149
+ insertHTML: html => editor.insertHTML(html),
150
+ focus: where =>
151
+ where === "emptyParagraph"
152
+ ? editor.focusEmptyParagraph()
153
+ : editor.focus(where ?? "end"),
154
+ blur: () => editor.blur(),
155
+ }),
156
+ [editor]
157
+ );
158
+
159
+ return (
160
+ <View
161
+ style={[
162
+ styles.root,
163
+ backgroundColor ? { backgroundColor } : null,
164
+ style,
165
+ ]}
166
+ testID={testID}
167
+ >
168
+ <View style={styles.content} testID="rich-text-content">
169
+ <RichText editor={editor} />
170
+ </View>
171
+ {mentionQuery && mentionMatches.length > 0 ? (
172
+ // In the toolbar's slot, like a keyboard's suggestion strip: the
173
+ // phone composer has no height to spare above the keyboard.
174
+ <MentionSuggestionBar
175
+ suggestions={mentionMatches}
176
+ onSelect={({ id, name }) =>
177
+ editor.insertMention({ id, label: name }, mentionQuery)
178
+ }
179
+ />
180
+ ) : (
181
+ toolbar === "fixed" && (
182
+ <RichTextToolbar colors={palette} editor={editor} />
183
+ )
184
+ )}
185
+ </View>
186
+ );
187
+ }
188
+ );
189
+
190
+ RichTextEditor.displayName = "RichTextEditor";
191
+
192
+ const MAX_MENTION_CHIPS = 10;
193
+
194
+ const defaultColors = (web: Record<string, string>) =>
195
+ [
196
+ web.error500,
197
+ web.warning500,
198
+ web.success500,
199
+ web.info500,
200
+ web.primary500,
201
+ web.accent500,
202
+ web.gray500,
203
+ ].filter((color): color is string => !!color);
204
+
205
+ const styles = StyleSheet.create(theme => ({
206
+ root: {
207
+ backgroundColor: theme.colors.background.primary,
208
+ flex: 1,
209
+ },
210
+ content: {
211
+ flex: 1,
212
+ },
213
+ }));