diff --git a/packages/react-native-sdk/android/src/main/java/com/streamio/aicomponents/renderkit/PerfTextViewManager.kt b/packages/react-native-sdk/android/src/main/java/com/streamio/aicomponents/renderkit/PerfTextViewManager.kt index 87192c9..62f1b0d 100644 --- a/packages/react-native-sdk/android/src/main/java/com/streamio/aicomponents/renderkit/PerfTextViewManager.kt +++ b/packages/react-native-sdk/android/src/main/java/com/streamio/aicomponents/renderkit/PerfTextViewManager.kt @@ -53,7 +53,6 @@ class PerfTextViewManager : SimpleViewManager() { if (sizeSp > 0) v.textSize = sizeSp.toFloat() } - @ReactProp(name = "lineHeight") fun setLineHeight(v: PerfTextView, height: Double) { val targetPx = PixelUtil.toPixelFromDIP(height.toFloat()) @@ -65,8 +64,6 @@ class PerfTextViewManager : SimpleViewManager() { }; } - - @ReactProp(name = "fontFamily") fun setFontFamily(v: PerfTextView, family: String?) { v.setFontFamilyCompat(family) @@ -138,11 +135,10 @@ class PerfTextViewManager : SimpleViewManager() { view.measure(widthSpec, heightSpec) - val measuredWidthDp = ceil(PixelUtil.toDIPFromPixel(view.measuredWidth.toFloat()).toDouble()).toInt() - val measuredHeightDp = ceil(PixelUtil.toDIPFromPixel(view.measuredHeight.toFloat()).toDouble()).toInt() + val measuredWidthDp = ceil(PixelUtil.toDIPFromPixel(view.measuredWidth.toFloat())) + val measuredHeightDp = ceil(PixelUtil.toDIPFromPixel(view.measuredHeight.toFloat())) return YogaMeasureOutput.make(measuredWidthDp * 1.5f, measuredHeightDp * 1.0f) - } } diff --git a/packages/react-native-sdk/package.json b/packages/react-native-sdk/package.json index 8ad3acd..1025cc0 100644 --- a/packages/react-native-sdk/package.json +++ b/packages/react-native-sdk/package.json @@ -56,6 +56,7 @@ "license": "SEE LICENSE IN LICENSE", "dependencies": { "@khanacademy/simple-markdown": "^2.1.0", + "@stream-io/state-store": "^1.1.6", "linkifyjs": "^4.3.2", "lodash": "4.17.21", "react-syntax-highlighter": "15.5.0", @@ -68,10 +69,12 @@ "@types/react": "19.2.2", "@types/react-syntax-highlighter": "^15.5.13", "concurrently": "catalog:", + "expo-image-picker": "^17.0.8", "react": "19.2.0", "react-native": "^0.82.1", "react-native-builder-bob": "0.40.14", "react-native-gesture-handler": "^2.29.0", + "react-native-image-picker": "^8.2.1", "react-native-reanimated": "^4.1.3", "rimraf": "^6.0.1", "typescript": "catalog:", diff --git a/packages/react-native-sdk/src/components/BottomSheet.tsx b/packages/react-native-sdk/src/components/BottomSheet.tsx new file mode 100644 index 0000000..ee2923c --- /dev/null +++ b/packages/react-native-sdk/src/components/BottomSheet.tsx @@ -0,0 +1,191 @@ +import React, { + type PropsWithChildren, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { + Dimensions, + Modal, + Platform, + Pressable, + StyleSheet, + View, +} from 'react-native'; +import { + Gesture, + GestureDetector, + GestureHandlerRootView, + type GestureType, +} from 'react-native-gesture-handler'; +import Animated, { + runOnJS, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; +import { useBottomSheetState } from './hooks/useBottomSheetState'; +import { closeSheet as closeSheetInternal } from '../store/bottom-sheet-state-store'; + +type Props = PropsWithChildren; + +const { height: SCREEN_H } = Dimensions.get('window'); +const SPRING = { duration: 200 }; +const MAX_BOTTOM_SHEET_HEIGHT = Math.round(SCREEN_H * 0.4); +const BACKDROP_OPACITY = 0.4; + +export function BottomSheet({ children }: Props) { + const { open, height } = useBottomSheetState(); + const [bottomSheetModalOpen, setBottomSheetModalOpen] = useState(open); + const maxTranslateY = SCREEN_H; + const translateY = useSharedValue(maxTranslateY); + + const bottomSheetHeight = useMemo( + () => Math.min(height, MAX_BOTTOM_SHEET_HEIGHT), + [height], + ); + const bottomSheetY = SCREEN_H - bottomSheetHeight; + + useEffect(() => { + if (open) { + setBottomSheetModalOpen(true); + } else { + translateY.value = withTiming(maxTranslateY, SPRING, (finished) => { + if (finished) { + runOnJS(setBottomSheetModalOpen)(false); + } + }); + } + }, [setBottomSheetModalOpen, maxTranslateY, open, translateY]); + + useEffect(() => { + if (bottomSheetModalOpen && open) { + translateY.value = withTiming(bottomSheetY, SPRING); + } + }, [open, bottomSheetModalOpen, bottomSheetY, translateY]); + + const dragStartY = useRef(0); + const panRef = useRef(undefined); + const scrollRef = useRef(undefined); + + const nativeScroll = useMemo(() => Gesture.Native().withRef(scrollRef), []); + + const pan = Gesture.Pan() + .withRef(panRef) + .requireExternalGestureToFail(scrollRef) + .onBegin(() => { + dragStartY.current = 0; + }) + .onUpdate((e) => { + if (dragStartY.current === 0) dragStartY.current = translateY.value; + const next = dragStartY.current + e.translationY; + const minY = bottomSheetY; + const maxY = maxTranslateY; + const overTop = next < minY; + const overBottom = next > maxY; + translateY.value = overTop + ? minY - (minY - next) * 0.2 + : overBottom + ? maxY + (next - maxY) * 0.2 + : next; + }) + .onEnd((e) => { + // very basic momentum bias + const projected = e.translationY + e.velocityY * 0.15; + const nearest = + projected >= bottomSheetHeight * 0.5 ? maxTranslateY : bottomSheetY; + translateY.value = withTiming(nearest, SPRING, (finished) => { + if (finished && nearest === maxTranslateY) + runOnJS(closeSheetInternal)(); + }); + }); + + const backdropStyle = useAnimatedStyle(() => { + const minY = bottomSheetY; + const t = + 1 - + Math.min( + 1, + Math.max(0, (translateY.value - minY) / (maxTranslateY - minY)), + ); + return { + opacity: t * BACKDROP_OPACITY, + pointerEvents: t > 0.02 ? 'auto' : ('none' as any), + }; + }); + + const sheetStyle = useAnimatedStyle(() => ({ + transform: [{ translateY: translateY.value }], + })); + + const sheetContentStyle = useMemo( + () => ({ height: bottomSheetHeight }), + [bottomSheetHeight], + ); + + return ( + + + + + + + + + + + + + + + + {open ? ( + + {children} + + ) : null} + + + + + + + + ); +} + +const styles = StyleSheet.create({ + gestureHandlerRootView: { flex: 1 }, + backdrop: { backgroundColor: '#000' }, + sheet: { + position: 'absolute', + overflow: 'hidden', + left: 0, + right: 0, + top: 0, + height: SCREEN_H, + transform: [{ translateY: SCREEN_H }], + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + backgroundColor: 'white', + ...Platform.select({ + ios: { + shadowColor: '#000', + shadowOpacity: 0.2, + shadowOffset: { width: 0, height: -4 }, + shadowRadius: 12, + }, + android: { elevation: 18 }, + }), + }, + handleContainer: { alignItems: 'center', paddingVertical: 8 }, + handle: { width: 40, height: 4, borderRadius: 2, backgroundColor: '#444' }, + content: { flexGrow: 0 }, + contentContainer: { flex: 1, minHeight: 0 }, +}); diff --git a/packages/react-native-sdk/src/components/hooks/useBottomSheetState.ts b/packages/react-native-sdk/src/components/hooks/useBottomSheetState.ts new file mode 100644 index 0000000..78434d3 --- /dev/null +++ b/packages/react-native-sdk/src/components/hooks/useBottomSheetState.ts @@ -0,0 +1,31 @@ +import { + type BottomSheetState, + closeSheet, + openSheet, + store, +} from '../../store/bottom-sheet-state-store'; +import { useStateStore } from '@stream-io/state-store/react-bindings'; +import { useStableCallback } from '../../internal/hooks/useStableCallback'; + +const selector = ({ open, height }: BottomSheetState) => ({ + open, + height, +}); + +export const useBottomSheetState = () => { + const data = useStateStore(store, selector); + + const openSheetInternal = useStableCallback(() => { + openSheet(); + }); + + const closeSheetInternal = useStableCallback(() => { + closeSheet(); + }); + + return { + ...data, + openSheet: openSheetInternal, + closeSheet: closeSheetInternal, + }; +}; diff --git a/packages/react-native-sdk/src/index.ts b/packages/react-native-sdk/src/index.ts index 2b91220..991a0a8 100644 --- a/packages/react-native-sdk/src/index.ts +++ b/packages/react-native-sdk/src/index.ts @@ -2,7 +2,11 @@ export * from './markdown'; export * from './syntax-highlighting'; export * from './charts'; +export * from './message-composer'; +export * from './MarkdownRichText'; // components export * from './components'; -export * from './MarkdownRichText'; + +// services +export * from './services'; diff --git a/packages/react-native-sdk/src/internal/icons/Camera.tsx b/packages/react-native-sdk/src/internal/icons/Camera.tsx new file mode 100644 index 0000000..6597fe4 --- /dev/null +++ b/packages/react-native-sdk/src/internal/icons/Camera.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +import { type IconProps, RootPath, RootSvg } from './utils/base'; + +export const Camera = (props: IconProps) => ( + + + +); diff --git a/packages/react-native-sdk/src/internal/icons/Close.tsx b/packages/react-native-sdk/src/internal/icons/Close.tsx new file mode 100644 index 0000000..3f8f4bb --- /dev/null +++ b/packages/react-native-sdk/src/internal/icons/Close.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +import { type IconProps, RootPath, RootSvg } from './utils/base'; + +export const Close = (props: IconProps) => ( + + + +); diff --git a/packages/react-native-sdk/src/internal/icons/Folder.tsx b/packages/react-native-sdk/src/internal/icons/Folder.tsx new file mode 100644 index 0000000..342941f --- /dev/null +++ b/packages/react-native-sdk/src/internal/icons/Folder.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +import { type IconProps, RootPath, RootSvg } from './utils/base'; + +export const Folder = (props: IconProps) => ( + + + +); diff --git a/packages/react-native-sdk/src/internal/icons/Mic.tsx b/packages/react-native-sdk/src/internal/icons/Mic.tsx new file mode 100644 index 0000000..f022a9f --- /dev/null +++ b/packages/react-native-sdk/src/internal/icons/Mic.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +import { G, Path, Svg } from 'react-native-svg'; + +import type { IconProps } from './utils/base'; + +type Props = IconProps & { + size: number; +}; + +export const Mic = ({ size, ...rest }: Props) => ( + + + + + +); diff --git a/packages/react-native-sdk/src/internal/icons/Picture.tsx b/packages/react-native-sdk/src/internal/icons/Picture.tsx new file mode 100644 index 0000000..dda8160 --- /dev/null +++ b/packages/react-native-sdk/src/internal/icons/Picture.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import { type IconProps, RootPath, RootSvg } from './utils/base'; + +export const Picture = (props: IconProps) => ( + + + + +); diff --git a/packages/react-native-sdk/src/internal/icons/SendUp.tsx b/packages/react-native-sdk/src/internal/icons/SendUp.tsx new file mode 100644 index 0000000..e8a64d8 --- /dev/null +++ b/packages/react-native-sdk/src/internal/icons/SendUp.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +import Svg, { Circle, Path } from 'react-native-svg'; + +import type { IconProps } from './utils/base'; + +type Props = IconProps & { + size: number; +}; + +export const SendUp = ({ size, ...rest }: Props) => ( + + {/**/} + + +); diff --git a/packages/react-native-sdk/src/internal/icons/utils/base.tsx b/packages/react-native-sdk/src/internal/icons/utils/base.tsx new file mode 100644 index 0000000..d7e6658 --- /dev/null +++ b/packages/react-native-sdk/src/internal/icons/utils/base.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import Svg, { Path, type PathProps, type SvgProps } from 'react-native-svg'; + +export type IconProps = Partial & + Omit & { + height?: number; + width?: number; + }; + +export const RootSvg = (props: IconProps) => { + const { children, height = 24, viewBox = '0 0 24 24', width = 24 } = props; + return ( + + {children} + + ); +}; + +export type RootPathProps = Pick & { + pathFill?: SvgProps['fill']; + pathOpacity?: PathProps['opacity']; +}; + +export const RootPath = (props: RootPathProps) => { + const { d, pathFill = 'black', pathOpacity } = props; + return ( + + ); +}; diff --git a/packages/react-native-sdk/src/message-composer/ActionSheet.tsx b/packages/react-native-sdk/src/message-composer/ActionSheet.tsx new file mode 100644 index 0000000..13e249b --- /dev/null +++ b/packages/react-native-sdk/src/message-composer/ActionSheet.tsx @@ -0,0 +1,192 @@ +// BottomSheetContent.tsx +import React from 'react'; +import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import type { + AIMessageComposerProps, + BottomSheetOption, +} from './MessageComposer.tsx'; +import { setHeight } from '../store/bottom-sheet-state-store.ts'; +import { Camera } from '../internal/icons/Camera.tsx'; +import { Picture } from '../internal/icons/Picture.tsx'; +import { Folder } from '../internal/icons/Folder.tsx'; +import type { AbstractMediaPickerService } from '../services/media-picker-service/AbstractMediaPickerService'; +import { withCloseSheet } from './utils/withCloseSheet.ts'; + +type BottomSheetContentProps = Pick< + AIMessageComposerProps, + 'bottomSheetInsets' | 'bottomSheetOptions' +> & { mediaPickerService?: AbstractMediaPickerService }; + +export const BottomSheetContent = ({ + bottomSheetInsets, + bottomSheetOptions, + mediaPickerService, +}: BottomSheetContentProps) => { + return ( + setHeight(height)} + > + + mediaPickerService?.takeMedia({}))} + /> + mediaPickerService?.pickMedia({}))} + /> + + + + {bottomSheetOptions.length > 0 ? ( + <> + + + {bottomSheetOptions.map((option, index) => ( + + ))} + + + ) : null} + + ); +}; + +type QuickActionButtonProps = { + label: string; + Icon: React.ComponentType; + onPress?: () => void; +}; + +export const QuickActionButton = ({ + label, + Icon, + onPress, +}: QuickActionButtonProps) => ( + [ + styles.quickAction, + pressed && styles.quickActionPressed, + ]} + > + + {label} + +); + +export type ListItemProps = { + option: BottomSheetOption; +}; + +export const ListItem = ({ option }: ListItemProps) => { + const { title, subtitle, action, Icon } = option; + return ( + [ + styles.listItem, + pressed && styles.listItemPressed, + ]} + > + {Icon ? ( + + + + ) : null} + + + {title} + {subtitle} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + contentContainer: { + paddingHorizontal: 16, + paddingTop: 12, + }, + quickActionsCard: { + flexDirection: 'row', + justifyContent: 'center', + gap: 12, + borderRadius: 18, + marginBottom: 16, + }, + quickAction: { + flex: 1, + alignItems: 'center', + padding: 16, + borderRadius: 14, + marginHorizontal: 4, + backgroundColor: '#F4F4F6', + }, + quickActionPressed: { + opacity: 0.7, + }, + quickActionIconPlaceholder: { + width: 24, + height: 24, + borderRadius: 12, + borderWidth: 1, + borderColor: '#C7C7CC', + marginBottom: 6, + }, + quickActionLabel: { + fontSize: 13, + fontWeight: '500', + color: '#111111', + marginTop: 4, + }, + listSection: { + marginTop: 4, + borderRadius: 18, + backgroundColor: '#FFFFFF', + paddingHorizontal: 12, + paddingVertical: 4, + }, + listItem: { + flexDirection: 'row', + alignItems: 'flex-start', + paddingVertical: 10, + }, + listItemPressed: { + opacity: 0.7, + }, + listIcon: { + width: 24, + height: 24, + marginRight: 12, + marginTop: 2, + }, + listTextContainer: { + flex: 1, + }, + listTitle: { + fontSize: 15, + fontWeight: '500', + color: '#111111', + }, + listSubtitle: { + marginTop: 2, + fontSize: 13, + color: '#6C6C70', + }, + divider: { + height: StyleSheet.hairlineWidth, + backgroundColor: '#E5E5EA', + marginTop: 10, + }, +}); diff --git a/packages/react-native-sdk/src/message-composer/MessageComposer.tsx b/packages/react-native-sdk/src/message-composer/MessageComposer.tsx new file mode 100644 index 0000000..b5bfeb9 --- /dev/null +++ b/packages/react-native-sdk/src/message-composer/MessageComposer.tsx @@ -0,0 +1,287 @@ +import React, { useState } from 'react'; +import { + Image, + Platform, + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; +import { openSheet } from '../store/bottom-sheet-state-store'; +import { BottomSheet } from '../components/BottomSheet'; +import { BottomSheetContent } from './ActionSheet'; +import { Mic } from '../internal/icons/Mic'; +import { SendUp } from '../internal/icons/SendUp'; + +import Animated, { + FadeIn, + FadeOut, + LinearTransition, + ZoomIn, + ZoomOut, +} from 'react-native-reanimated'; +import { MediaPickerService } from '../services'; +import { useMediaPickerState } from '../services/media-picker-service/hooks/useMediaPickerState.ts'; +import type { AbstractMediaPickerService } from '../services/media-picker-service/AbstractMediaPickerService.ts'; +import { type MediaPickerState } from '../services/media-picker-service/AbstractMediaPickerService.ts'; +import { useStableCallback } from '../internal/hooks/useStableCallback.ts'; +import { Close } from '../internal/icons/Close.tsx'; + +export type BottomSheetOption = { + title: string; + action: () => void | Promise; + subtitle?: string; + Icon?: React.ComponentType; +}; + +export type AIMessageComposerProps = { + bottomSheetInsets?: { + top: number; + bottom: number; + left: number; + right: number; + }; + bottomSheetOptions: BottomSheetOption[]; + onSendMessage: (opts: { + text: string; + attachments?: MediaPickerState['assets']; + }) => Promise; +}; + +export const AIMessageComposer = ({ + bottomSheetInsets, + bottomSheetOptions = [], + onSendMessage, +}: AIMessageComposerProps) => { + const [mediaPickerService] = useState(() => + MediaPickerService ? new MediaPickerService() : undefined, + ); + const [text, setText] = useState(''); + + const clearState = useStableCallback(() => { + setText(''); + mediaPickerService?.clearAssets(); + }); + + const sendMessage = useStableCallback(async () => { + const data = { + text, + attachments: mediaPickerService?.state.getLatestValue().assets, + }; + + clearState(); + + await onSendMessage(data); + }); + + return ( + <> + + + + + + + + + + + + + {text && text.length > 0 ? ( + + + + + + + + ) : ( + + + + + + + + )} + + + + + + + + + ); +}; + +export const MediaPreviewList = ({ + mediaPickerService, +}: { + mediaPickerService?: AbstractMediaPickerService; +}) => { + const { attachments } = + useMediaPickerState({ service: mediaPickerService }) ?? {}; + + return ( + + {(attachments ?? []).map((attachment, index) => ( + + + mediaPickerService?.removeAsset(index)} + > + + + + ))} + + ); +}; + +const PILL_HEIGHT = 52; + +const styles = StyleSheet.create({ + absoluteContainer: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + paddingHorizontal: 12, + paddingBottom: 8, + }, + row: { + flexDirection: 'row', + alignItems: 'flex-end', + }, + roundButton: { + width: PILL_HEIGHT, + height: PILL_HEIGHT, + borderRadius: PILL_HEIGHT / 2, + backgroundColor: '#F0F0F0', + justifyContent: 'center', + alignItems: 'center', + marginRight: 8, + }, + plusIcon: { + backgroundColor: '#555', + justifyContent: 'center', + alignItems: 'center', + }, + inputPill: { + alignItems: 'center', + flexDirection: 'row', + maxHeight: PILL_HEIGHT * 3, + }, + inputPillContainer: { + flex: 1, + minHeight: PILL_HEIGHT, + alignItems: 'center', + borderRadius: PILL_HEIGHT / 2, + backgroundColor: '#F5F5F5', + paddingHorizontal: 14, + paddingVertical: Platform.OS === 'ios' ? 10 : 6, + shadowColor: '#000', + shadowOpacity: 0.08, + shadowOffset: { width: 0, height: 1 }, + shadowRadius: 4, + elevation: 2, + }, + textInput: { + flex: 1, + fontSize: 16, + color: '#111', + paddingVertical: 0, + paddingHorizontal: 0, + marginRight: 24, + }, + iconButton: { + justifyContent: 'center', + alignItems: 'center', + }, + attachIcon: { + fontSize: 32, + textAlign: 'center', + alignSelf: 'center', + lineHeight: 32, + color: '#7A7A7A', + }, + micIcon: { + width: 32, + height: 32, + justifyContent: 'center', + alignItems: 'center', + borderColor: '#777', + }, + sendIcon: { + width: 32, + height: 32, + backgroundColor: 'black', + borderRadius: 16, + }, + voiceButton: { + marginLeft: 4, + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: '#000', + justifyContent: 'center', + alignItems: 'center', + }, + mediaPreviewStyle: { + width: '100%', + }, + mediaPreviewContentContainerStyle: { flexGrow: 1 }, + mediaPreviewImage: { borderRadius: 12, marginRight: 8 }, + mediaPreviewRemoveButton: { + position: 'absolute', + top: 8, + right: 12, + backgroundColor: '#000000CC', + borderRadius: 24, + }, +}); diff --git a/packages/react-native-sdk/src/message-composer/index.ts b/packages/react-native-sdk/src/message-composer/index.ts new file mode 100644 index 0000000..c441da4 --- /dev/null +++ b/packages/react-native-sdk/src/message-composer/index.ts @@ -0,0 +1 @@ +export * from './MessageComposer'; diff --git a/packages/react-native-sdk/src/message-composer/utils/withCloseSheet.ts b/packages/react-native-sdk/src/message-composer/utils/withCloseSheet.ts new file mode 100644 index 0000000..e8509fd --- /dev/null +++ b/packages/react-native-sdk/src/message-composer/utils/withCloseSheet.ts @@ -0,0 +1,13 @@ +import { closeSheet } from '../../store/bottom-sheet-state-store.ts'; + +export const withCloseSheet = ( + callback: ( + ...args: T + ) => void | Promise | unknown | Promise | undefined, +) => { + return async (...args: T) => { + const result = await callback(...args); + closeSheet(); + return result; + }; +}; diff --git a/packages/react-native-sdk/src/services/index.ts b/packages/react-native-sdk/src/services/index.ts new file mode 100644 index 0000000..4dfb650 --- /dev/null +++ b/packages/react-native-sdk/src/services/index.ts @@ -0,0 +1 @@ +export * from './media-picker-service'; diff --git a/packages/react-native-sdk/src/services/media-picker-service/AbstractMediaPickerService.ts b/packages/react-native-sdk/src/services/media-picker-service/AbstractMediaPickerService.ts new file mode 100644 index 0000000..3abccdc --- /dev/null +++ b/packages/react-native-sdk/src/services/media-picker-service/AbstractMediaPickerService.ts @@ -0,0 +1,66 @@ +import { StateStore } from '@stream-io/state-store'; + +export type MediaAsset = { + uri: string; + width: number; + height: number; + name?: string; + size?: number; + type?: string; +}; + +export type MediaPickerState = { + assets: MediaAsset[]; +}; + +export type PickMediaOpts = { maxNumberOfFiles?: number }; +export type PickMediaReturnType = { + assets?: MediaAsset[]; + cancelled?: boolean; + askToOpenSettings?: boolean; +}; +export type PickMediaApi = ( + opts: PickMediaOpts, +) => Promise; + +export type TakeMediaOpts = { compressionQuality?: number }; +export type TakeMediaReturnType = { + asset?: MediaAsset; + cancelled?: boolean; + askToOpenSettings?: boolean; +}; +export type TakeMediaApi = ( + opts: TakeMediaOpts, +) => Promise; + +export abstract class AbstractMediaPickerService { + public state: StateStore; + + constructor() { + this.state = new StateStore({ + assets: [], + }); + } + + abstract pickMedia: PickMediaApi; + + abstract takeMedia: TakeMediaApi; + + appendAssets = (assets: MediaAsset[]) => { + this.state.next((prevState) => ({ + ...prevState, + assets: [...prevState.assets, ...assets], + })); + }; + + removeAsset = (index: number) => { + this.state.next((prevState) => ({ + ...prevState, + assets: prevState.assets.filter((_, i) => i !== index), + })); + }; + + clearAssets = () => { + this.state.partialNext({ assets: [] }); + }; +} diff --git a/packages/react-native-sdk/src/services/media-picker-service/expo.ts b/packages/react-native-sdk/src/services/media-picker-service/expo.ts new file mode 100644 index 0000000..2bdd203 --- /dev/null +++ b/packages/react-native-sdk/src/services/media-picker-service/expo.ts @@ -0,0 +1,165 @@ +import { + AbstractMediaPickerService, + type MediaAsset, + type PickMediaOpts, +} from './AbstractMediaPickerService.ts'; +import { Image, Platform } from 'react-native'; +import type { ImagePickerAsset } from 'expo-image-picker'; + +let internalService = undefined; + +try { + // eslint-disable-next-line + internalService = require('expo-image-picker'); +} catch (_error) { + /* do nothing */ +} + +type Size = { + height?: number; + width?: number; +}; + +class ExpoMediaPickerService extends AbstractMediaPickerService { + pickMedia = async ({ maxNumberOfFiles = 3 }: PickMediaOpts = {}) => { + try { + let permissionGranted = true; + if (Platform.OS === 'ios') { + const permissionCheck = + await internalService!.getMediaLibraryPermissionsAsync(); + const canRequest = permissionCheck.canAskAgain; + permissionGranted = permissionCheck.granted; + if (!permissionGranted) { + if (canRequest) { + const response = + await internalService!.requestMediaLibraryPermissionsAsync(); + permissionGranted = response.granted; + } else { + return { askToOpenSettings: true, cancelled: true }; + } + } + } + if (permissionGranted) { + const result = await internalService!.launchImageLibraryAsync({ + allowsMultipleSelection: true, + mediaTypes: 'images', + preferredAssetRepresentationMode: 'current', + selectionLimit: maxNumberOfFiles, + }); + + const canceled = result.canceled; + + if (!canceled) { + const assets = result.assets.map((asset: ImagePickerAsset) => ({ + ...asset, + name: asset.fileName, + size: asset.fileSize, + type: asset.mimeType, + uri: asset.uri, + })); + this.appendAssets(assets); + return { assets, cancelled: false }; + } else { + return { cancelled: true }; + } + } + return { cancelled: true }; + } catch (error) { + console.log('Error while picking image', error); + return { cancelled: true }; + } + }; + + takeMedia = async ({ compressionQuality = 1 }) => { + try { + const permissionCheck = + await internalService!.getCameraPermissionsAsync(); + const canRequest = permissionCheck.canAskAgain; + let permissionGranted = permissionCheck.granted; + if (!permissionGranted) { + if (canRequest) { + const response = + await internalService!.requestCameraPermissionsAsync(); + permissionGranted = response.granted; + } else { + return { askToOpenSettings: true, cancelled: true }; + } + } + + if (permissionGranted) { + const result = await internalService!.launchCameraAsync({ + mediaTypes: 'images', + quality: Math.min(Math.max(0, compressionQuality), 1), + }); + if ( + !result || + !result.assets || + !result.assets.length || + result.canceled + ) { + return { cancelled: true }; + } + // since we only support single photo upload for now we will only be focusing on 0'th element. + const photo = result.assets[0]; + if (!photo) { + return { cancelled: true }; + } + if ( + photo && + photo.mimeType.includes('image') && + photo.height && + photo.width && + photo.uri + ) { + let size: Size = {}; + if (Platform.OS === 'android') { + const getSize = (): Promise => + new Promise((resolve) => { + Image.getSize(photo.uri, (width, height) => { + resolve({ height, width }); + }); + }); + + try { + const { height, width } = await getSize(); + size.height = height; + size.width = width; + } catch (e) { + console.warn( + 'Error get image size of picture caputred from camera ', + e, + ); + } + } else { + size = { + height: photo.height, + width: photo.width, + }; + } + const clearFilter = new RegExp('[.:]', 'g'); + const date = new Date().toISOString().replace(clearFilter, '_'); + const asset: MediaAsset = { + name: 'image_' + date + '.' + photo.uri.split('.').pop(), + size: photo.fileSize, + type: photo.mimeType, + uri: photo.uri, + height: size.height!, + width: size.width!, + }; + this.appendAssets([asset]); + return { + cancelled: false, + asset, + }; + } + } + } catch (error) { + console.log(error); + } + return { cancelled: true }; + }; +} + +const MediaPickerService = internalService ? ExpoMediaPickerService : undefined; + +export { MediaPickerService }; diff --git a/packages/react-native-sdk/src/services/media-picker-service/hooks/useMediaPickerState.ts b/packages/react-native-sdk/src/services/media-picker-service/hooks/useMediaPickerState.ts new file mode 100644 index 0000000..9c54356 --- /dev/null +++ b/packages/react-native-sdk/src/services/media-picker-service/hooks/useMediaPickerState.ts @@ -0,0 +1,15 @@ +import { + AbstractMediaPickerService, + type MediaPickerState, +} from '../AbstractMediaPickerService.ts'; +import { useStateStore } from '@stream-io/state-store/react-bindings'; + +export const selector = (nextState: MediaPickerState) => ({ + attachments: nextState.assets, +}); + +export const useMediaPickerState = ({ + service, +}: { + service: AbstractMediaPickerService | undefined; +}) => useStateStore(service?.state, selector); diff --git a/packages/react-native-sdk/src/services/media-picker-service/index.ts b/packages/react-native-sdk/src/services/media-picker-service/index.ts new file mode 100644 index 0000000..faaeb59 --- /dev/null +++ b/packages/react-native-sdk/src/services/media-picker-service/index.ts @@ -0,0 +1,5 @@ +import { MediaPickerService as ExpoMediaPickerService } from './expo'; +import { MediaPickerService as RNCLIMediaPickerService } from './rncli'; + +export const MediaPickerService = + RNCLIMediaPickerService ?? ExpoMediaPickerService; diff --git a/packages/react-native-sdk/src/services/media-picker-service/rncli.ts b/packages/react-native-sdk/src/services/media-picker-service/rncli.ts new file mode 100644 index 0000000..55f599d --- /dev/null +++ b/packages/react-native-sdk/src/services/media-picker-service/rncli.ts @@ -0,0 +1,169 @@ +import { + AbstractMediaPickerService, + type PickMediaOpts, +} from './AbstractMediaPickerService.ts'; +import { AppState, Image, PermissionsAndroid, Platform } from 'react-native'; +import type { Asset } from 'react-native-image-picker'; + +let internalService = undefined; + +try { + // eslint-disable-next-line + internalService = require('react-native-image-picker'); +} catch (_error) { + /* do nothing */ +} + +class RNCLIMediaPickerService extends AbstractMediaPickerService { + pickMedia = async ({ maxNumberOfFiles = 3 }: PickMediaOpts = {}) => { + try { + const result = await internalService!.launchImageLibrary({ + assetRepresentationMode: 'current', + mediaType: 'photo', + selectionLimit: maxNumberOfFiles, + }); + const canceled = result.didCancel; + const errorCode = result.errorCode; + + if (Platform.OS === 'ios' && errorCode === 'permission') { + return { askToOpenSettings: true, cancelled: true }; + } + if (!canceled) { + const assets = result.assets.map((asset: Asset) => ({ + ...asset, + name: asset.fileName, + size: asset.fileSize, + type: asset.type, + uri: asset.uri, + })); + this.appendAssets(assets); + return { assets, cancelled: false }; + } else { + return { cancelled: true }; + } + } catch (error) { + console.log('Error picking image: ', error); + return { cancelled: true }; + } + }; + + takeMedia = async ({ + compressionQuality = Platform.OS === 'ios' ? 0.8 : 1, + }) => { + if (Platform.OS === 'android') { + const cameraPermissions = await PermissionsAndroid.check( + PermissionsAndroid.PERMISSIONS.CAMERA, + ); + if (!cameraPermissions) { + const androidPermissionStatus = await PermissionsAndroid.request( + PermissionsAndroid.PERMISSIONS.CAMERA, + ); + if (androidPermissionStatus === PermissionsAndroid.RESULTS.DENIED) { + return { cancelled: true }; + } else if ( + androidPermissionStatus === PermissionsAndroid.RESULTS.NEVER_ASK_AGAIN + ) { + return { askToOpenSettings: true, cancelled: true }; + } + } + } + try { + const result = await internalService!.launchCamera({ + mediaType: 'photo', + quality: Math.min(Math.max(0, compressionQuality), 1), + }); + if ( + !result || + !result.assets || + !result.assets.length || + result.didCancel + ) { + return { + cancelled: true, + }; + } + const asset = result.assets[0]; + if (!asset) { + return { + cancelled: true, + }; + } + if (asset.type.includes('video')) { + const clearFilter = new RegExp('[.:]', 'g'); + const date = new Date().toISOString().replace(clearFilter, '_'); + return { + ...asset, + cancelled: false, + duration: asset.duration * 1000, + name: + 'video_recording_' + date + '.' + asset.fileName.split('.').pop(), + size: asset.fileSize, + type: asset.type, + uri: asset.uri, + }; + } else { + if (asset.height && asset.width && asset.uri) { + let size: { height?: number; width?: number } = {}; + if (Platform.OS === 'android') { + // Height and width returned by ImagePicker are incorrect on Android. + const getSize = (): Promise<{ height: number; width: number }> => + new Promise((resolve) => { + Image.getSize(asset.uri, (width, height) => { + resolve({ height, width }); + }); + }); + + try { + const { height, width } = await getSize(); + size.height = height; + size.width = width; + } catch (e) { + // do nothing + console.warn( + 'Error while getting image size of picture captured from camera ', + e, + ); + } + } else { + size = { + height: asset.height, + width: asset.width, + }; + } + const clearFilter = new RegExp('[.:]', 'g'); + const date = new Date().toISOString().replace(clearFilter, '_'); + return { + cancelled: false, + asset: { + name: 'image_' + date + '.' + asset.uri.split('.').pop(), + size: asset.fileSize, + type: asset.type, + uri: asset.uri, + ...size, + }, + }; + } + } + } catch (e: unknown) { + if (e instanceof Error) { + // on iOS: if it was in inactive state, then the user had just denied the permissions + if (Platform.OS === 'ios' && AppState.currentState === 'active') { + const cameraPermissionDeniedMsg = + 'User did not grant camera permission.'; + // Open settings when the user did not allow camera permissions + if (e.message === cameraPermissionDeniedMsg) { + return { askToOpenSettings: true, cancelled: true }; + } + } + } + } + + return { cancelled: true }; + }; +} + +const MediaPickerService = internalService + ? RNCLIMediaPickerService + : undefined; + +export { MediaPickerService }; diff --git a/packages/react-native-sdk/src/store/bottom-sheet-state-store.ts b/packages/react-native-sdk/src/store/bottom-sheet-state-store.ts new file mode 100644 index 0000000..ee5c7c4 --- /dev/null +++ b/packages/react-native-sdk/src/store/bottom-sheet-state-store.ts @@ -0,0 +1,23 @@ +import { StateStore } from '@stream-io/state-store'; +import { Keyboard } from 'react-native'; + +export type BottomSheetState = { + open: boolean; + height: number; +}; + +const DEFAULT_STATE: BottomSheetState = { + open: false, + height: Number.MAX_SAFE_INTEGER, +}; + +export const store = new StateStore(DEFAULT_STATE); + +export const openSheet = () => { + Keyboard.dismiss(); + store.partialNext({ open: true }); +}; + +export const closeSheet = () => store.partialNext(DEFAULT_STATE); + +export const setHeight = (height: number) => store.partialNext({ height }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1c8c15..2f3e71f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -209,6 +209,9 @@ importers: '@khanacademy/simple-markdown': specifier: ^2.1.0 version: 2.1.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@stream-io/state-store': + specifier: ^1.1.6 + version: 1.1.6(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0)) expo: specifier: '>=51.0.0' version: 54.0.20(@babel/core@7.28.4)(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0) @@ -246,6 +249,9 @@ importers: concurrently: specifier: 'catalog:' version: 9.2.1 + expo-image-picker: + specifier: ^17.0.8 + version: 17.0.8(expo@54.0.20(@babel/core@7.28.4)(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0)) react: specifier: 19.2.0 version: 19.2.0 @@ -258,6 +264,9 @@ importers: react-native-gesture-handler: specifier: ^2.29.0 version: 2.29.0(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0) + react-native-image-picker: + specifier: ^8.2.1 + version: 8.2.1(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0) react-native-reanimated: specifier: ^4.1.3 version: 4.1.3(@babel/core@7.28.4)(react-native-worklets@0.5.2(@babel/core@7.28.4)(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0))(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0) @@ -2047,6 +2056,17 @@ packages: '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@stream-io/state-store@1.1.6': + resolution: {integrity: sha512-4jZ7vub9EBh+zrNsA8cyW/4U2EuiCkO3T9FgZrbgorO2iBE4k8gP9GAl6w8Ikv+ZcO5jR53CeG+AA48UG3LOVQ==} + peerDependencies: + react: ^17 || ^18 || ^19 + use-sync-external-store: ^1.5.0 + peerDependenciesMeta: + react: + optional: true + use-sync-external-store: + optional: true + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -3505,6 +3525,16 @@ packages: react: '*' react-native: '*' + expo-image-loader@6.0.0: + resolution: {integrity: sha512-nKs/xnOGw6ACb4g26xceBD57FKLFkSwEUTDXEDF3Gtcu3MqF3ZIYd3YM+sSb1/z9AKV1dYT7rMSGVNgsveXLIQ==} + peerDependencies: + expo: '*' + + expo-image-picker@17.0.8: + resolution: {integrity: sha512-489ByhVs2XPoAu9zodivAKLv7hG4S/FOe8hO/C2U6jVxmRjpAKakKNjMml0IwWjf1+c/RYBqm1XxKaZ+vq/fDQ==} + peerDependencies: + expo: '*' + expo-keep-awake@15.0.7: resolution: {integrity: sha512-CgBNcWVPnrIVII5G54QDqoE125l+zmqR4HR8q+MQaCfHet+dYpS5vX5zii/RMayzGN4jPgA4XYIQ28ePKFjHoA==} peerDependencies: @@ -5210,6 +5240,12 @@ packages: react: '*' react-native: '*' + react-native-image-picker@8.2.1: + resolution: {integrity: sha512-FBeGYJGFDjMdGCcyubDJgBAPCQ4L1D3hwLXyUU91jY9ahOZMTbluceVvRmrEKqnDPFJ0gF1NVhJ0nr1nROFLdg==} + peerDependencies: + react: '*' + react-native: '*' + react-native-is-edge-to-edge@1.2.1: resolution: {integrity: sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q==} peerDependencies: @@ -7691,7 +7727,7 @@ snapshots: dependencies: '@babel/code-frame': 7.27.1 '@babel/core': 7.28.4 - '@babel/generator': 7.28.3 + '@babel/generator': 7.28.5 '@expo/config': 12.0.10 '@expo/env': 2.0.7 '@expo/json-file': 10.0.7 @@ -8373,6 +8409,11 @@ snapshots: '@standard-schema/spec@1.0.0': {} + '@stream-io/state-store@1.1.6(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0))': + optionalDependencies: + react: 19.2.0 + use-sync-external-store: 1.6.0(react@19.2.0) + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -10064,6 +10105,15 @@ snapshots: react: 19.2.0 react-native: 0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0) + expo-image-loader@6.0.0(expo@54.0.20(@babel/core@7.28.4)(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0)): + dependencies: + expo: 54.0.20(@babel/core@7.28.4)(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0) + + expo-image-picker@17.0.8(expo@54.0.20(@babel/core@7.28.4)(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0)): + dependencies: + expo: 54.0.20(@babel/core@7.28.4)(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0) + expo-image-loader: 6.0.0(expo@54.0.20(@babel/core@7.28.4)(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0)) + expo-keep-awake@15.0.7(expo@54.0.20(@babel/core@7.28.4)(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0))(react@19.2.0): dependencies: expo: 54.0.20(@babel/core@7.28.4)(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0) @@ -11392,7 +11442,7 @@ snapshots: dependencies: '@babel/traverse': 7.28.4 '@babel/traverse--for-generate-function-map': '@babel/traverse@7.28.5' - '@babel/types': 7.28.4 + '@babel/types': 7.28.5 flow-enums-runtime: 0.0.6 invariant: 2.2.4 metro-symbolicate: 0.83.2 @@ -11443,7 +11493,7 @@ snapshots: metro-transform-plugins@0.83.2: dependencies: '@babel/core': 7.28.4 - '@babel/generator': 7.28.3 + '@babel/generator': 7.28.5 '@babel/template': 7.27.2 '@babel/traverse': 7.28.4 flow-enums-runtime: 0.0.6 @@ -11465,9 +11515,9 @@ snapshots: metro-transform-worker@0.83.2: dependencies: '@babel/core': 7.28.4 - '@babel/generator': 7.28.3 - '@babel/parser': 7.28.4 - '@babel/types': 7.28.4 + '@babel/generator': 7.28.5 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 flow-enums-runtime: 0.0.6 metro: 0.83.2 metro-babel-transformer: 0.83.2 @@ -11506,11 +11556,11 @@ snapshots: dependencies: '@babel/code-frame': 7.27.1 '@babel/core': 7.28.4 - '@babel/generator': 7.28.3 - '@babel/parser': 7.28.4 + '@babel/generator': 7.28.5 + '@babel/parser': 7.28.5 '@babel/template': 7.27.2 '@babel/traverse': 7.28.4 - '@babel/types': 7.28.4 + '@babel/types': 7.28.5 accepts: 1.3.8 chalk: 4.1.2 ci-info: 2.0.0 @@ -12294,6 +12344,11 @@ snapshots: react: 19.2.0 react-native: 0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0) + react-native-image-picker@8.2.1(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0): + dependencies: + react: 19.2.0 + react-native: 0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0) + react-native-is-edge-to-edge@1.2.1(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0): dependencies: react: 19.2.0