Skip to content

Commit 025b398

Browse files
committed
Merge branch 'develop' of github.com:tloncorp/landscape-apps into develop
2 parents c174f9e + 9ea916d commit 025b398

File tree

7 files changed

+176
-33
lines changed

7 files changed

+176
-33
lines changed

packages/app/ui/components/BareChatInput/index.tsx

+25-2
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
UploadedImageAttachment,
4040
useAttachmentContext,
4141
} from '../../contexts';
42+
import { MentionController } from '../MentionPopup';
4243
import { DEFAULT_MESSAGE_INPUT_HEIGHT } from '../MessageInput';
4344
import { AttachmentPreviewList } from '../MessageInput/AttachmentPreviewList';
4445
import {
@@ -229,6 +230,7 @@ export default function BareChatInput({
229230
mentions,
230231
setMentions,
231232
showMentionPopup,
233+
handleMentionEscape,
232234
} = useMentions();
233235
const maxInputHeight = useKeyboardHeight(maxInputHeightBasic);
234236
const inputRef = useRef<TextInput>(null);
@@ -268,6 +270,7 @@ export default function BareChatInput({
268270
);
269271

270272
const lastProcessedRef = useRef('');
273+
const mentionRef = useRef<MentionController>(null);
271274

272275
const handleTextChange = useCallback(
273276
(newText: string) => {
@@ -670,16 +673,33 @@ export default function BareChatInput({
670673
return;
671674
}
672675

676+
if (
677+
(keyEvent.key === 'ArrowUp' || keyEvent.key === 'ArrowDown') &&
678+
showMentionPopup
679+
) {
680+
e.preventDefault();
681+
mentionRef.current?.handleMentionKey(keyEvent.key);
682+
}
683+
684+
if (keyEvent.key === 'Escape') {
685+
if (showMentionPopup) {
686+
e.preventDefault();
687+
handleMentionEscape();
688+
}
689+
}
690+
673691
if (keyEvent.key === 'Enter' && !keyEvent.shiftKey) {
674692
e.preventDefault();
675-
if (editingPost) {
693+
if (showMentionPopup) {
694+
mentionRef.current?.handleMentionKey('Enter');
695+
} else if (editingPost) {
676696
handleEdit();
677697
} else {
678698
handleSend();
679699
}
680700
}
681701
},
682-
[setIsOpen, handleSend, handleEdit, editingPost]
702+
[showMentionPopup, setIsOpen, editingPost, handleEdit, handleSend]
683703
);
684704

685705
return (
@@ -691,6 +711,7 @@ export default function BareChatInput({
691711
sendError={sendError}
692712
showMentionPopup={showMentionPopup}
693713
mentionText={mentionSearchText}
714+
mentionRef={mentionRef}
694715
showAttachmentButton={showAttachmentButton}
695716
groupMembers={groupMembers}
696717
onSelectMention={onMentionSelect}
@@ -737,6 +758,8 @@ export default function BareChatInput({
737758
...(isWeb ? placeholderTextColor : {}),
738759
...(isWeb ? { outlineStyle: 'none' } : {}),
739760
}}
761+
// Hack to prevent @p's getting squiggled on web
762+
spellCheck={!mentions.length}
740763
>
741764
{isWeb ? undefined : (
742765
<TextWithMentions

packages/app/ui/components/BareChatInput/useMentions.tsx

+39-1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ export const useMentions = () => {
1515
);
1616
const [mentionSearchText, setMentionSearchText] = useState<string>('');
1717
const [mentions, setMentions] = useState<Mention[]>([]);
18+
const [wasDismissedByEscape, setWasDismissedByEscape] = useState(false);
19+
const [lastDismissedTriggerIndex, setLastDismissedTriggerIndex] = useState<
20+
number | null
21+
>(null);
1822

1923
const handleMention = (oldText: string, newText: string) => {
2024
// Find cursor position by comparing old and new text
@@ -28,6 +32,25 @@ export const useMentions = () => {
2832
}
2933
}
3034

35+
// Clear escape state when starting a new word
36+
if (
37+
oldText.length < newText.length &&
38+
newText[cursorPosition - 1] === ' '
39+
) {
40+
setWasDismissedByEscape(false);
41+
}
42+
43+
// Clear escape state when deleting past the escaped trigger
44+
if (wasDismissedByEscape && lastDismissedTriggerIndex !== null) {
45+
if (
46+
oldText.length > newText.length &&
47+
cursorPosition <= lastDismissedTriggerIndex
48+
) {
49+
setWasDismissedByEscape(false);
50+
setLastDismissedTriggerIndex(null);
51+
}
52+
}
53+
3154
// Check if we're deleting a trigger symbol
3255
if (newText.length < oldText.length && showMentionPopup) {
3356
const deletedChar = oldText[cursorPosition];
@@ -55,9 +78,15 @@ export const useMentions = () => {
5578
);
5679
const hasSpace = textBetweenTriggerAndCursor.includes(' ');
5780

58-
// Only show popup if we're right after the trigger or actively searching
81+
// Only show popup if:
82+
// 1. We're right after the trigger or actively searching
83+
// 2. AND it wasn't dismissed by escape for this trigger index
84+
// 3. OR it's a new trigger position different from the dismissed one
85+
const isDismissedTrigger =
86+
wasDismissedByEscape && lastTriggerIndex === lastDismissedTriggerIndex;
5987
if (
6088
!hasSpace &&
89+
!isDismissedTrigger &&
6190
(cursorPosition === lastTriggerIndex + 1 ||
6291
(cursorPosition > lastTriggerIndex && !afterCursor.includes(' ')))
6392
) {
@@ -109,10 +138,18 @@ export const useMentions = () => {
109138
setShowMentionPopup(false);
110139
setMentionStartIndex(null);
111140
setMentionSearchText('');
141+
setWasDismissedByEscape(false);
142+
setLastDismissedTriggerIndex(null);
112143

113144
return newText;
114145
};
115146

147+
const handleMentionEscape = () => {
148+
setShowMentionPopup(false);
149+
setWasDismissedByEscape(true);
150+
setLastDismissedTriggerIndex(mentionStartIndex);
151+
};
152+
116153
return {
117154
mentions,
118155
setMentions,
@@ -122,5 +159,6 @@ export const useMentions = () => {
122159
handleSelectMention,
123160
showMentionPopup,
124161
setShowMentionPopup,
162+
handleMentionEscape,
125163
};
126164
};

packages/app/ui/components/ListItem/ContactListItem.tsx

+4-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Pressable } from '@tloncorp/ui';
22
import { ComponentProps } from 'react';
33
import { isWeb } from 'tamagui';
44

5+
import { formatUserId } from '../../utils';
56
import { AvatarProps } from '../Avatar';
67
import ContactName from '../ContactName';
78
import { ListItem } from './ListItem';
@@ -57,7 +58,9 @@ export const ContactListItem = ({
5758
/>
5859
</ListItem.Title>
5960
{showUserId && showNickname ? (
60-
<ListItem.Subtitle>{contactId}</ListItem.Subtitle>
61+
<ListItem.Subtitle>
62+
{formatUserId(contactId)?.display}
63+
</ListItem.Subtitle>
6164
) : null}
6265
{subtitle && <ListItem.Subtitle>{subtitle}</ListItem.Subtitle>}
6366
</ListItem.MainContent>

packages/app/ui/components/MentionPopup.tsx

+68-13
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,36 @@
11
import * as db from '@tloncorp/shared/db';
22
import { desig } from '@tloncorp/shared/urbit';
3-
import { useMemo } from 'react';
4-
import { Dimensions } from 'react-native';
3+
import {
4+
PropsWithRef,
5+
useEffect,
6+
useImperativeHandle,
7+
useMemo,
8+
useState,
9+
} from 'react';
10+
import React from 'react';
11+
import { Platform } from 'react-native';
512

613
import { ContactList } from './ContactList';
714

8-
export default function MentionPopup({
9-
groupMembers,
10-
onPress,
11-
matchText,
12-
}: {
13-
groupMembers: db.ChatMember[];
14-
onPress: (contact: db.Contact) => void;
15-
matchText?: string;
16-
}) {
15+
export interface MentionController {
16+
handleMentionKey(key: 'ArrowUp' | 'ArrowDown' | 'Enter'): void;
17+
}
18+
export type MentionPopupRef = React.RefObject<MentionController>;
19+
20+
function MentionPopupInternal(
21+
{
22+
groupMembers,
23+
onPress,
24+
matchText,
25+
}: PropsWithRef<{
26+
groupMembers: db.ChatMember[];
27+
onPress: (contact: db.Contact) => void;
28+
matchText?: string;
29+
}>,
30+
ref: React.Ref<{
31+
handleMentionKey(key: 'ArrowUp' | 'ArrowDown' | 'Enter'): void;
32+
}>
33+
) {
1734
const subSet = useMemo(
1835
() =>
1936
groupMembers
@@ -35,21 +52,51 @@ export default function MentionPopup({
3552
[groupMembers, matchText]
3653
);
3754

55+
const subsetSize = useMemo(() => subSet.length, [subSet]);
56+
57+
const [selectedIndex, setSelectedIndex] = useState(0);
58+
59+
useEffect(() => {
60+
setSelectedIndex(0);
61+
}, [subsetSize]);
62+
63+
useImperativeHandle(ref, () => ({
64+
handleMentionKey(key) {
65+
switch (key) {
66+
case 'ArrowUp':
67+
setSelectedIndex((prevIndex) =>
68+
prevIndex > 0 ? prevIndex - 1 : prevIndex
69+
);
70+
break;
71+
case 'ArrowDown':
72+
setSelectedIndex((prevIndex) =>
73+
prevIndex < subsetSize - 1 ? prevIndex + 1 : prevIndex
74+
);
75+
break;
76+
case 'Enter':
77+
onPress(subSet[selectedIndex]!);
78+
break;
79+
default:
80+
break;
81+
}
82+
},
83+
}));
84+
3885
if (subSet.length === 0) {
3986
return null;
4087
}
4188

4289
return (
4390
<ContactList>
44-
{subSet.map((contact) =>
91+
{subSet.map((contact, index) =>
4592
contact ? (
4693
<ContactList.Item
4794
alignItems="center"
4895
justifyContent="flex-start"
4996
onPress={() => onPress(contact)}
5097
// setting the width to the screen width - 40 so that we can use
5198
// ellipsizeMode="tail" to truncate the text
52-
width={Dimensions.get('window').width - 40}
99+
// width={Dimensions.get('window').width - 40}
53100
// this is a hack to make the text not overflow the container
54101
paddingRight="$3xl"
55102
padding="$s"
@@ -58,9 +105,17 @@ export default function MentionPopup({
58105
matchText={matchText ? desig(matchText) : undefined}
59106
showNickname
60107
showUserId
108+
backgroundColor={
109+
Platform.OS === 'web' && index === selectedIndex
110+
? '$positiveBackground'
111+
: 'unset'
112+
}
61113
/>
62114
) : null
63115
)}
64116
</ContactList>
65117
);
66118
}
119+
120+
const MentionPopup = React.forwardRef(MentionPopupInternal);
121+
export default MentionPopup;
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,50 @@
11
import * as db from '@tloncorp/shared/db';
2+
import { PropsWithRef } from 'react';
3+
import React from 'react';
4+
import { Dimensions } from 'react-native';
25
import { View, YStack } from 'tamagui';
36

4-
import MentionPopup from '../MentionPopup';
7+
import { useIsWindowNarrow } from '../Emoji';
8+
import MentionPopup, { MentionPopupRef } from '../MentionPopup';
59

6-
export default function InputMentionPopup({
7-
containerHeight,
8-
showMentionPopup,
9-
mentionText,
10-
groupMembers,
11-
onSelectMention,
12-
}: {
13-
containerHeight: number;
14-
showMentionPopup: boolean;
15-
mentionText?: string;
16-
groupMembers: db.ChatMember[];
17-
onSelectMention: (contact: db.Contact) => void;
18-
}) {
10+
function InputMentionPopupInternal(
11+
{
12+
containerHeight,
13+
showMentionPopup,
14+
mentionText,
15+
groupMembers,
16+
onSelectMention,
17+
}: PropsWithRef<{
18+
containerHeight: number;
19+
showMentionPopup: boolean;
20+
mentionText?: string;
21+
groupMembers: db.ChatMember[];
22+
onSelectMention: (contact: db.Contact) => void;
23+
}>,
24+
ref: MentionPopupRef
25+
) {
26+
const isNarrow = useIsWindowNarrow();
1927
return showMentionPopup ? (
20-
<YStack position="absolute" bottom={containerHeight + 24} zIndex={15}>
28+
<YStack
29+
position="absolute"
30+
bottom={containerHeight + 24}
31+
zIndex={15}
32+
// borderWidth={2}
33+
// borderColor="orange"
34+
width="90%"
35+
maxWidth={isNarrow ? 'unset' : 500}
36+
>
2137
<View position="relative" top={0} left={8}>
2238
<MentionPopup
2339
onPress={onSelectMention}
2440
matchText={mentionText}
2541
groupMembers={groupMembers}
42+
ref={ref}
2643
/>
2744
</View>
2845
</YStack>
2946
) : null;
3047
}
48+
49+
const InputMentionPopup = React.forwardRef(InputMentionPopupInternal);
50+
export default InputMentionPopup;

packages/app/ui/components/MessageInput/MessageInputBase.tsx

+4
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
} from 'tamagui';
1919

2020
import { useAttachmentContext } from '../../contexts/attachment';
21+
import { MentionPopupRef } from '../MentionPopup';
2122
import { GalleryDraftType } from '../draftInputs/shared';
2223
import AttachmentButton from './AttachmentButton';
2324
import InputMentionPopup from './InputMentionPopup';
@@ -89,6 +90,7 @@ export const MessageInputContainer = memo(
8990
cancelEditing,
9091
onPressEdit,
9192
goBack,
93+
mentionRef,
9294
}: PropsWithChildren<{
9395
setShouldBlur: (shouldBlur: boolean) => void;
9496
onPressSend: () => void;
@@ -106,6 +108,7 @@ export const MessageInputContainer = memo(
106108
cancelEditing?: () => void;
107109
onPressEdit?: () => void;
108110
goBack?: () => void;
111+
mentionRef?: MentionPopupRef;
109112
}>) => {
110113
const { canUpload } = useAttachmentContext();
111114

@@ -127,6 +130,7 @@ export const MessageInputContainer = memo(
127130
mentionText={mentionText}
128131
groupMembers={groupMembers}
129132
onSelectMention={onSelectMention}
133+
ref={mentionRef}
130134
/>
131135
<XStack
132136
paddingVertical="$s"

0 commit comments

Comments
 (0)