Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 59 additions & 9 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/n
import type { ReactNode } from "react";
import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from "react";
import { Keyboard, Platform, View, type ViewStyle } from "react-native";
import { KeyboardController, KeyboardEvents } from "react-native-keyboard-controller";
import { composerImageGallery } from "../../lib/composerImageGallery";
import { filePreviewGallery } from "../../lib/filePreviewGallery";
import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
Expand Down Expand Up @@ -140,9 +141,19 @@ function collapsedEditorInset(text: ScaledTextRole): number {
return Math.max(0, Math.round(COLLAPSED_EDITOR_DEFAULT_INSET - shift));
}

export function useCompactComposerEditor(text: ScaledTextRole, value: string) {
export function useCompactComposerEditor(
text: ScaledTextRole,
value: string,
opensOnFocus: boolean,
yieldsModelLabel: boolean,
) {
const [contentSize, setContentSize] = useState({ width: 0, height: 0 });
const [compactWidth, setCompactWidth] = useState(0);
const [compactSlot, setCompactSlot] = useState({ width: 0, labelYielded: false });
const [labelYielded, setLabelYielded] = useState(false);
const [measuredRowWidth, setMeasuredRowWidth] = useState(0);
if (labelYielded && value.length === 0) setLabelYielded(false);
const compactWidth =
!yieldsModelLabel || compactSlot.labelYielded === labelYielded ? compactSlot.width : 0;
const contentHeight = contentSize.height;
const rowWidth = useSharedValue(0);
const editorX = useSharedValue(0);
Expand All @@ -157,9 +168,16 @@ export function useCompactComposerEditor(text: ScaledTextRole, value: string) {
// A line that fits the expanded editor may still overflow its compact slot.
// Compare its measured width too so widening cannot trigger a collapse loop.
const isExpanded =
opensOnFocus ||
(value.length > 0 &&
(contentHeight > text.lineHeight * 1.5 ||
(compactWidth > 0 && contentSize.width > compactWidth)));
const nearsModelLabel =
yieldsModelLabel &&
value.length > 0 &&
(contentHeight > text.lineHeight * 1.5 ||
(compactWidth > 0 && contentSize.width > compactWidth));
compactWidth > 0 &&
contentSize.width > compactWidth - text.fontSize;
if (nearsModelLabel && !labelYielded) setLabelYielded(true);
const compactRowHeight = Math.max(44, minimum);
const animatedHeight = useSharedValue(height);
const surfaceHeight = useSharedValue(compactRowHeight);
Expand Down Expand Up @@ -202,18 +220,24 @@ export function useCompactComposerEditor(text: ScaledTextRole, value: string) {
}));
return {
isExpanded,
labelYielded: labelYielded && value.length > 0,
containerStyle,
editorStyle,
onRowLayout: (event: { nativeEvent: { layout: { width: number } } }) => {
rowWidth.set(event.nativeEvent.layout.width);
const width = event.nativeEvent.layout.width;
rowWidth.set(width);
if (width !== measuredRowWidth) {
setMeasuredRowWidth(width);
setCompactSlot({ width: 0, labelYielded });
}
},
onEditorLayout: (event: { nativeEvent: { layout: { x: number; width: number } } }) => {
// The expanded toolbar gives this slot to the model control. Keep the
// compact geometry for the return transition, rather than recording zero.
if (!isExpanded) {
editorX.set(event.nativeEvent.layout.x);
editorWidth.set(event.nativeEvent.layout.width);
setCompactWidth(Math.max(0, event.nativeEvent.layout.width - 8));
setCompactSlot({ width: Math.max(0, event.nativeEvent.layout.width - 8), labelYielded });
}
},
editorProps: {
Expand Down Expand Up @@ -439,6 +463,19 @@ export function ComposerSurface(props: {
);
}

function useKeyboardShowing() {
const [showing, setShowing] = useState(KeyboardController.isVisible);
useEffect(() => {
const show = KeyboardEvents.addListener("keyboardWillShow", () => setShowing(true));
const hide = KeyboardEvents.addListener("keyboardWillHide", () => setShowing(false));
return () => {
show.remove();
hide.remove();
};
}, []);
return showing;
}

/** Space below the composer pill, inside the bottom inset it is given. */
export const THREAD_COMPOSER_BOTTOM_GUTTER = 6;

Expand All @@ -452,6 +489,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
const fallbackInputRef = useRef<ComposerEditorHandle>(null);
const inputRef = props.editorRef ?? fallbackInputRef;
const [isFocused, setIsFocused] = useState(false);
const keyboardVisible = useKeyboardShowing();
const pendingPastedTextAttachmentCountRef = useRef(0);
const pendingNativeImagePasteCountRef = useRef(0);
const [pendingPastedTextAttachmentCount, setPendingPastedTextAttachmentCount] = useState(0);
Expand Down Expand Up @@ -600,8 +638,17 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
const { setComposerEditing } = useAdaptiveWorkspaceLayout();
const hasSidebarTabBar = useSidebarWorkspaceTabs() === "picker";
const [compactRowWidth, setCompactRowWidth] = useState(0);
const compactAttachmentLimit = compactRowWidth >= 500 ? 3 : 1;
const compactEditor = useCompactComposerEditor(bodyText, draftMessage);
const isWideRow = compactRowWidth >= 500;
const compactAttachmentLimit = isWideRow ? 3 : 1;
const opensCard =
(isFocused && (Platform.OS !== "android" || keyboardVisible)) ||
settingsSheetPresentation.keepsComposerExpanded;
const compactEditor = useCompactComposerEditor(
bodyText,
draftMessage,
opensCard && compactRowWidth > 0 && !isWideRow,
isWideRow,
);
const showsCompactDictation = isVoiceInputPresented;
const isToolbarVisible = isVoiceInputPresented;
const hasEditingAttachments =
Expand Down Expand Up @@ -859,7 +906,10 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
reportedSelection={props.reportedModelSelection}
prompt={draftMessage}
surfaceColor={composerPanel}
iconOnly={!compactEditor.isExpanded}
iconOnly={
!compactEditor.isExpanded &&
(!isWideRow || compactEditor.labelYielded || stripAttachments.length > 0)
}
onPress={openSettings}
/>
</View>
Expand Down
Loading