Skip to content

Commit ce26aa7

Browse files
authored
feat: RN message composer (#6)
* feat: add media picker svc * chore: renaming more appropriately * feat: implement message action sheet and refine message composer * chore: message input box and animations * feat: integrate sending messages * chore: cleanup sending * fix: close sheet on action selection * chore: extract as styles
1 parent 7e27894 commit ce26aa7

File tree

24 files changed

+1376
-16
lines changed

24 files changed

+1376
-16
lines changed

packages/react-native-sdk/android/src/main/java/com/streamio/aicomponents/renderkit/PerfTextViewManager.kt

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,6 @@ class PerfTextViewManager : SimpleViewManager<PerfTextView>() {
5353
if (sizeSp > 0) v.textSize = sizeSp.toFloat()
5454
}
5555

56-
5756
@ReactProp(name = "lineHeight")
5857
fun setLineHeight(v: PerfTextView, height: Double) {
5958
val targetPx = PixelUtil.toPixelFromDIP(height.toFloat())
@@ -65,8 +64,6 @@ class PerfTextViewManager : SimpleViewManager<PerfTextView>() {
6564
};
6665
}
6766

68-
69-
7067
@ReactProp(name = "fontFamily")
7168
fun setFontFamily(v: PerfTextView, family: String?) {
7269
v.setFontFamilyCompat(family)
@@ -138,11 +135,10 @@ class PerfTextViewManager : SimpleViewManager<PerfTextView>() {
138135

139136
view.measure(widthSpec, heightSpec)
140137

141-
val measuredWidthDp = ceil(PixelUtil.toDIPFromPixel(view.measuredWidth.toFloat()).toDouble()).toInt()
142-
val measuredHeightDp = ceil(PixelUtil.toDIPFromPixel(view.measuredHeight.toFloat()).toDouble()).toInt()
138+
val measuredWidthDp = ceil(PixelUtil.toDIPFromPixel(view.measuredWidth.toFloat()))
139+
val measuredHeightDp = ceil(PixelUtil.toDIPFromPixel(view.measuredHeight.toFloat()))
143140

144141
return YogaMeasureOutput.make(measuredWidthDp * 1.5f, measuredHeightDp * 1.0f)
145-
146142
}
147143
}
148144

packages/react-native-sdk/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
"license": "SEE LICENSE IN LICENSE",
5757
"dependencies": {
5858
"@khanacademy/simple-markdown": "^2.1.0",
59+
"@stream-io/state-store": "^1.1.6",
5960
"linkifyjs": "^4.3.2",
6061
"lodash": "4.17.21",
6162
"react-syntax-highlighter": "15.5.0",
@@ -68,10 +69,12 @@
6869
"@types/react": "19.2.2",
6970
"@types/react-syntax-highlighter": "^15.5.13",
7071
"concurrently": "catalog:",
72+
"expo-image-picker": "^17.0.8",
7173
"react": "19.2.0",
7274
"react-native": "^0.82.1",
7375
"react-native-builder-bob": "0.40.14",
7476
"react-native-gesture-handler": "^2.29.0",
77+
"react-native-image-picker": "^8.2.1",
7578
"react-native-reanimated": "^4.1.3",
7679
"rimraf": "^6.0.1",
7780
"typescript": "catalog:",
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import React, {
2+
type PropsWithChildren,
3+
useEffect,
4+
useMemo,
5+
useRef,
6+
useState,
7+
} from 'react';
8+
import {
9+
Dimensions,
10+
Modal,
11+
Platform,
12+
Pressable,
13+
StyleSheet,
14+
View,
15+
} from 'react-native';
16+
import {
17+
Gesture,
18+
GestureDetector,
19+
GestureHandlerRootView,
20+
type GestureType,
21+
} from 'react-native-gesture-handler';
22+
import Animated, {
23+
runOnJS,
24+
useAnimatedStyle,
25+
useSharedValue,
26+
withTiming,
27+
} from 'react-native-reanimated';
28+
import { useBottomSheetState } from './hooks/useBottomSheetState';
29+
import { closeSheet as closeSheetInternal } from '../store/bottom-sheet-state-store';
30+
31+
type Props = PropsWithChildren;
32+
33+
const { height: SCREEN_H } = Dimensions.get('window');
34+
const SPRING = { duration: 200 };
35+
const MAX_BOTTOM_SHEET_HEIGHT = Math.round(SCREEN_H * 0.4);
36+
const BACKDROP_OPACITY = 0.4;
37+
38+
export function BottomSheet({ children }: Props) {
39+
const { open, height } = useBottomSheetState();
40+
const [bottomSheetModalOpen, setBottomSheetModalOpen] = useState(open);
41+
const maxTranslateY = SCREEN_H;
42+
const translateY = useSharedValue(maxTranslateY);
43+
44+
const bottomSheetHeight = useMemo(
45+
() => Math.min(height, MAX_BOTTOM_SHEET_HEIGHT),
46+
[height],
47+
);
48+
const bottomSheetY = SCREEN_H - bottomSheetHeight;
49+
50+
useEffect(() => {
51+
if (open) {
52+
setBottomSheetModalOpen(true);
53+
} else {
54+
translateY.value = withTiming(maxTranslateY, SPRING, (finished) => {
55+
if (finished) {
56+
runOnJS(setBottomSheetModalOpen)(false);
57+
}
58+
});
59+
}
60+
}, [setBottomSheetModalOpen, maxTranslateY, open, translateY]);
61+
62+
useEffect(() => {
63+
if (bottomSheetModalOpen && open) {
64+
translateY.value = withTiming(bottomSheetY, SPRING);
65+
}
66+
}, [open, bottomSheetModalOpen, bottomSheetY, translateY]);
67+
68+
const dragStartY = useRef(0);
69+
const panRef = useRef<GestureType | undefined>(undefined);
70+
const scrollRef = useRef<GestureType | undefined>(undefined);
71+
72+
const nativeScroll = useMemo(() => Gesture.Native().withRef(scrollRef), []);
73+
74+
const pan = Gesture.Pan()
75+
.withRef(panRef)
76+
.requireExternalGestureToFail(scrollRef)
77+
.onBegin(() => {
78+
dragStartY.current = 0;
79+
})
80+
.onUpdate((e) => {
81+
if (dragStartY.current === 0) dragStartY.current = translateY.value;
82+
const next = dragStartY.current + e.translationY;
83+
const minY = bottomSheetY;
84+
const maxY = maxTranslateY;
85+
const overTop = next < minY;
86+
const overBottom = next > maxY;
87+
translateY.value = overTop
88+
? minY - (minY - next) * 0.2
89+
: overBottom
90+
? maxY + (next - maxY) * 0.2
91+
: next;
92+
})
93+
.onEnd((e) => {
94+
// very basic momentum bias
95+
const projected = e.translationY + e.velocityY * 0.15;
96+
const nearest =
97+
projected >= bottomSheetHeight * 0.5 ? maxTranslateY : bottomSheetY;
98+
translateY.value = withTiming(nearest, SPRING, (finished) => {
99+
if (finished && nearest === maxTranslateY)
100+
runOnJS(closeSheetInternal)();
101+
});
102+
});
103+
104+
const backdropStyle = useAnimatedStyle(() => {
105+
const minY = bottomSheetY;
106+
const t =
107+
1 -
108+
Math.min(
109+
1,
110+
Math.max(0, (translateY.value - minY) / (maxTranslateY - minY)),
111+
);
112+
return {
113+
opacity: t * BACKDROP_OPACITY,
114+
pointerEvents: t > 0.02 ? 'auto' : ('none' as any),
115+
};
116+
});
117+
118+
const sheetStyle = useAnimatedStyle(() => ({
119+
transform: [{ translateY: translateY.value }],
120+
}));
121+
122+
const sheetContentStyle = useMemo(
123+
() => ({ height: bottomSheetHeight }),
124+
[bottomSheetHeight],
125+
);
126+
127+
return (
128+
<Modal visible={bottomSheetModalOpen} transparent={true}>
129+
<GestureHandlerRootView style={styles.gestureHandlerRootView}>
130+
<View style={StyleSheet.absoluteFill} pointerEvents={'box-none'}>
131+
<Animated.View
132+
style={[StyleSheet.absoluteFill, styles.backdrop, backdropStyle]}
133+
>
134+
<Pressable
135+
style={StyleSheet.absoluteFill}
136+
onPress={closeSheetInternal}
137+
/>
138+
</Animated.View>
139+
140+
<GestureDetector gesture={pan}>
141+
<Animated.View style={[styles.sheet, sheetStyle]}>
142+
<View style={styles.handleContainer} pointerEvents={'none'}>
143+
<View style={styles.handle} />
144+
</View>
145+
146+
<View style={[styles.content, sheetContentStyle]}>
147+
<View style={styles.contentContainer}>
148+
{open ? (
149+
<GestureDetector gesture={nativeScroll}>
150+
{children}
151+
</GestureDetector>
152+
) : null}
153+
</View>
154+
</View>
155+
</Animated.View>
156+
</GestureDetector>
157+
</View>
158+
</GestureHandlerRootView>
159+
</Modal>
160+
);
161+
}
162+
163+
const styles = StyleSheet.create({
164+
gestureHandlerRootView: { flex: 1 },
165+
backdrop: { backgroundColor: '#000' },
166+
sheet: {
167+
position: 'absolute',
168+
overflow: 'hidden',
169+
left: 0,
170+
right: 0,
171+
top: 0,
172+
height: SCREEN_H,
173+
transform: [{ translateY: SCREEN_H }],
174+
borderTopLeftRadius: 20,
175+
borderTopRightRadius: 20,
176+
backgroundColor: 'white',
177+
...Platform.select({
178+
ios: {
179+
shadowColor: '#000',
180+
shadowOpacity: 0.2,
181+
shadowOffset: { width: 0, height: -4 },
182+
shadowRadius: 12,
183+
},
184+
android: { elevation: 18 },
185+
}),
186+
},
187+
handleContainer: { alignItems: 'center', paddingVertical: 8 },
188+
handle: { width: 40, height: 4, borderRadius: 2, backgroundColor: '#444' },
189+
content: { flexGrow: 0 },
190+
contentContainer: { flex: 1, minHeight: 0 },
191+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import {
2+
type BottomSheetState,
3+
closeSheet,
4+
openSheet,
5+
store,
6+
} from '../../store/bottom-sheet-state-store';
7+
import { useStateStore } from '@stream-io/state-store/react-bindings';
8+
import { useStableCallback } from '../../internal/hooks/useStableCallback';
9+
10+
const selector = ({ open, height }: BottomSheetState) => ({
11+
open,
12+
height,
13+
});
14+
15+
export const useBottomSheetState = () => {
16+
const data = useStateStore(store, selector);
17+
18+
const openSheetInternal = useStableCallback(() => {
19+
openSheet();
20+
});
21+
22+
const closeSheetInternal = useStableCallback(() => {
23+
closeSheet();
24+
});
25+
26+
return {
27+
...data,
28+
openSheet: openSheetInternal,
29+
closeSheet: closeSheetInternal,
30+
};
31+
};

packages/react-native-sdk/src/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22
export * from './markdown';
33
export * from './syntax-highlighting';
44
export * from './charts';
5+
export * from './message-composer';
6+
export * from './MarkdownRichText';
57

68
// components
79
export * from './components';
8-
export * from './MarkdownRichText';
10+
11+
// services
12+
export * from './services';
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import React from 'react';
2+
3+
import { type IconProps, RootPath, RootSvg } from './utils/base';
4+
5+
export const Camera = (props: IconProps) => (
6+
<RootSvg {...props}>
7+
<RootPath
8+
d="M15 3H9L7 5H3a1 1 0 00-1 1v14a1 1 0 001 1h18a1 1 0 001-1V6a1 1 0 00-1-1h-4l-2-2zM7.828 7l2-2h4.344l2 2H20v12H4V7h3.828zM12 18a5.5 5.5 0 110-11 5.5 5.5 0 010 11zm3.5-5.5a3.5 3.5 0 11-7 0 3.5 3.5 0 017 0z"
9+
{...props}
10+
/>
11+
</RootSvg>
12+
);
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import React from 'react';
2+
3+
import { type IconProps, RootPath, RootSvg } from './utils/base';
4+
5+
export const Close = (props: IconProps) => (
6+
<RootSvg {...props}>
7+
<RootPath
8+
d='M7.05 7.05a1 1 0 000 1.414L10.586 12 7.05 15.536a1 1 0 101.414 1.414L12 13.414l3.536 3.536a1 1 0 001.414-1.414L13.414 12l3.536-3.536a1 1 0 00-1.414-1.414L12 10.586 8.464 7.05a1 1 0 00-1.414 0z'
9+
{...props}
10+
/>
11+
</RootSvg>
12+
);
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import React from 'react';
2+
3+
import { type IconProps, RootPath, RootSvg } from './utils/base';
4+
5+
export const Folder = (props: IconProps) => (
6+
<RootSvg {...props}>
7+
<RootPath
8+
d="M21 5h-8.586l-2-2H3a1 1 0 00-1 1v16a1 1 0 001 1h18a1 1 0 001-1V6a1 1 0 00-1-1zM4 19V7h16v12H4z"
9+
{...props}
10+
/>
11+
</RootSvg>
12+
);
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import React from 'react';
2+
3+
import { G, Path, Svg } from 'react-native-svg';
4+
5+
import type { IconProps } from './utils/base';
6+
7+
type Props = IconProps & {
8+
size: number;
9+
};
10+
11+
export const Mic = ({ size, ...rest }: Props) => (
12+
<Svg height={size} viewBox={`0 0 ${size} ${size}`} width={size} {...rest}>
13+
<G id="24 / mic_iOS">
14+
<Path
15+
d="M11.5234 13.9922C11.5234 16.8047 13.375 18.7734 16 18.7734C18.6367 18.7734 20.4766 16.8047 20.4766 13.9922V6.69141C20.4766 3.86719 18.6367 1.89844 16 1.89844C13.375 1.89844 11.5234 3.86719 11.5234 6.69141V13.9922ZM13.7383 14.0273V6.64453C13.7383 5.08594 14.6172 4.01953 16 4.01953C17.3828 4.01953 18.2617 5.08594 18.2617 6.64453V14.0273C18.2617 15.5977 17.3828 16.6523 16 16.6523C14.6172 16.6523 13.7383 15.5977 13.7383 14.0273ZM7.10547 14.5078C7.10547 19.3242 10.3047 22.6289 14.9336 23.0742V25.4766H10.6211C10.0234 25.4766 9.51953 25.9688 9.51953 26.5781C9.51953 27.1758 10.0234 27.668 10.6211 27.668H21.3789C21.9883 27.668 22.4922 27.1758 22.4922 26.5781C22.4922 25.9688 21.9883 25.4766 21.3789 25.4766H17.0781V23.0742C21.6953 22.6289 24.9062 19.3242 24.9062 14.5078V12.1875C24.9062 11.5781 24.4141 11.0977 23.8047 11.0977C23.1953 11.0977 22.6914 11.5781 22.6914 12.1875V14.4258C22.6914 18.4102 19.9961 21.0469 16 21.0469C12.0039 21.0469 9.30859 18.4102 9.30859 14.4258V12.1875C9.30859 11.5781 8.81641 11.0977 8.20703 11.0977C7.59766 11.0977 7.10547 11.5781 7.10547 12.1875V14.5078Z"
16+
{...rest}
17+
/>
18+
</G>
19+
</Svg>
20+
);
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import React from 'react';
2+
3+
import { type IconProps, RootPath, RootSvg } from './utils/base';
4+
5+
export const Picture = (props: IconProps) => (
6+
<RootSvg {...props}>
7+
<RootPath
8+
d="M2 8a3 3 0 013-3h14a3 3 0 013 3v8a3 3 0 01-3 3H5a3 3 0 01-3-3V8zm3-1a1 1 0 00-1 1v8a1 1 0 001 1h14a1 1 0 001-1V8a1 1 0 00-1-1H5z"
9+
{...props}
10+
/>
11+
<RootPath
12+
d="M15.99 9a1 1 0 01.778.36l5 6a1 1 0 11-1.536 1.28l-4.216-5.059-3.235 4.044a1 1 0 01-1.381.175l-3.306-2.48-3.387 3.387a1 1 0 01-1.414-1.414l4-4A1 1 0 018.6 11.2l3.225 2.418 3.394-4.243A1 1 0 0115.99 9z"
13+
{...props}
14+
/>
15+
</RootSvg>
16+
);

0 commit comments

Comments
 (0)