Skip to content

Commit 7ec48ee

Browse files
authored
Create Conversation Design Feedback (#1552)
handled most "musts" from #1498 (comment) will wrap the rest up next week https://www.loom.com/share/cee47d3644e341a4abacf0dcdf4df642?sid=03ee3626-f3f5-42cb-ade8-6b0b74c9c938
1 parent 68c816c commit 7ec48ee

14 files changed

+669
-417
lines changed

App.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ LogBox.ignoreLogs([
5656
"[Reanimated] Reading from `value` during component render. Please ensure that you do not access the `value` property or use `get` method of a shared value while React is rendering a component.",
5757
"Attempted to import the module",
5858
"Couldn't find real values for `KeyboardContext`. Please make sure you're inside of `KeyboardProvider` - otherwise functionality of `react-native-keyboard-controller` will not work. [Component Stack]",
59+
"sync worker error storage error: Pool needs to reconnect before use",
60+
"[Converse.debug.dylib] sync worker error storage error: Pool needs to reconnect before use",
5961
]);
6062

6163
// This is the default configuration

components/Avatar.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export const Avatar = memo(function Avatar({
4141
onError={handleImageError}
4242
source={{ uri }}
4343
style={{
44-
borderRadius: 9999,
44+
borderRadius: avatarSize / 2,
4545
width: avatarSize,
4646
height: avatarSize,
4747
}}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/**
2+
* A chip component that displays an avatar and text
3+
* Used for showing selected users in search/selection contexts
4+
*
5+
* @param {object} props Component props
6+
* @param {string} props.name Display name for the chip
7+
* @param {string} props.avatarUri Optional avatar image URI
8+
* @param {boolean} props.isSelected Whether the chip is in selected state
9+
* @param {() => void} props.onPress Called when chip is pressed
10+
*/
11+
12+
import React from "react";
13+
import { Pressable, ViewStyle, TextStyle } from "react-native";
14+
import { ThemedStyle, useAppTheme } from "@/theme/useAppTheme";
15+
import { Text } from "@/design-system/Text";
16+
import { Avatar } from "@/components/Avatar";
17+
18+
type ChipProps = {
19+
name: string;
20+
avatarUri?: string;
21+
isSelected?: boolean;
22+
onPress?: () => void;
23+
};
24+
25+
export function Chip({ name, avatarUri, isSelected, onPress }: ChipProps) {
26+
const { themed, theme } = useAppTheme();
27+
28+
return (
29+
<Pressable
30+
onPress={onPress}
31+
style={[themed($container), isSelected && themed($selectedContainer)]}
32+
>
33+
<Avatar uri={avatarUri} name={name} size={theme.avatarSize.xs} />
34+
<Text preset="small">{name}</Text>
35+
</Pressable>
36+
);
37+
}
38+
39+
const $container: ThemedStyle<ViewStyle> = ({ colors, spacing }) => ({
40+
flexDirection: "row",
41+
alignItems: "center",
42+
justifyContent: "center",
43+
paddingVertical: spacing.xxxs,
44+
paddingLeft: spacing.xs,
45+
paddingRight: spacing.xxs,
46+
gap: spacing.xxxs,
47+
minHeight: spacing.container.large,
48+
borderRadius: spacing.xs,
49+
borderWidth: 1,
50+
borderColor: colors.border.subtle,
51+
backgroundColor: colors.background.surface,
52+
});
53+
54+
const $selectedContainer: ThemedStyle<ViewStyle> = ({ colors }) => ({
55+
backgroundColor: colors.background.blurred,
56+
borderColor: colors.border.primary,
57+
});
58+
59+
const $text: ThemedStyle<TextStyle> = ({ colors }) => ({
60+
color: colors.text.primary,
61+
lineHeight: 18,
62+
fontSize: 14,
63+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import React from "react";
2+
import { View } from "react-native";
3+
import { useAppTheme } from "@/theme/useAppTheme";
4+
import { createConversationStyles } from "../create-conversation.styles";
5+
import { ComposerSectionProps } from "../create-conversation.types";
6+
import { ConversationComposer } from "@/features/conversation/conversation-composer/conversation-composer";
7+
import { ConversationComposerContainer } from "@/features/conversation/conversation-composer/conversation-composer-container";
8+
import { ConversationComposerStoreProvider } from "@/features/conversation/conversation-composer/conversation-composer.store-context";
9+
import { ConversationKeyboardFiller } from "@/features/conversation/conversation-keyboard-filler";
10+
11+
/**
12+
* Composer section for creating new conversations
13+
* Includes the message composer and keyboard handling
14+
*
15+
* @param props.disabled - Whether the composer is disabled
16+
* @param props.conversationMode - The type of conversation being created
17+
* @param props.onSend - Callback when a message is sent
18+
*/
19+
export function ComposerSection({
20+
disabled,
21+
conversationMode,
22+
onSend,
23+
}: ComposerSectionProps) {
24+
const { themed } = useAppTheme();
25+
26+
return (
27+
<View style={themed(createConversationStyles.$composerSection)}>
28+
<ConversationComposerStoreProvider
29+
storeName={`new-conversation-${conversationMode}`}
30+
>
31+
<ConversationComposerContainer>
32+
<ConversationComposer
33+
hideAddAttachmentButton
34+
disabled={disabled}
35+
onSend={async (params) => {
36+
const messageText = params.content?.text?.trim();
37+
if (!messageText) return;
38+
await onSend({ text: messageText });
39+
}}
40+
/>
41+
</ConversationComposerContainer>
42+
</ConversationComposerStoreProvider>
43+
<ConversationKeyboardFiller
44+
messageContextMenuIsOpen={false}
45+
enabled={true}
46+
/>
47+
</View>
48+
);
49+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import React from "react";
2+
import { View } from "react-native";
3+
import { Text } from "@/design-system/Text";
4+
import { useAppTheme } from "@/theme/useAppTheme";
5+
import { createConversationStyles } from "../create-conversation.styles";
6+
import { MessageSectionProps } from "../create-conversation.types";
7+
8+
/**
9+
* Displays a message or error in the create conversation screen
10+
*
11+
* @param props.message - Message to display
12+
* @param props.isError - Whether the message is an error
13+
*/
14+
export function MessageSection({ message, isError }: MessageSectionProps) {
15+
const { themed } = useAppTheme();
16+
17+
if (!message) return null;
18+
19+
return (
20+
<View style={themed(createConversationStyles.$messageSection)}>
21+
<Text
22+
style={[
23+
themed(createConversationStyles.$messageText),
24+
isError && themed(createConversationStyles.$errorText),
25+
]}
26+
>
27+
{message}
28+
</Text>
29+
</View>
30+
);
31+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import React from "react";
2+
import { View } from "react-native";
3+
import { Chip } from "@/design-system/chip";
4+
import { useAppTheme } from "@/theme/useAppTheme";
5+
import { createConversationStyles } from "../create-conversation.styles";
6+
import { PendingMembersSectionProps } from "../create-conversation.types";
7+
import { getPreferredAvatar, getPreferredName } from "@/utils/profile";
8+
9+
/**
10+
* Displays chips for pending chat members that can be removed
11+
* by clicking on them.
12+
*
13+
* @param props.members - Array of pending chat members
14+
* @param props.onRemoveMember - Callback when a member chip is pressed
15+
*/
16+
export function PendingMembersSection({
17+
members,
18+
onRemoveMember,
19+
}: PendingMembersSectionProps) {
20+
const { themed } = useAppTheme();
21+
22+
if (!members.length) return null;
23+
24+
return (
25+
<View style={themed(createConversationStyles.$pendingMembersSection)}>
26+
{members.map((member) => {
27+
const preferredName = getPreferredName(member, member.address);
28+
29+
return (
30+
<Chip
31+
key={member.address}
32+
text={preferredName}
33+
avatarUrl={getPreferredAvatar(member)}
34+
onPress={() => onRemoveMember(member.address)}
35+
/>
36+
);
37+
})}
38+
</View>
39+
);
40+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import React from "react";
2+
import { View } from "react-native";
3+
import { useAppTheme } from "@/theme/useAppTheme";
4+
import { createConversationStyles } from "../create-conversation.styles";
5+
import { SearchResultsSectionProps } from "../create-conversation.types";
6+
import { ProfileSearchResultsList } from "@/features/search/components/ProfileSearchResultsList";
7+
8+
/**
9+
* Displays search results for profiles when creating a conversation
10+
*
11+
* @param props.profiles - Object containing profile search results
12+
* @param props.onSelectProfile - Callback when a profile is selected
13+
*/
14+
export function SearchResultsSection({
15+
profiles,
16+
onSelectProfile,
17+
}: SearchResultsSectionProps) {
18+
const { themed } = useAppTheme();
19+
20+
if (Object.keys(profiles).length === 0) return null;
21+
22+
const $searchSection = themed(createConversationStyles.$searchSection);
23+
24+
return (
25+
<View style={$searchSection}>
26+
<ProfileSearchResultsList
27+
profiles={profiles}
28+
handleSearchResultItemPress={onSelectProfile}
29+
/>
30+
</View>
31+
);
32+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { ComposerSection } from "./ComposerSection";
2+
export { MessageSection } from "./MessageSection";
3+
export { UserInlineSearch } from "./user-inline-search";
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/**
2+
* A searchable chip list component for selecting users
3+
* Supports keyboard navigation and chip management
4+
*
5+
* @param {object} props Component props
6+
* @param {string} props.value Current search input value
7+
* @param {(value: string) => void} props.onChangeText Called when input changes
8+
* @param {(ref: TextInput | null) => void} props.onRef Called with input ref
9+
* @param {string} props.placeholder Placeholder text for empty input
10+
* @param {Array<{address: string, name: string}>} props.selectedUsers Currently selected users
11+
* @param {(address: string) => void} props.onRemoveUser Called when removing a user
12+
*/
13+
14+
import React, { useRef, useState } from "react";
15+
import { TextInput, View, ViewStyle, TextStyle } from "react-native";
16+
import { ThemedStyle, useAppTheme } from "@/theme/useAppTheme";
17+
import { textSizeStyles } from "@/design-system/Text/Text.styles";
18+
import { Text } from "@/design-system/Text";
19+
import { Chip } from "./Chip";
20+
// import { debugBorder } from "@/utils/debug-style";
21+
import logger from "@/utils/logger";
22+
// import { debugBorder } from "@/utils/debug-style";
23+
24+
type Props = {
25+
value: string;
26+
onChangeText: (value: string) => void;
27+
onRef: (ref: TextInput | null) => void;
28+
placeholder?: string;
29+
selectedUsers: Array<{
30+
address: string;
31+
name: string;
32+
avatarUri: string | undefined;
33+
}>;
34+
onRemoveUser: (address: string) => void;
35+
};
36+
37+
export function UserInlineSearch({
38+
value,
39+
onChangeText,
40+
onRef,
41+
placeholder = "Name, address or onchain ID",
42+
selectedUsers,
43+
onRemoveUser,
44+
}: Props) {
45+
const { theme, themed } = useAppTheme();
46+
const [selectedChipIndex, setSelectedChipIndex] = useState<number | null>(
47+
null
48+
);
49+
const inputRef = useRef<TextInput | null>(null);
50+
51+
const handleKeyPress = ({ nativeEvent: { key } }: any) => {
52+
logger.debug("key", key);
53+
if (key === "Backspace" && value === "") {
54+
if (selectedChipIndex !== null) {
55+
onRemoveUser(selectedUsers[selectedChipIndex].address);
56+
setSelectedChipIndex(null);
57+
} else if (selectedUsers.length > 0) {
58+
setSelectedChipIndex(selectedUsers.length - 1);
59+
}
60+
} else {
61+
setSelectedChipIndex(null);
62+
}
63+
};
64+
65+
return (
66+
<View style={themed($container)}>
67+
<View style={themed($inputContainer)}>
68+
<Text preset="formLabel" style={themed($toText)}>
69+
To
70+
</Text>
71+
{selectedUsers.map((user, index) => (
72+
<Chip
73+
avatarUri={user.avatarUri}
74+
key={user.address}
75+
name={user.name}
76+
isSelected={selectedChipIndex === index}
77+
onPress={() => {
78+
if (selectedChipIndex === index) {
79+
setSelectedChipIndex(null);
80+
} else {
81+
setSelectedChipIndex(index);
82+
}
83+
}}
84+
/>
85+
))}
86+
87+
<TextInput
88+
ref={(r) => {
89+
inputRef.current = r;
90+
onRef(r);
91+
}}
92+
style={themed($input) as TextStyle}
93+
value={value}
94+
onChangeText={onChangeText}
95+
placeholder={selectedUsers.length === 0 ? placeholder : ""}
96+
placeholderTextColor={theme.colors.text.secondary}
97+
onKeyPress={handleKeyPress}
98+
autoCapitalize="none"
99+
autoCorrect={false}
100+
/>
101+
</View>
102+
</View>
103+
);
104+
}
105+
106+
const $container: ThemedStyle<ViewStyle> = ({ colors, spacing }) => ({
107+
backgroundColor: colors.background.surface,
108+
marginHorizontal: 16,
109+
marginVertical: 8,
110+
// padding: spacing.sm,
111+
// borderColor: colors.border.subtle,
112+
// ...debugBorder("blue")
113+
});
114+
115+
const $inputContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
116+
flexDirection: "row",
117+
flexWrap: "wrap",
118+
alignItems: "center",
119+
gap: spacing.xxxs,
120+
// ...debugBorder("yellow"),
121+
});
122+
123+
const $input: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
124+
flex: 1,
125+
minWidth: 120,
126+
height: spacing["3xl"],
127+
color: colors.text.primary,
128+
paddingStart: spacing.xxs,
129+
...textSizeStyles.xs,
130+
// ...debugBorder("purple"),
131+
});
132+
133+
const $toText: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
134+
color: colors.text.primary,
135+
marginEnd: spacing.xxs,
136+
});

0 commit comments

Comments
 (0)