Skip to content

Commit 5e94bb0

Browse files
devi-rmuhsin-k
andauthored
feat: Add the support for contact labels (#1002)
* feat: add contact labels feature with shared LabelActions component - Extract common label management logic into generic LabelActions component - Refactor ConversationLabelActions to use shared component - Create ContactLabelActions wrapper for contact label management - Integrate ContactLabelActions into ContactDetailsScreen - Fix contact labels API method from PUT to POST to match API spec * fix: iOS bottom sheet z-index issue in ContactDetailsScreen - Add detached prop to BottomSheetModal in LabelActions to render at root level - Add BottomSheetModalProvider wrapper in ContactDetailsScreen for formSheet context - Add enableDynamicSizing={false} for consistent rendering - Fixes issue where bottom sheet backdrop appeared behind formSheet on iOS * fix: shared ref conflict between multiple LabelActions instances - Add optional sheetRef prop to LabelActions - Update ConversationLabelActions to use its own ref - Update ContactLabelActions to use its own ref - Prevents ref collision when ChatScreen and ContactDetailsScreen both mount - Create custom LabelBackdrop component that uses instance-specific ref - Ensures backdrop dismisses the correct modal instance * refactor: use export * pattern in label-actions index file * chore: update app versions * fix(components-next): resolve require cycles in label-actions --------- Co-authored-by: Muhsin <[email protected]>
1 parent 3ff0483 commit 5e94bb0

File tree

13 files changed

+494
-195
lines changed

13 files changed

+494
-195
lines changed

app.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ export default ({ config }: ConfigContext): ExpoConfig => {
44
return {
55
name: 'Chatwoot',
66
slug: process.env.EXPO_PUBLIC_APP_SLUG || 'chatwoot-mobile',
7-
version: '4.3.12',
7+
version: '4.3.13',
88
orientation: 'portrait',
99
icon: './assets/icon.png',
1010
userInterfaceStyle: 'light',

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@chatwoot/mobile-app",
3-
"version": "4.3.12",
3+
"version": "4.3.13",
44
"scripts": {
55
"start": "expo start --dev-client",
66
"start:production": "expo start --no-dev --minify",
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import React, { useState, useEffect } from 'react';
2+
import { Platform, Pressable, StyleSheet } from 'react-native';
3+
import Animated from 'react-native-reanimated';
4+
import { BottomSheetModal, BottomSheetScrollView } from '@gorhom/bottom-sheet';
5+
6+
import { useRefsContext } from '@/context';
7+
import { LabelTag } from '@/svg-icons';
8+
import { tailwind } from '@/theme';
9+
import { Icon, SearchBar } from '@/components-next';
10+
import { useAppSelector } from '@/hooks';
11+
import { filterLabels } from '@/store/label/labelSelectors';
12+
13+
import { LabelItem } from '@/components-next/label-section';
14+
import { LabelStack, LabelBackdrop } from './label-actions';
15+
16+
interface LabelActionsProps {
17+
labels: string[];
18+
onLabelsUpdate: (updatedLabels: string[]) => Promise<void> | void;
19+
sheetRef?: React.RefObject<BottomSheetModal>;
20+
}
21+
22+
export const LabelActions = (props: LabelActionsProps) => {
23+
const { labels, onLabelsUpdate, sheetRef } = props;
24+
const [searchTerm, setSearchTerm] = useState('');
25+
26+
const [selectedLabels, setSelectedLabels] = useState(labels);
27+
28+
useEffect(() => {
29+
setSelectedLabels(labels);
30+
}, [labels]);
31+
32+
const { addLabelSheetRef: contextAddLabelSheetRef } = useRefsContext();
33+
const addLabelSheetRef = sheetRef || contextAddLabelSheetRef;
34+
35+
// Custom backdrop that uses the instance's own ref instead of the shared context ref
36+
const backdropComponent = (props: Parameters<typeof LabelBackdrop>[0]) => (
37+
<LabelBackdrop {...props} sheetRef={addLabelSheetRef} />
38+
);
39+
40+
const allLabels = useAppSelector(state => filterLabels(state, ''));
41+
42+
const filteredLabels = useAppSelector(state => filterLabels(state, searchTerm));
43+
44+
const selectedLabelItems =
45+
allLabels && selectedLabels
46+
? allLabels.filter(({ title }) => {
47+
return selectedLabels?.includes(title);
48+
})
49+
: [];
50+
51+
const handleAddLabelPress = () => {
52+
addLabelSheetRef.current?.present();
53+
};
54+
55+
const handleOnSubmitEditing = () => {
56+
addLabelSheetRef.current?.close();
57+
};
58+
59+
const handleChangeText = (text: string) => {
60+
setSearchTerm(text);
61+
};
62+
63+
const handleChange = (index: number) => {
64+
if (index === -1) {
65+
setSearchTerm('');
66+
}
67+
};
68+
69+
const handleAddOrUpdateLabels = async (label: string) => {
70+
setSelectedLabels(prevLabels => {
71+
const updatedLabels = prevLabels.includes(label)
72+
? prevLabels.filter(item => item !== label)
73+
: [...prevLabels, label];
74+
75+
onLabelsUpdate(updatedLabels);
76+
77+
return updatedLabels;
78+
});
79+
};
80+
return (
81+
<Animated.View>
82+
<Animated.View style={tailwind.style('pl-4')}>
83+
<Animated.Text
84+
style={tailwind.style(
85+
'text-sm font-inter-medium-24 leading-[16px] tracking-[0.32px] text-gray-700',
86+
)}>
87+
Labels
88+
</Animated.Text>
89+
</Animated.View>
90+
<Animated.View style={tailwind.style('flex flex-row flex-wrap pl-4')}>
91+
{selectedLabelItems.map((label, index) => (
92+
<LabelItem key={index} index={index} item={label} />
93+
))}
94+
<Pressable
95+
onPress={handleAddLabelPress}
96+
style={({ pressed }) => [
97+
styles.labelShadow,
98+
tailwind.style(
99+
'flex flex-row items-center bg-white px-3 py-[7px] rounded-lg mr-2 mt-3',
100+
pressed ? 'bg-blue-100' : '',
101+
),
102+
]}>
103+
<Icon icon={<LabelTag />} size={16} />
104+
<Animated.Text
105+
style={tailwind.style(
106+
'text-md font-inter-medium-24 leading-[17px] tracking-[0.24px] pl-1.5 text-blue-800',
107+
)}>
108+
Add
109+
</Animated.Text>
110+
</Pressable>
111+
</Animated.View>
112+
<BottomSheetModal
113+
ref={addLabelSheetRef}
114+
backdropComponent={backdropComponent}
115+
handleIndicatorStyle={tailwind.style('overflow-hidden bg-blackA-A6 w-8 h-1 rounded-[11px]')}
116+
handleStyle={tailwind.style('p-0 h-4 pt-[5px]')}
117+
style={tailwind.style('rounded-[26px] overflow-hidden')}
118+
enablePanDownToClose
119+
snapPoints={[316]}
120+
keyboardBehavior="interactive"
121+
keyboardBlurBehavior="restore"
122+
onChange={handleChange}
123+
enableDynamicSizing={false}
124+
detached>
125+
<SearchBar
126+
isInsideBottomSheet
127+
onSubmitEditing={handleOnSubmitEditing}
128+
onChangeText={handleChangeText}
129+
placeholder="Search labels"
130+
returnKeyLabel="done"
131+
returnKeyType="done"
132+
/>
133+
<BottomSheetScrollView showsVerticalScrollIndicator={false}>
134+
<LabelStack
135+
filteredLabels={filteredLabels}
136+
selectedLabels={selectedLabels}
137+
isStandAloneComponent={allLabels.length > 3}
138+
handleLabelPress={handleAddOrUpdateLabels}
139+
/>
140+
</BottomSheetScrollView>
141+
</BottomSheetModal>
142+
</Animated.View>
143+
);
144+
};
145+
146+
const styles = StyleSheet.create({
147+
labelShadow:
148+
Platform.select({
149+
ios: {
150+
shadowColor: '#00000040',
151+
shadowOffset: { width: 0, height: 0.15 },
152+
shadowRadius: 2,
153+
shadowOpacity: 0.35,
154+
elevation: 2,
155+
},
156+
android: {
157+
elevation: 4,
158+
backgroundColor: 'white',
159+
},
160+
}) || {}, // Add fallback empty object
161+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './LabelCell';
22
export * from './LabelItem';
3+
export * from './label-actions';
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import React, { useState, useEffect } from 'react';
2+
import { Platform, Pressable, StyleSheet } from 'react-native';
3+
import Animated from 'react-native-reanimated';
4+
import {
5+
BottomSheetModal,
6+
BottomSheetScrollView,
7+
BottomSheetBackgroundProps,
8+
} from '@gorhom/bottom-sheet';
9+
10+
import { useRefsContext } from '@/context';
11+
import { LabelTag } from '@/svg-icons';
12+
import { tailwind } from '@/theme';
13+
import { Icon } from '@/components-next/common/icon';
14+
import { SearchBar } from '@/components-next/common/search';
15+
import { useAppSelector } from '@/hooks';
16+
import { filterLabels } from '@/store/label/labelSelectors';
17+
18+
import { LabelItem } from '../LabelItem';
19+
import { LabelStack } from './LabelStack';
20+
import { LabelBackdrop } from './LabelBackdrop';
21+
22+
interface LabelActionsProps {
23+
labels: string[];
24+
onLabelsUpdate: (updatedLabels: string[]) => Promise<void> | void;
25+
sheetRef?: React.RefObject<BottomSheetModal>;
26+
}
27+
28+
export const LabelActions = (props: LabelActionsProps) => {
29+
const { labels, onLabelsUpdate, sheetRef } = props;
30+
const [searchTerm, setSearchTerm] = useState('');
31+
32+
const [selectedLabels, setSelectedLabels] = useState(labels);
33+
34+
useEffect(() => {
35+
setSelectedLabels(labels);
36+
}, [labels]);
37+
38+
const { addLabelSheetRef: contextAddLabelSheetRef } = useRefsContext();
39+
const addLabelSheetRef = sheetRef || contextAddLabelSheetRef;
40+
41+
// Custom backdrop that uses the instance's own ref instead of the shared context ref
42+
const backdropComponent = (props: BottomSheetBackgroundProps) => (
43+
<LabelBackdrop {...props} sheetRef={addLabelSheetRef} />
44+
);
45+
46+
const allLabels = useAppSelector(state => filterLabels(state, ''));
47+
48+
const filteredLabels = useAppSelector(state => filterLabels(state, searchTerm));
49+
50+
const selectedLabelItems =
51+
allLabels && selectedLabels
52+
? allLabels.filter(({ title }) => {
53+
return selectedLabels?.includes(title);
54+
})
55+
: [];
56+
57+
const handleAddLabelPress = () => {
58+
addLabelSheetRef.current?.present();
59+
};
60+
61+
const handleOnSubmitEditing = () => {
62+
addLabelSheetRef.current?.close();
63+
};
64+
65+
const handleChangeText = (text: string) => {
66+
setSearchTerm(text);
67+
};
68+
69+
const handleChange = (index: number) => {
70+
if (index === -1) {
71+
setSearchTerm('');
72+
}
73+
};
74+
75+
const handleAddOrUpdateLabels = async (label: string) => {
76+
setSelectedLabels(prevLabels => {
77+
const updatedLabels = prevLabels.includes(label)
78+
? prevLabels.filter(item => item !== label)
79+
: [...prevLabels, label];
80+
81+
onLabelsUpdate(updatedLabels);
82+
83+
return updatedLabels;
84+
});
85+
};
86+
return (
87+
<Animated.View>
88+
<Animated.View style={tailwind.style('pl-4')}>
89+
<Animated.Text
90+
style={tailwind.style(
91+
'text-sm font-inter-medium-24 leading-[16px] tracking-[0.32px] text-gray-700',
92+
)}>
93+
Labels
94+
</Animated.Text>
95+
</Animated.View>
96+
<Animated.View style={tailwind.style('flex flex-row flex-wrap pl-4')}>
97+
{selectedLabelItems.map((label, index) => (
98+
<LabelItem key={index} index={index} item={label} />
99+
))}
100+
<Pressable
101+
onPress={handleAddLabelPress}
102+
style={({ pressed }) => [
103+
styles.labelShadow,
104+
tailwind.style(
105+
'flex flex-row items-center bg-white px-3 py-[7px] rounded-lg mr-2 mt-3',
106+
pressed ? 'bg-blue-100' : '',
107+
),
108+
]}>
109+
<Icon icon={<LabelTag />} size={16} />
110+
<Animated.Text
111+
style={tailwind.style(
112+
'text-md font-inter-medium-24 leading-[17px] tracking-[0.24px] pl-1.5 text-blue-800',
113+
)}>
114+
Add
115+
</Animated.Text>
116+
</Pressable>
117+
</Animated.View>
118+
<BottomSheetModal
119+
ref={addLabelSheetRef}
120+
backdropComponent={backdropComponent}
121+
handleIndicatorStyle={tailwind.style('overflow-hidden bg-blackA-A6 w-8 h-1 rounded-[11px]')}
122+
handleStyle={tailwind.style('p-0 h-4 pt-[5px]')}
123+
style={tailwind.style('rounded-[26px] overflow-hidden')}
124+
enablePanDownToClose
125+
snapPoints={[316]}
126+
keyboardBehavior="interactive"
127+
keyboardBlurBehavior="restore"
128+
onChange={handleChange}
129+
enableDynamicSizing={false}
130+
detached>
131+
<SearchBar
132+
isInsideBottomSheet
133+
onSubmitEditing={handleOnSubmitEditing}
134+
onChangeText={handleChangeText}
135+
placeholder="Search labels"
136+
returnKeyLabel="done"
137+
returnKeyType="done"
138+
/>
139+
<BottomSheetScrollView showsVerticalScrollIndicator={false}>
140+
<LabelStack
141+
filteredLabels={filteredLabels}
142+
selectedLabels={selectedLabels}
143+
isStandAloneComponent={allLabels.length > 3}
144+
handleLabelPress={handleAddOrUpdateLabels}
145+
/>
146+
</BottomSheetScrollView>
147+
</BottomSheetModal>
148+
</Animated.View>
149+
);
150+
};
151+
152+
const styles = StyleSheet.create({
153+
labelShadow:
154+
Platform.select({
155+
ios: {
156+
shadowColor: '#00000040',
157+
shadowOffset: { width: 0, height: 0.15 },
158+
shadowRadius: 2,
159+
shadowOpacity: 0.35,
160+
elevation: 2,
161+
},
162+
android: {
163+
elevation: 4,
164+
backgroundColor: 'white',
165+
},
166+
}) || {}, // Add fallback empty object
167+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import React from 'react';
2+
import { Pressable } from 'react-native';
3+
import Animated, { interpolate, useAnimatedStyle } from 'react-native-reanimated';
4+
import { BottomSheetBackgroundProps, BottomSheetModal } from '@gorhom/bottom-sheet';
5+
6+
import { tailwind } from '@/theme';
7+
8+
interface LabelBackdropProps extends BottomSheetBackgroundProps {
9+
sheetRef: React.RefObject<BottomSheetModal>;
10+
}
11+
12+
export const LabelBackdrop: React.FC<LabelBackdropProps> = props => {
13+
const { animatedIndex, style, sheetRef } = props;
14+
15+
const animatedStyle = useAnimatedStyle(() => {
16+
return {
17+
opacity: interpolate(animatedIndex.value, [-1, 0], [0, 1]),
18+
};
19+
});
20+
21+
const handleBackdropPress = () => {
22+
sheetRef.current?.dismiss({ overshootClamping: true });
23+
};
24+
25+
return (
26+
<Pressable onPress={handleBackdropPress} style={style}>
27+
<Animated.View style={[tailwind.style('bg-blackA-A9'), style, animatedStyle]} />
28+
</Pressable>
29+
);
30+
};

0 commit comments

Comments
 (0)