Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

clean up bare chat input #4314

Merged
merged 1 commit into from
Jan 8, 2025
Merged
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
clean up bare chat input
  • Loading branch information
patosullivan committed Jan 7, 2025
commit df107ff439e8f0e35b25083d1902ea2c614499fe
291 changes: 142 additions & 149 deletions packages/ui/src/components/BareChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,132 @@ import { useMentions } from './useMentions';

const bareChatInputLogger = createDevLogger('bareChatInput', false);

const DEFAULT_KEYBOARD_HEIGHT = 300;

function useKeyboardHeight(maxInputHeightBasic: number) {
const [maxInputHeight, setMaxInputHeight] = useState(maxInputHeightBasic);

useEffect(() => {
const handleKeyboardShow = () => {
const keyboardHeight =
Keyboard.metrics()?.height || DEFAULT_KEYBOARD_HEIGHT;
setMaxInputHeight(maxInputHeightBasic - keyboardHeight);
};

const handleKeyboardHide = () => {
setMaxInputHeight(maxInputHeightBasic);
};

const showSubscription = Keyboard.addListener(
'keyboardDidShow',
handleKeyboardShow
);
const hideSubscription = Keyboard.addListener(
'keyboardDidHide',
handleKeyboardHide
);

return () => {
showSubscription.remove();
hideSubscription.remove();
};
}, [maxInputHeightBasic]);

return maxInputHeight;
}

function usePasteHandler(addAttachment: (attachment: Attachment) => void) {
// For now, we only check to make sure we're on web,
// we don't check if the input is focused. This allows users to paste
// images before they select the input. We may want to change this behavior
// if this feels weird, but it feels like a nice quality of life improvement.
// We can do this because there is only ever one input on the screen at a time,
// unlike the old app where you could have both the main chat input and the
// thread input on screen at the same time.
useEffect(() => {
if (!isWeb) return;

const handlePaste = async (e: ClipboardEvent) => {
const items = Array.from(e.clipboardData?.items || []);
const image = items.find((item) => item.type.includes('image'));

if (!image) return;

const file = image.getAsFile();
if (!file) return;

const uri = URL.createObjectURL(file);
const img = new Image();

img.onload = () => {
addAttachment({
type: 'image',
file: {
uri,
height: img.height,
width: img.width,
},
});
};

img.src = uri;
};

document.addEventListener('paste', handlePaste);
return () => document.removeEventListener('paste', handlePaste);
}, [addAttachment]);
}

interface TextWithMentionsProps {
text: string;
mentions: Array<{ start: number; end: number; display: string; id: string }>;
textColor: string;
}

function TextWithMentions({
text,
mentions,
textColor,
}: TextWithMentionsProps) {
if (!text || mentions.length === 0) {
return <RawText color={textColor}>{text}</RawText>;
}

const sortedMentions = [...mentions].sort((a, b) => a.start - b.start);
const textParts: JSX.Element[] = [];

if (sortedMentions[0].start > 0) {
textParts.push(
<RawText key="text-start" color={textColor}>
{text.slice(0, sortedMentions[0].start)}
</RawText>
);
}

sortedMentions.forEach((mention, index) => {
textParts.push(
<Text
key={`mention-${mention.id}-${index}`}
color="$positiveActionText"
backgroundColor="$positiveBackground"
>
{mention.display}
</Text>
);

const nextStart = sortedMentions[index + 1]?.start ?? text.length;
if (mention.end < nextStart) {
textParts.push(
<RawText key={`text-${index}`} color={textColor}>
{text.slice(mention.end, nextStart)}
</RawText>
);
}
});

return <>{textParts}</>;
}

export default function BareChatInput({
shouldBlur,
setShouldBlur,
Expand Down Expand Up @@ -102,9 +228,11 @@ export default function BareChatInput({
setMentions,
showMentionPopup,
} = useMentions();
const [maxInputHeight, setMaxInputHeight] = useState(maxInputHeightBasic);
const maxInputHeight = useKeyboardHeight(maxInputHeightBasic);
const inputRef = useRef<TextInput>(null);

usePasteHandler(addAttachment);

const processReferences = useCallback(
(text: string): string => {
const references = text.match(REF_REGEX);
Expand Down Expand Up @@ -194,94 +322,6 @@ export default function BareChatInput({
[handleSelectMention, controlledText]
);

const renderTextWithMentionsWeb = useMemo(() => {
if (!controlledText || mentions.length === 0) {
return null;
}

const sortedMentions = [...mentions].sort((a, b) => a.start - b.start);
const textParts: JSX.Element[] = [];

// Handle text before first mention
if (sortedMentions[0].start > 0) {
textParts.push(
<RawText key="text-start" color="transparent">
{controlledText.slice(0, sortedMentions[0].start)}
</RawText>
);
}

// Handle mentions and text between them
sortedMentions.forEach((mention, index) => {
textParts.push(
<Text
key={`mention-${mention.id}-${index}`}
color="$positiveActionText"
backgroundColor="$positiveBackground"
>
{mention.display}
</Text>
);

// Add text between this mention and the next one (or end of text)
const nextStart =
sortedMentions[index + 1]?.start ?? controlledText.length;
if (mention.end < nextStart) {
textParts.push(
<RawText key={`text-${index}`} color="transparent">
{controlledText.slice(mention.end, nextStart)}
</RawText>
);
}
});

return textParts;
}, [mentions, controlledText]);

const renderTextWithMentions = useMemo(() => {
if (!controlledText || mentions.length === 0) {
return <RawText color="$primaryText">{controlledText}</RawText>;
}

const sortedMentions = [...mentions].sort((a, b) => a.start - b.start);
const textParts: JSX.Element[] = [];

// Handle text before first mention
if (sortedMentions[0].start > 0) {
textParts.push(
<RawText key="text-start" color="$primaryText">
{controlledText.slice(0, sortedMentions[0].start)}
</RawText>
);
}

// Handle mentions and text between them
sortedMentions.forEach((mention, index) => {
textParts.push(
<Text
key={`mention-${mention.id}-${index}`}
color="$positiveActionText"
backgroundColor="$positiveBackground"
>
{mention.display}
</Text>
);

// Add text between this mention and the next one (or end of text)
const nextStart =
sortedMentions[index + 1]?.start ?? controlledText.length;
if (mention.end < nextStart) {
textParts.push(
<RawText key={`text-${index}`} color="$primaryText">
{controlledText.slice(mention.end, nextStart)}
</RawText>
);
}
});

return textParts;
}, [mentions, controlledText]);

const sendMessage = useCallback(
async (isEdit?: boolean) => {
const jsonContent = textAndMentionsToContent(controlledText, mentions);
Expand Down Expand Up @@ -434,18 +474,6 @@ export default function BareChatInput({
runSendMessage(true);
}, [runSendMessage, editingPost]);

// Make sure the user can still see some of the scroller when the keyboard is up
useEffect(() => {
Keyboard.addListener('keyboardDidShow', () => {
const keyboardHeight = Keyboard.metrics()?.height || 300;
setMaxInputHeight(maxInputHeightBasic - keyboardHeight);
});

Keyboard.addListener('keyboardDidHide', () => {
setMaxInputHeight(maxInputHeightBasic);
});
}, [maxInputHeightBasic]);

// Handle autofocus
useEffect(() => {
if (!shouldBlur && shouldAutoFocus && !hasAutoFocused) {
Expand Down Expand Up @@ -569,51 +597,6 @@ export default function BareChatInput({
setMentions,
]);

// Handle pastes on web
useEffect(() => {
// For now, we only check to make sure we're on web,
// we don't check if the input is focused. This allows users to paste
// images before they select the input. We may want to change this behavior
// if this feels weird, but it feels like a nice quality of life improvement.
// We can do this because there is only ever one input on the screen at a time,
// unlike the old app where you could have both the main chat input and the
// thread input on screen at the same time.
if (!isWeb) return;

const handlePaste = async (e: ClipboardEvent) => {
const items = Array.from(e.clipboardData?.items || []);
const image = items.find((item) => item.type.includes('image'));

if (!image) return;

const file = image.getAsFile();
if (!file) return;

const uri = URL.createObjectURL(file);

const img = new Image();

img.onload = () => {
addAttachment({
type: 'image',
file: {
uri,
height: img.height,
width: img.width,
},
});
};

img.src = uri;
};

document.addEventListener('paste', handlePaste);

return () => {
document.removeEventListener('paste', handlePaste);
};
}, [addAttachment]);

const handleCancelEditing = useCallback(() => {
setEditingPost?.(undefined);
setHasSetInitialContent(false);
Expand Down Expand Up @@ -728,9 +711,15 @@ export default function BareChatInput({
...(isWeb ? { outlineStyle: 'none' } : {}),
}}
>
{isWeb ? undefined : renderTextWithMentions}
{isWeb ? undefined : (
<TextWithMentions
text={controlledText}
mentions={mentions}
textColor="$primaryText"
/>
)}
</TextInput>
{isWeb && mentions.length > 0 && (
{isWeb && controlledText && mentions.length > 0 && (
<View height={inputHeight} position="absolute" pointerEvents="none">
<RawText
paddingHorizontal="$l"
Expand All @@ -740,7 +729,11 @@ export default function BareChatInput({
letterSpacing={-0.032}
color="$primaryText"
>
{renderTextWithMentionsWeb}
<TextWithMentions
text={controlledText}
mentions={mentions}
textColor="transparent"
/>
</RawText>
</View>
)}
Expand Down
Loading