Skip to content

Commit ef306d3

Browse files
authored
Merge pull request Expensify#87247 from callstack-internal/decompose/composer-pr1-context
[No QA] decomp-composer 1: add composer's context
2 parents bbd13e9 + a7bd996 commit ef306d3

3 files changed

Lines changed: 410 additions & 0 deletions

File tree

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import type {RefObject} from 'react';
2+
import {createContext, useContext} from 'react';
3+
import type {BlurEvent, TextInputSelectionChangeEvent, View} from 'react-native';
4+
import type {Emoji} from '@assets/emojis/types';
5+
import type {Mention} from '@components/MentionSuggestions';
6+
import type {FileObject} from '@src/types/utils/Attachment';
7+
import type {ComposerRef} from './ComposerWithSuggestions/ComposerWithSuggestions';
8+
9+
type SuggestionsRef = {
10+
resetSuggestions: () => void;
11+
onSelectionChange?: (event: TextInputSelectionChangeEvent) => void;
12+
triggerHotkeyActions: (event: KeyboardEvent) => boolean | undefined;
13+
updateShouldShowSuggestionMenuToFalse: (shouldShowSuggestionMenu?: boolean) => void;
14+
setShouldBlockSuggestionCalc: (shouldBlock: boolean) => void;
15+
getSuggestions: () => Mention[] | Emoji[];
16+
getIsSuggestionsMenuVisible: () => boolean;
17+
};
18+
19+
// Hot — changes on every keystroke
20+
type ComposerText = string;
21+
22+
// Warm — changes on interaction
23+
type ComposerState = {
24+
isFocused: boolean;
25+
isMenuVisible: boolean;
26+
isFullComposerAvailable: boolean;
27+
};
28+
29+
// Warm — changes based on content + policy
30+
type ComposerSendState = {
31+
isSendDisabled: boolean;
32+
exceededMaxLength: number | null;
33+
hasExceededMaxTaskTitleLength: boolean;
34+
isBlockedFromConcierge: boolean;
35+
};
36+
37+
// Frozen — stable references, never changes after mount
38+
type ComposerActions = {
39+
setValue: (v: string) => void;
40+
setMenuVisibility: (v: boolean) => void;
41+
setIsFullComposerAvailable: (v: boolean) => void;
42+
setComposerRef: (ref: ComposerRef | null) => void;
43+
focus: () => void;
44+
onBlur: (event: BlurEvent) => void;
45+
onFocus: () => void;
46+
onAddActionPressed: () => void;
47+
onItemSelected: () => void;
48+
onTriggerAttachmentPicker: () => void;
49+
clearComposer: () => void;
50+
};
51+
52+
// Infrequent — changes only when send logic changes
53+
type ComposerSendActions = {
54+
handleSendMessage: () => void;
55+
onValueChange: (value: string) => void;
56+
};
57+
58+
// Frozen — stable refs, set once
59+
type ComposerMeta = {
60+
containerRef: RefObject<View | null>;
61+
composerRef: RefObject<ComposerRef | null>;
62+
suggestionsRef: RefObject<SuggestionsRef | null>;
63+
actionButtonRef: RefObject<View | HTMLDivElement | null>;
64+
isNextModalWillOpenRef: RefObject<boolean>;
65+
attachmentFileRef: RefObject<FileObject | FileObject[] | null>;
66+
};
67+
68+
const noop = () => {};
69+
70+
const ComposerTextContext = createContext<ComposerText>('');
71+
72+
const defaultState: ComposerState = {
73+
isFocused: false,
74+
isMenuVisible: false,
75+
isFullComposerAvailable: false,
76+
};
77+
const ComposerStateContext = createContext<ComposerState>(defaultState);
78+
79+
const defaultSendState: ComposerSendState = {
80+
isSendDisabled: true,
81+
exceededMaxLength: null,
82+
hasExceededMaxTaskTitleLength: false,
83+
isBlockedFromConcierge: false,
84+
};
85+
const ComposerSendStateContext = createContext<ComposerSendState>(defaultSendState);
86+
87+
const defaultActions: ComposerActions = {
88+
setValue: noop,
89+
setMenuVisibility: noop,
90+
setIsFullComposerAvailable: noop,
91+
setComposerRef: noop,
92+
focus: noop,
93+
onBlur: noop,
94+
onFocus: noop,
95+
onAddActionPressed: noop,
96+
onItemSelected: noop,
97+
onTriggerAttachmentPicker: noop,
98+
clearComposer: noop,
99+
};
100+
const ComposerActionsContext = createContext<ComposerActions>(defaultActions);
101+
102+
const defaultSendActions: ComposerSendActions = {
103+
handleSendMessage: noop,
104+
onValueChange: noop,
105+
};
106+
const ComposerSendActionsContext = createContext<ComposerSendActions>(defaultSendActions);
107+
108+
const ComposerMetaContext = createContext<ComposerMeta | null>(null);
109+
110+
function useComposerText() {
111+
return useContext(ComposerTextContext);
112+
}
113+
114+
function useComposerState() {
115+
return useContext(ComposerStateContext);
116+
}
117+
118+
function useComposerSendState() {
119+
return useContext(ComposerSendStateContext);
120+
}
121+
122+
function useComposerActions() {
123+
return useContext(ComposerActionsContext);
124+
}
125+
126+
function useComposerSendActions() {
127+
return useContext(ComposerSendActionsContext);
128+
}
129+
130+
function useComposerMeta() {
131+
const ctx = useContext(ComposerMetaContext);
132+
if (!ctx) {
133+
throw new Error('useComposerMeta must be used inside ComposerProvider');
134+
}
135+
return ctx;
136+
}
137+
138+
export {
139+
ComposerTextContext,
140+
ComposerStateContext,
141+
ComposerSendStateContext,
142+
ComposerActionsContext,
143+
ComposerSendActionsContext,
144+
ComposerMetaContext,
145+
useComposerText,
146+
useComposerState,
147+
useComposerSendState,
148+
useComposerActions,
149+
useComposerSendActions,
150+
useComposerMeta,
151+
};
152+
export type {SuggestionsRef, ComposerText, ComposerState, ComposerSendState, ComposerActions, ComposerSendActions, ComposerMeta};
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import lodashDebounce from 'lodash/debounce';
2+
import React, {useRef, useState} from 'react';
3+
import type {View} from 'react-native';
4+
import {useSharedValue} from 'react-native-reanimated';
5+
import {scheduleOnUI} from 'react-native-worklets';
6+
import useHandleExceedMaxCommentLength from '@hooks/useHandleExceedMaxCommentLength';
7+
import useHandleExceedMaxTaskTitleLength from '@hooks/useHandleExceedMaxTaskTitleLength';
8+
import useOnyx from '@hooks/useOnyx';
9+
import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus';
10+
import {chatIncludesConcierge} from '@libs/ReportUtils';
11+
import {setIsComposerFullSize} from '@userActions/Report';
12+
import {isBlockedFromConcierge as isBlockedFromConciergeUserAction} from '@userActions/User';
13+
import CONST from '@src/CONST';
14+
import ONYXKEYS from '@src/ONYXKEYS';
15+
import type {FileObject} from '@src/types/utils/Attachment';
16+
import {ComposerActionsContext, ComposerMetaContext, ComposerSendActionsContext, ComposerSendStateContext, ComposerStateContext, ComposerTextContext} from './ComposerContext';
17+
import type {SuggestionsRef} from './ComposerContext';
18+
import type {ComposerRef} from './ComposerWithSuggestions/ComposerWithSuggestions';
19+
import useComposerFocus from './useComposerFocus';
20+
21+
const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus();
22+
23+
type ComposerProviderProps = {
24+
reportID: string;
25+
children: React.ReactNode;
26+
};
27+
28+
function ComposerProvider({children, reportID}: ComposerProviderProps) {
29+
const [blockedFromConcierge] = useOnyx(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE);
30+
const [shouldShowComposeInput = true] = useOnyx(ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT);
31+
const [initialModalState] = useOnyx(ONYXKEYS.MODAL);
32+
const [draftComment] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`);
33+
const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`);
34+
const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`);
35+
36+
const shouldFocusComposerOnScreenFocus = shouldFocusInputOnScreenFocus || !!draftComment;
37+
38+
const initialFocused = shouldFocusComposerOnScreenFocus && shouldShowComposeInput && !initialModalState?.isVisible && !initialModalState?.willAlertModalBecomeVisible;
39+
40+
const [isFullComposerAvailable, setIsFullComposerAvailable] = useState(isComposerFullSize);
41+
const [isMenuVisible, setMenuVisibility] = useState(false);
42+
43+
const [value, setValue] = useState(() => {
44+
return draftComment ?? '';
45+
});
46+
47+
const isEmpty = !value || !!value.match(CONST.REGEX.EMPTY_COMMENT);
48+
49+
const includesConcierge = chatIncludesConcierge({participants: report?.participants});
50+
const userBlockedFromConcierge = isBlockedFromConciergeUserAction(blockedFromConcierge);
51+
const isBlockedFromConcierge = includesConcierge && userBlockedFromConcierge;
52+
53+
const {hasExceededMaxCommentLength, validateCommentMaxLength, setHasExceededMaxCommentLength} = useHandleExceedMaxCommentLength();
54+
const {hasExceededMaxTaskTitleLength, validateTaskTitleMaxLength, setHasExceededMaxTitleLength} = useHandleExceedMaxTaskTitleLength();
55+
56+
let exceededMaxLength: number | null = null;
57+
if (hasExceededMaxTaskTitleLength) {
58+
exceededMaxLength = CONST.TITLE_CHARACTER_LIMIT;
59+
} else if (hasExceededMaxCommentLength) {
60+
exceededMaxLength = CONST.MAX_COMMENT_LENGTH;
61+
}
62+
63+
const isSendDisabled = isEmpty || isBlockedFromConcierge || !!exceededMaxLength;
64+
65+
const validateMaxLength = (v: string) => {
66+
const taskCommentMatch = v?.match(CONST.REGEX.TASK_TITLE_WITH_OPTIONAL_SHORT_MENTION);
67+
if (taskCommentMatch) {
68+
const title = taskCommentMatch?.[3] ? taskCommentMatch[3].trim().replaceAll('\n', ' ') : '';
69+
setHasExceededMaxCommentLength(false);
70+
return validateTaskTitleMaxLength(title);
71+
}
72+
setHasExceededMaxTitleLength(false);
73+
return validateCommentMaxLength(v, {reportID});
74+
};
75+
76+
const debouncedValidate = lodashDebounce(validateMaxLength, CONST.TIMING.COMMENT_LENGTH_DEBOUNCE_TIME, {leading: true});
77+
78+
const containerRef = useRef<View>(null);
79+
const suggestionsRef = useRef<SuggestionsRef>(null);
80+
const composerRef = useRef<ComposerRef | null>(null);
81+
const actionButtonRef = useRef<View | HTMLDivElement | null>(null);
82+
const attachmentFileRef = useRef<FileObject | FileObject[] | null>(null);
83+
84+
const composerRefShared = useSharedValue<Partial<ComposerRef>>({});
85+
86+
const {isFocused, onBlur, onFocus, focus, onAddActionPressed, onItemSelected, onTriggerAttachmentPicker, isNextModalWillOpenRef} = useComposerFocus({
87+
composerRef,
88+
suggestionsRef,
89+
actionButtonRef,
90+
initialFocused,
91+
});
92+
93+
const clearComposer = () => {
94+
const clearWorklet = composerRefShared.get().clearWorklet;
95+
if (!clearWorklet) {
96+
throw new Error('The composerRef.clearWorklet function is not set yet. This should never happen, and indicates a developer error.');
97+
}
98+
scheduleOnUI(clearWorklet);
99+
};
100+
101+
const handleSendMessage = () => {
102+
if (isSendDisabled || !debouncedValidate.flush()) {
103+
return;
104+
}
105+
106+
composerRef.current?.resetHeight();
107+
if (isComposerFullSize) {
108+
setIsComposerFullSize(reportID, false);
109+
}
110+
111+
scheduleOnUI(() => {
112+
const {clearWorklet} = composerRefShared.get();
113+
114+
if (!clearWorklet) {
115+
throw new Error('The composerRef.clearWorklet function is not set yet. This should never happen, and indicates a developer error.');
116+
}
117+
118+
clearWorklet?.();
119+
});
120+
};
121+
122+
const setComposerRef = (ref: ComposerRef | null) => {
123+
composerRef.current = ref;
124+
composerRefShared.set({
125+
clearWorklet: ref?.clearWorklet,
126+
});
127+
};
128+
129+
const onValueChange = (v: string) => {
130+
if (v.length === 0 && isComposerFullSize) {
131+
setIsComposerFullSize(reportID, false);
132+
}
133+
debouncedValidate(v);
134+
};
135+
136+
const text = value;
137+
138+
const composerState = {
139+
isFocused,
140+
isMenuVisible,
141+
isFullComposerAvailable,
142+
};
143+
144+
const composerSendState = {
145+
isSendDisabled,
146+
exceededMaxLength,
147+
hasExceededMaxTaskTitleLength,
148+
isBlockedFromConcierge,
149+
};
150+
151+
const composerActions = {
152+
setValue,
153+
setMenuVisibility,
154+
setIsFullComposerAvailable,
155+
setComposerRef,
156+
focus,
157+
onBlur,
158+
onFocus,
159+
onAddActionPressed,
160+
onItemSelected,
161+
onTriggerAttachmentPicker,
162+
clearComposer,
163+
};
164+
165+
const composerSendActions = {
166+
handleSendMessage,
167+
onValueChange,
168+
};
169+
170+
const composerMeta = {
171+
containerRef,
172+
composerRef,
173+
suggestionsRef,
174+
actionButtonRef,
175+
isNextModalWillOpenRef,
176+
attachmentFileRef,
177+
};
178+
179+
return (
180+
<ComposerTextContext.Provider value={text}>
181+
<ComposerStateContext.Provider value={composerState}>
182+
<ComposerSendStateContext.Provider value={composerSendState}>
183+
<ComposerActionsContext.Provider value={composerActions}>
184+
<ComposerSendActionsContext.Provider value={composerSendActions}>
185+
<ComposerMetaContext.Provider value={composerMeta}>{children}</ComposerMetaContext.Provider>
186+
</ComposerSendActionsContext.Provider>
187+
</ComposerActionsContext.Provider>
188+
</ComposerSendStateContext.Provider>
189+
</ComposerStateContext.Provider>
190+
</ComposerTextContext.Provider>
191+
);
192+
}
193+
194+
export default ComposerProvider;
195+
export type {ComposerProviderProps};

0 commit comments

Comments
 (0)