diff --git a/examples/ExpoMessaging/app.json b/examples/ExpoMessaging/app.json index 792175b08b..09857f4be1 100644 --- a/examples/ExpoMessaging/app.json +++ b/examples/ExpoMessaging/app.json @@ -15,9 +15,7 @@ "updates": { "fallbackToCacheTimeout": 0 }, - "assetBundlePatterns": [ - "**/*" - ], + "assetBundlePatterns": ["**/*"], "ios": { "supportsTablet": true, "usesIcloudStorage": true, @@ -25,15 +23,17 @@ "appleTeamId": "EHV7XZLAHA" }, "android": { + "config": { + "googleMaps": { + "apiKey": "AIzaSyDVh35biMyXbOjt74CQyO1dlqSMlrdHOOA" + } + }, "package": "io.stream.expomessagingapp", "adaptiveIcon": { "foregroundImage": "./assets/adaptive-icon.png", "backgroundColor": "#ffffff" }, - "permissions": [ - "android.permission.RECORD_AUDIO", - "android.permission.MODIFY_AUDIO_SETTINGS" - ] + "permissions": ["android.permission.RECORD_AUDIO", "android.permission.MODIFY_AUDIO_SETTINGS"] }, "web": { "favicon": "./assets/favicon.png", @@ -41,6 +41,12 @@ }, "scheme": "ExpoMessaging", "plugins": [ + [ + "expo-location", + { + "locationAlwaysAndWhenInUsePermission": "Allow $(PRODUCT_NAME) to use your location." + } + ], "expo-router", [ "expo-image-picker", @@ -63,6 +69,7 @@ "microphonePermission": "$(PRODUCT_NAME) would like to use your microphone for voice recording." } ], + "./plugins/keyboardInsetMainActivityListener.js" ] } diff --git a/examples/ExpoMessaging/app/_layout.tsx b/examples/ExpoMessaging/app/_layout.tsx index 8a0da3e920..4cdf2bc521 100644 --- a/examples/ExpoMessaging/app/_layout.tsx +++ b/examples/ExpoMessaging/app/_layout.tsx @@ -1,18 +1,23 @@ +import React from 'react'; import { Stack } from 'expo-router'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { ChatWrapper } from '../components/ChatWrapper'; import { AppProvider } from '../context/AppContext'; import { StyleSheet } from 'react-native'; import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { LiveLocationManagerProvider } from 'stream-chat-expo'; +import { watchLocation } from '../utils/watchLocation'; export default function Layout() { return ( - - - + + + + + diff --git a/examples/ExpoMessaging/app/channel/[cid]/index.tsx b/examples/ExpoMessaging/app/channel/[cid]/index.tsx index 8209a2d581..88b35f33f4 100644 --- a/examples/ExpoMessaging/app/channel/[cid]/index.tsx +++ b/examples/ExpoMessaging/app/channel/[cid]/index.tsx @@ -5,6 +5,8 @@ import { Stack, useRouter } from 'expo-router'; import { AuthProgressLoader } from '../../../components/AuthProgressLoader'; import { AppContext } from '../../../context/AppContext'; import { useHeaderHeight } from '@react-navigation/elements'; +import InputButtons from '../../../components/InputButtons'; +import { MessageLocation } from '../../../components/LocationSharing/MessageLocation'; export default function ChannelScreen() { const router = useRouter(); @@ -15,6 +17,21 @@ export default function ChannelScreen() { return ; } + const onPressMessage: NonNullable['onPressMessage']> = ( + payload, + ) => { + const { message, defaultHandler, emitter } = payload; + const { shared_location } = message; + if (emitter === 'messageContent' && shared_location) { + // Create url params from shared_location + const params = Object.entries(shared_location) + .map(([key, value]) => `${key}=${value}`) + .join('&'); + router.push(`/map/${message.id}?${params}`); + } + defaultHandler?.(); + }; + return ( @@ -22,7 +39,9 @@ export default function ChannelScreen() { @@ -32,7 +51,7 @@ export default function ChannelScreen() { router.push(`/channel/${channel.cid}/thread/${thread.cid}`); }} /> - + )} diff --git a/examples/ExpoMessaging/app/map/[id].tsx b/examples/ExpoMessaging/app/map/[id].tsx new file mode 100644 index 0000000000..f12f546f3c --- /dev/null +++ b/examples/ExpoMessaging/app/map/[id].tsx @@ -0,0 +1,232 @@ +import { Stack, useLocalSearchParams } from 'expo-router'; +import { + Platform, + Pressable, + useWindowDimensions, + StyleSheet, + View, + Image, + Text, +} from 'react-native'; +import { useContext, useMemo, useCallback, useRef } from 'react'; +import { AppContext } from '../../context/AppContext'; +import { useChatContext, useHandleLiveLocationEvents, useTheme } from 'stream-chat-expo'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import MapView, { MapMarker, Marker } from 'react-native-maps'; +import { SharedLocationResponse, StreamChat } from 'stream-chat'; + +export type SharedLiveLocationParamsStringType = SharedLocationResponse & { + latitude: string; + longitude: string; +}; + +const MapScreenFooter = ({ + client, + shared_location, + locationResponse, + isLiveLocationStopped, +}: { + client: StreamChat; + shared_location: SharedLocationResponse; + locationResponse?: SharedLocationResponse; + isLiveLocationStopped?: boolean; +}) => { + const { channel } = useContext(AppContext); + const { end_at, user_id } = shared_location; + const { + theme: { + colors: { accent_blue, accent_red, grey }, + }, + } = useTheme(); + const liveLocationActive = isLiveLocationStopped ? false : new Date(end_at) > new Date(); + const endedAtDate = end_at ? new Date(end_at) : null; + const formattedEndedAt = endedAtDate ? endedAtDate.toLocaleString() : ''; + + const stopSharingLiveLocation = useCallback(async () => { + await channel.stopLiveLocationSharing(locationResponse); + }, [channel, locationResponse]); + + if (!end_at) { + return null; + } + + const isCurrentUser = user_id === client.user.id; + if (!isCurrentUser) { + return ( + + + {liveLocationActive ? 'Live Location' : 'Live Location ended'} + + + {liveLocationActive + ? `Live until: ${formattedEndedAt}` + : `Location last updated at: ${formattedEndedAt}`} + + + ); + } + + if (liveLocationActive) { + return ( + + [styles.footerButton, { opacity: pressed ? 0.5 : 1 }]} + onPress={stopSharingLiveLocation} + hitSlop={10} + > + Stop Sharing + + + + Live until: {formattedEndedAt} + + + ); + } + + return ( + + Live Location ended + + Location last updated at: {formattedEndedAt} + + + ); +}; + +export default function MapScreen() { + const { client } = useChatContext(); + const shared_location = useLocalSearchParams(); + const { channel } = useContext(AppContext); + const mapRef = useRef(null); + const markerRef = useRef(null); + const { + theme: { + colors: { accent_blue }, + }, + } = useTheme(); + + const { width, height } = useWindowDimensions(); + const aspect_ratio = width / height; + + const onLocationUpdate = useCallback((location: SharedLocationResponse) => { + const newPosition = { + latitude: location.latitude, + longitude: location.longitude, + latitudeDelta: 0.1, + longitudeDelta: 0.1 * aspect_ratio, + }; + // Animate the map to the new position + if (mapRef.current?.animateToRegion) { + mapRef.current.animateToRegion(newPosition, 500); + } + // This is android only + if (Platform.OS === 'android' && markerRef.current?.animateMarkerToCoordinate) { + markerRef.current.animateMarkerToCoordinate(newPosition, 500); + } + }, []); + + const { isLiveLocationStopped, locationResponse } = useHandleLiveLocationEvents({ + channel, + messageId: shared_location.message_id, + onLocationUpdate, + }); + + const initialRegion = useMemo(() => { + const latitudeDelta = 0.1; + const longitudeDelta = latitudeDelta * aspect_ratio; + + return { + latitude: parseFloat(shared_location.latitude), + longitude: parseFloat(shared_location.longitude), + latitudeDelta, + longitudeDelta, + }; + }, [aspect_ratio]); + + const region = useMemo(() => { + const latitudeDelta = 0.1; + const longitudeDelta = latitudeDelta * aspect_ratio; + return { + latitude: locationResponse?.latitude, + longitude: locationResponse?.longitude, + latitudeDelta, + longitudeDelta, + }; + }, [aspect_ratio, locationResponse]); + + return ( + + + + {shared_location.end_at ? ( + + + + + + ) : ( + + )} + + + + ); +} + +const IMAGE_SIZE = 35; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + mapView: { + width: 'auto', + flex: 3, + }, + markerWrapper: { + overflow: 'hidden', // REQUIRED for rounded corners to show on Android + }, + markerImage: { + width: IMAGE_SIZE, + height: IMAGE_SIZE, + borderRadius: IMAGE_SIZE / 2, + resizeMode: 'cover', // or 'contain' if image is cropped + borderWidth: 2, + }, + footer: { + marginVertical: 8, + }, + footerButton: { + padding: 4, + }, + footerText: { + textAlign: 'center', + fontSize: 14, + }, + footerDescription: { + textAlign: 'center', + fontSize: 12, + marginTop: 4, + }, +}); diff --git a/examples/ExpoMessaging/components/ChatWrapper.tsx b/examples/ExpoMessaging/components/ChatWrapper.tsx index b82d143ee0..56fe955d7b 100644 --- a/examples/ExpoMessaging/components/ChatWrapper.tsx +++ b/examples/ExpoMessaging/components/ChatWrapper.tsx @@ -1,6 +1,7 @@ import React, { PropsWithChildren } from 'react'; import { Chat, + enTranslations, OverlayProvider, SqliteClient, Streami18n, @@ -24,6 +25,12 @@ export const ChatWrapper = ({ children }: PropsWithChildren<{}>) => { userData: user, tokenOrProvider: userToken, }); + + streami18n.registerTranslation('en', { + ...enTranslations, + 'timestamp/Location end at': '{{ milliseconds | durationFormatter(withSuffix: false) }}', + }); + const theme = useStreamChatTheme(); if (!chatClient) { diff --git a/examples/ExpoMessaging/components/InputButtons.tsx b/examples/ExpoMessaging/components/InputButtons.tsx new file mode 100644 index 0000000000..929b10acef --- /dev/null +++ b/examples/ExpoMessaging/components/InputButtons.tsx @@ -0,0 +1,35 @@ +import React, { useState } from 'react'; +import { Pressable, StyleSheet } from 'react-native'; +import { Channel, InputButtons as DefaultInputButtons } from 'stream-chat-expo'; +import { ShareLocationIcon } from '../icons/ShareLocationIcon'; +import { LiveLocationCreateModal } from './LocationSharing/CreateLocationModal'; + +const InputButtons: NonNullable['InputButtons']> = (props) => { + const [modalVisible, setModalVisible] = useState(false); + + const onRequestClose = () => { + setModalVisible(false); + }; + + const onOpenModal = () => { + setModalVisible(true); + }; + + return ( + <> + + + + + + + ); +}; + +const styles = StyleSheet.create({ + liveLocationButton: { + paddingLeft: 5, + }, +}); + +export default InputButtons; diff --git a/examples/ExpoMessaging/components/LocationSharing/CreateLocationModal.tsx b/examples/ExpoMessaging/components/LocationSharing/CreateLocationModal.tsx new file mode 100644 index 0000000000..e1f49b1fd9 --- /dev/null +++ b/examples/ExpoMessaging/components/LocationSharing/CreateLocationModal.tsx @@ -0,0 +1,288 @@ +import { useState, useEffect, useMemo, useRef } from 'react'; +import { + Alert, + AlertButton, + Modal, + Text, + View, + Pressable, + StyleSheet, + useWindowDimensions, + Image, + Platform, +} from 'react-native'; +import * as Location from 'expo-location'; +import { + useChatContext, + useMessageComposer, + useTheme, + useTranslationContext, +} from 'stream-chat-expo'; +import MapView, { MapMarker, Marker } from 'react-native-maps'; + +type LiveLocationCreateModalProps = { + visible: boolean; + onRequestClose: () => void; +}; + +const endedAtDurations = [60000, 600000, 3600000]; // 1 min, 10 mins, 1 hour + +export const LiveLocationCreateModal = ({ + visible, + onRequestClose, +}: LiveLocationCreateModalProps) => { + const [location, setLocation] = useState(); + const messageComposer = useMessageComposer(); + const { width, height } = useWindowDimensions(); + const { client } = useChatContext(); + const { + theme: { + colors: { accent_blue, grey, grey_whisper }, + }, + } = useTheme(); + const { t } = useTranslationContext(); + const mapRef = useRef(null); + const markerRef = useRef(null); + + const aspect_ratio = width / height; + + const region = useMemo(() => { + const latitudeDelta = 0.1; + const longitudeDelta = latitudeDelta * aspect_ratio; + if (location) { + return { + latitude: location.latitude, + longitude: location.longitude, + latitudeDelta, + longitudeDelta, + }; + } + }, [aspect_ratio, location]); + + useEffect(() => { + let subscription: Location.LocationSubscription; + const watchLocationHandler = async () => { + let { status } = await Location.requestForegroundPermissionsAsync(); + if (status !== 'granted') { + Alert.alert('Permissions not granted!'); + return; + } + subscription = await Location.watchPositionAsync( + { + accuracy: Location.Accuracy.High, + distanceInterval: 0, + // Android only: these option are ignored on iOS + timeInterval: 2000, + }, + (position) => { + setLocation(position.coords); + const newPosition = { + latitude: position.coords.latitude, + longitude: position.coords.longitude, + latitudeDelta: 0.1, + longitudeDelta: 0.1 * aspect_ratio, + }; + if (mapRef.current?.animateToRegion) { + mapRef.current.animateToRegion(newPosition, 500); + } + // This is android only + if (Platform.OS === 'android' && markerRef.current?.animateMarkerToCoordinate) { + markerRef.current.animateMarkerToCoordinate(newPosition, 500); + } + }, + (error) => { + console.error('watchPosition', error); + }, + ); + }; + watchLocationHandler(); + return () => { + subscription?.remove(); + }; + }, []); + + const buttons = [ + { + text: 'Share Live Location', + description: 'Share your location in real-time', + onPress: () => { + const options: AlertButton[] = endedAtDurations.map((offsetMs) => ({ + text: t('timestamp/Location end at', { milliseconds: offsetMs }), + onPress: async () => { + await messageComposer.locationComposer.setData({ + durationMs: offsetMs, + latitude: location?.latitude, + longitude: location?.longitude, + }); + await messageComposer.sendLocation(); + onRequestClose(); + }, + style: 'default', + })); + + options.push({ style: 'destructive', text: 'Cancel' }); + + Alert.alert( + 'Share Live Location', + 'Select the duration for which you want to share your live location.', + options, + ); + }, + }, + { + text: 'Share Current Location', + description: 'Share your current location once', + onPress: async () => { + onRequestClose(); + let { status } = await Location.requestForegroundPermissionsAsync(); + if (status !== 'granted') { + Alert.alert('Permission to access location was denied!'); + return; + } + const location = await Location.getCurrentPositionAsync({ + accuracy: Location.Accuracy.High, + distanceInterval: 0, + }); + + if (location) { + await messageComposer.locationComposer.setData({ + latitude: location.coords.latitude, + longitude: location.coords.longitude, + }); + await messageComposer.sendLocation(); + } + }, + }, + ]; + + if (!location && client) { + return null; + } + + return ( + + + + Cancel + + Share Location + + + + + {location && ( + + + + + + )} + + + {buttons.map((button, index) => ( + [ + { + borderColor: pressed ? accent_blue : grey_whisper, + }, + styles.button, + ]} + > + {button.text} + {button.description} + + ))} + + + ); +}; + +const IMAGE_SIZE = 35; + +const styles = StyleSheet.create({ + mapView: { + width: 'auto', + flex: 3, + }, + textStyle: { + fontSize: 12, + color: 'gray', + marginHorizontal: 12, + marginVertical: 4, + }, + modalHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingTop: 16, + paddingBottom: 12, + }, + cancelText: { + fontSize: 16, + }, + headerTitle: { + fontSize: 16, + fontWeight: '600', + textAlign: 'center', + }, + rightContent: { + flex: 1, + flexShrink: 1, + }, + leftContent: { + flex: 1, + flexShrink: 1, + }, + buttons: { + flex: 1, + marginVertical: 16, + }, + button: { + borderWidth: 1, + borderRadius: 8, + marginVertical: 4, + marginHorizontal: 16, + padding: 8, + }, + buttonTitle: { + fontWeight: '600', + marginVertical: 4, + }, + buttonDescription: { + fontSize: 12, + marginVertical: 4, + }, + markerWrapper: { + overflow: 'hidden', // REQUIRED for rounded corners to show on Android + }, + markerImage: { + width: IMAGE_SIZE, + height: IMAGE_SIZE, + borderRadius: IMAGE_SIZE / 2, + resizeMode: 'cover', // or 'contain' if image is cropped + borderWidth: 2, + }, +}); diff --git a/examples/ExpoMessaging/components/LocationSharing/MessageLocation.tsx b/examples/ExpoMessaging/components/LocationSharing/MessageLocation.tsx new file mode 100644 index 0000000000..66e80ddbc8 --- /dev/null +++ b/examples/ExpoMessaging/components/LocationSharing/MessageLocation.tsx @@ -0,0 +1,182 @@ +import React, { useMemo, useCallback, useEffect, useRef } from 'react'; +import { + Platform, + Image, + StyleSheet, + useWindowDimensions, + Text, + View, + Pressable, +} from 'react-native'; +import { + MessageLocationProps, + useChannelContext, + useChatContext, + useTheme, +} from 'stream-chat-expo'; +import MapView, { MapMarker, Marker } from 'react-native-maps'; +import { SharedLocationResponse, StreamChat } from 'stream-chat'; + +const MessageLocationFooter = ({ + client, + shared_location, +}: { + client: StreamChat; + shared_location: SharedLocationResponse; +}) => { + const { channel } = useChannelContext(); + const { end_at, user_id } = shared_location; + const { + theme: { + colors: { grey }, + }, + } = useTheme(); + const liveLocationActive = new Date(end_at) > new Date(); + const endedAtDate = end_at ? new Date(end_at) : null; + const formattedEndedAt = endedAtDate ? endedAtDate.toLocaleString() : ''; + + const stopSharingLiveLocation = useCallback(async () => { + await channel.stopLiveLocationSharing(shared_location); + }, [channel, shared_location]); + + if (!end_at) { + return null; + } + const isCurrentUser = user_id === client.user.id; + if (!isCurrentUser) { + return ( + + + {liveLocationActive ? `Live until: ${formattedEndedAt}` : 'Live Location ended'} + + + ); + } + + if (liveLocationActive) { + return ( + + Stop Sharing + + ); + } + return ( + + Live Location ended + + ); +}; + +export const MessageLocation = ({ message }: MessageLocationProps) => { + const { client } = useChatContext(); + const { shared_location } = message; + const { latitude, longitude } = shared_location || {}; + const mapRef = useRef(null); + const markerRef = useRef(null); + + const { width, height } = useWindowDimensions(); + const aspect_ratio = width / height; + + const { + theme: { + colors: { accent_blue }, + }, + } = useTheme(); + + const region = useMemo(() => { + const latitudeDelta = 0.1; + const longitudeDelta = latitudeDelta * aspect_ratio; + return { + latitude, + longitude, + latitudeDelta, + longitudeDelta, + }; + }, [aspect_ratio, latitude, longitude]); + + useEffect(() => { + if (!region) return; + const newPosition = { + latitude, + longitude, + latitudeDelta: 0.1, + longitudeDelta: 0.1 * aspect_ratio, + }; + // Animate the map to the new position + if (mapRef.current?.animateToRegion) { + mapRef.current.animateToRegion(newPosition, 500); + } + // This is android only + if (Platform.OS === 'android' && markerRef.current?.animateMarkerToCoordinate) { + markerRef.current.animateMarkerToCoordinate(newPosition, 500); + } + }, [region]); + + if (!shared_location) { + return null; + } + + return ( + + + {shared_location.end_at ? ( + + + + + + ) : ( + + )} + + + + ); +}; + +const IMAGE_SIZE = 35; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + mapView: { + height: 250, + width: 250, + }, + textStyle: { + fontSize: 12, + color: 'gray', + marginHorizontal: 12, + marginVertical: 4, + }, + markerWrapper: { + overflow: 'hidden', // REQUIRED for rounded corners to show on Android + }, + markerImage: { + width: IMAGE_SIZE, + height: IMAGE_SIZE, + borderRadius: IMAGE_SIZE / 2, + resizeMode: 'cover', // or 'contain' if image is cropped + borderWidth: 2, + }, + footer: { + marginVertical: 8, + }, + footerText: { + textAlign: 'center', + fontSize: 14, + }, + footerDescription: { + textAlign: 'center', + fontSize: 12, + }, +}); diff --git a/examples/ExpoMessaging/custom-types.d.ts b/examples/ExpoMessaging/custom-types.d.ts index dcd62b8eb0..daa3906639 100644 --- a/examples/ExpoMessaging/custom-types.d.ts +++ b/examples/ExpoMessaging/custom-types.d.ts @@ -15,7 +15,9 @@ import { declare module 'stream-chat' { /* eslint-disable @typescript-eslint/no-empty-object-type */ - interface CustomAttachmentData extends DefaultAttachmentData {} + interface CustomAttachmentData extends DefaultAttachmentData { + ended_at?: string; + } interface CustomChannelData extends DefaultChannelData {} diff --git a/examples/ExpoMessaging/icons/ShareLocationIcon.tsx b/examples/ExpoMessaging/icons/ShareLocationIcon.tsx new file mode 100644 index 0000000000..694b2eed91 --- /dev/null +++ b/examples/ExpoMessaging/icons/ShareLocationIcon.tsx @@ -0,0 +1,25 @@ +import Svg, { Path } from 'react-native-svg'; +import { useTheme } from 'stream-chat-expo'; + +// Icon for "Share Location" button, next to input box. +export const ShareLocationIcon = () => { + const { + theme: { + colors: { grey }, + }, + } = useTheme(); + return ( + + + + + ); +}; diff --git a/examples/ExpoMessaging/package.json b/examples/ExpoMessaging/package.json index 898801baa9..43d9956cfc 100644 --- a/examples/ExpoMessaging/package.json +++ b/examples/ExpoMessaging/package.json @@ -23,6 +23,7 @@ "expo-image-manipulator": "~13.1.7", "expo-image-picker": "~16.1.4", "expo-linking": "~7.1.5", + "expo-location": "~18.1.6", "expo-router": "~5.1.0", "expo-sharing": "~13.1.5", "expo-splash-screen": "~0.30.9", @@ -32,6 +33,7 @@ "react-dom": "19.0.0", "react-native": "0.79.3", "react-native-gesture-handler": "~2.26.0", + "react-native-maps": "1.20.1", "react-native-reanimated": "~3.17.4", "react-native-safe-area-context": "5.4.0", "react-native-screens": "~4.11.1", diff --git a/examples/ExpoMessaging/utils/watchLocation.ts b/examples/ExpoMessaging/utils/watchLocation.ts new file mode 100644 index 0000000000..763bbfcca6 --- /dev/null +++ b/examples/ExpoMessaging/utils/watchLocation.ts @@ -0,0 +1,39 @@ +import * as Location from 'expo-location'; +import { Alert } from 'react-native'; + +export type LocationHandler = (value: { latitude: number; longitude: number }) => void; + +export const checkLocationPermission = async () => { + const { status } = await Location.requestForegroundPermissionsAsync(); + if (status !== 'granted') { + console.warn('Location permission not granted'); + Alert.alert('Location permission not granted!'); + return false; + } + return true; +}; + +export const watchLocation = (handler: LocationHandler) => { + let subscription: Location.LocationSubscription | null = null; + Location.watchPositionAsync( + { + accuracy: Location.Accuracy.High, + distanceInterval: 0, + // Android only: these option are ignored on iOS + timeInterval: 2000, + }, + (location) => { + const { latitude, longitude } = location.coords; + handler({ latitude, longitude }); + }, + (error) => { + console.warn('Error watching location:', error); + }, + ).then((sub) => { + subscription = sub; + }); + + return () => { + subscription?.remove(); + }; +}; diff --git a/examples/ExpoMessaging/yarn.lock b/examples/ExpoMessaging/yarn.lock index 5633e8863a..d06793d99c 100644 --- a/examples/ExpoMessaging/yarn.lock +++ b/examples/ExpoMessaging/yarn.lock @@ -2494,6 +2494,11 @@ dependencies: "@babel/types" "^7.20.7" +"@types/geojson@^7946.0.13": + version "7946.0.16" + resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.16.tgz#8ebe53d69efada7044454e3305c19017d97ced2a" + integrity sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg== + "@types/graceful-fs@^4.1.3": version "4.1.9" resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.9.tgz#2a06bc0f68a20ab37b3e36aa238be6abdf49e8b4" @@ -3703,6 +3708,11 @@ expo-linking@~7.1.5: expo-constants "~17.1.6" invariant "^2.2.4" +expo-location@~18.1.6: + version "18.1.6" + resolved "https://registry.yarnpkg.com/expo-location/-/expo-location-18.1.6.tgz#b855e14e8b4e29a1bde470fc4dc2a341abecf631" + integrity sha512-l5dQQ2FYkrBgNzaZN1BvSmdhhcztFOUucu2kEfDBMV4wSIuTIt/CKsho+F3RnAiWgvui1wb1WTTf80E8zq48hA== + expo-modules-autolinking@2.1.12: version "2.1.12" resolved "https://registry.yarnpkg.com/expo-modules-autolinking/-/expo-modules-autolinking-2.1.12.tgz#f7f04f143411e04fefc6c4585e69fd0f6ea953fa" @@ -5555,6 +5565,13 @@ react-native-lightbox@^0.7.0: dependencies: prop-types "^15.5.10" +react-native-maps@1.20.1: + version "1.20.1" + resolved "https://registry.yarnpkg.com/react-native-maps/-/react-native-maps-1.20.1.tgz#f613364886b2f72db56cafcd2bd7d3a35f470dfb" + integrity sha512-NZI3B5Z6kxAb8gzb2Wxzu/+P2SlFIg1waHGIpQmazDSCRkNoHNY4g96g+xS0QPSaG/9xRBbDNnd2f2/OW6t6LQ== + dependencies: + "@types/geojson" "^7946.0.13" + react-native-markdown-package@1.8.2: version "1.8.2" resolved "https://registry.yarnpkg.com/react-native-markdown-package/-/react-native-markdown-package-1.8.2.tgz#19db1047e174077f9b9f80303938c775b1c223c0" @@ -6137,10 +6154,10 @@ stream-chat-react-native-core@8.1.0: version "0.0.0" uid "" -stream-chat@^9.11.0: - version "9.11.0" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.11.0.tgz#412b5f2f55cc3b76e862d88cb3e95b9720493575" - integrity sha512-mnf86LMBPmCZUrmrU6bdaOUoxRqpXUvw7prl4b5q6WPNWZ+p/41UN8vllBAaZvAS7jPj9JYWvbhUe40VZ6o8/g== +stream-chat@^9.12.0: + version "9.12.0" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.12.0.tgz#d27b1319844d100ca419c61463f5c65f53c87952" + integrity sha512-/A4y8jBmWdP53RUY9f8dlc8dRjC/irR5KUMiOhn0IiAEmK2fKuD/7IpUzXi7cNmR8QMxlHdDMJZdB2wMDkiskQ== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14" diff --git a/examples/SampleApp/App.tsx b/examples/SampleApp/App.tsx index 8e6dc469c6..79bd3f171a 100644 --- a/examples/SampleApp/App.tsx +++ b/examples/SampleApp/App.tsx @@ -7,14 +7,15 @@ import { SafeAreaProvider } from 'react-native-safe-area-context'; import { Chat, createTextComposerEmojiMiddleware, + LiveLocationManagerProvider, OverlayProvider, setupCommandUIMiddlewares, SqliteClient, Streami18n, ThemeProvider, useOverlayContext, - enTranslations, } from 'stream-chat-react-native'; + import { getMessaging } from '@react-native-firebase/messaging'; import notifee, { EventType } from '@notifee/react-native'; import { AppContext } from './src/context/AppContext'; @@ -40,6 +41,19 @@ import { ThreadScreen } from './src/screens/ThreadScreen'; import { UserSelectorScreen } from './src/screens/UserSelectorScreen'; import { init, SearchIndex } from 'emoji-mart'; import data from '@emoji-mart/data'; +import Geolocation from '@react-native-community/geolocation'; +import type { StackNavigatorParamList, UserSelectorParamList } from './src/types'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { navigateToChannel, RootNavigationRef } from './src/utils/RootNavigation'; +import FastImage from 'react-native-fast-image'; +import { StreamChatProvider } from './src/context/StreamChatContext'; +import { MapScreen } from './src/screens/MapScreen'; +import { watchLocation } from './src/utils/watchLocation'; + +Geolocation.setRNConfiguration({ + skipPermissionRequests: false, + authorizationLevel: 'always', +}); import type { LocalMessage, StreamChat, TextComposerMiddleware } from 'stream-chat'; @@ -52,11 +66,6 @@ if (__DEV__) { }); } -import type { StackNavigatorParamList, UserSelectorParamList } from './src/types'; -import { GestureHandlerRootView } from 'react-native-gesture-handler'; -import { navigateToChannel, RootNavigationRef } from './src/utils/RootNavigation'; -import FastImage from 'react-native-fast-image'; - LogBox.ignoreLogs(['Non-serializable values were found in the navigation state']); console.assert = () => null; @@ -185,16 +194,18 @@ const App = () => { }; const DrawerNavigator: React.FC = () => ( - - - + + + + + ); const isMessageAIGenerated = (message: LocalMessage) => !!message.ai_generated; @@ -205,16 +216,6 @@ const DrawerNavigatorWrapper: React.FC<{ const streamChatTheme = useStreamChatTheme(); const streami18n = new Streami18n(); - streami18n.registerTranslation('en', { - ...enTranslations, - 'Due since {{ dueSince }}': 'Due since {{ dueSince }}', - 'Due {{ timeLeft }}': 'Due {{ timeLeft }}', - 'duration/Message reminder': '{{ milliseconds | durationFormatter(withSuffix: true) }}', - 'duration/Remind Me': '{{ milliseconds | durationFormatter(withSuffix: true) }}', - 'timestamp/Remind me': '{{ milliseconds | durationFormatter(withSuffix: true) }}', - 'timestamp/ReminderNotification': '{{ timestamp | timestampFormatter(calendar: true) }}', - }); - return ( @@ -226,11 +227,13 @@ const DrawerNavigatorWrapper: React.FC<{ isMessageAIGenerated={isMessageAIGenerated} i18nInstance={streami18n} > - - - - - + + + + + + + @@ -278,6 +281,11 @@ const HomeScreen = () => { headerShown: false, }} /> + + + @@ -31,5 +33,8 @@ + \ No newline at end of file diff --git a/examples/SampleApp/ios/Podfile.lock b/examples/SampleApp/ios/Podfile.lock index 4fc32de857..66c8bc5dfe 100644 --- a/examples/SampleApp/ios/Podfile.lock +++ b/examples/SampleApp/ios/Podfile.lock @@ -1581,6 +1581,30 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga + - react-native-geolocation (3.4.0): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga - react-native-image-picker (8.2.1): - DoubleConversion - glog @@ -1605,6 +1629,8 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga + - react-native-maps (1.20.1): + - React-Core - react-native-netinfo (11.4.1): - React-Core - react-native-safe-area-context (5.4.1): @@ -2500,7 +2526,9 @@ DEPENDENCIES: - react-native-blob-util (from `../node_modules/react-native-blob-util`) - "react-native-cameraroll (from `../node_modules/@react-native-camera-roll/camera-roll`)" - "react-native-document-picker (from `../node_modules/@react-native-documents/picker`)" + - "react-native-geolocation (from `../node_modules/@react-native-community/geolocation`)" - react-native-image-picker (from `../node_modules/react-native-image-picker`) + - react-native-maps (from `../node_modules/react-native-maps`) - "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)" - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) - react-native-video (from `../node_modules/react-native-video`) @@ -2661,8 +2689,12 @@ EXTERNAL SOURCES: :path: "../node_modules/@react-native-camera-roll/camera-roll" react-native-document-picker: :path: "../node_modules/@react-native-documents/picker" + react-native-geolocation: + :path: "../node_modules/@react-native-community/geolocation" react-native-image-picker: :path: "../node_modules/react-native-image-picker" + react-native-maps: + :path: "../node_modules/react-native-maps" react-native-netinfo: :path: "../node_modules/@react-native-community/netinfo" react-native-safe-area-context: @@ -2822,7 +2854,9 @@ SPEC CHECKSUMS: react-native-blob-util: e032f2a9d5779aa94934139a60fe5ed6c5071328 react-native-cameraroll: 23d28040c32ca8b20661e0c41b56ab041779244b react-native-document-picker: 1f8a568fcd43ed5ad9e53307d487d1de21c340a4 + react-native-geolocation: fcd6daeef13402b1cbb3aadc7711b4e6ed9d89e0 react-native-image-picker: f6ece66f251f4a17aab08f5add7be6eb9e7f5356 + react-native-maps: 9febd31278b35cd21e4fad2cf6fa708993be5dab react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187 react-native-safe-area-context: 8cf8caf5fc0f5a5d3581b650579af9c85c16be04 react-native-video: 6a236aa5c7619d4ba9b07ddf965a0b62b1702da3 diff --git a/examples/SampleApp/ios/SampleApp/Info.plist b/examples/SampleApp/ios/SampleApp/Info.plist index 6ee0a9287f..f88a368401 100644 --- a/examples/SampleApp/ios/SampleApp/Info.plist +++ b/examples/SampleApp/ios/SampleApp/Info.plist @@ -33,7 +33,9 @@ NSLocationWhenInUseUsageDescription - + $(PRODUCT_NAME) would like share live location when in use in a message. + NSLocationAlwaysAndWhenInUseUsageDescription + $(PRODUCT_NAME) would like share live location always in a message. NSPhotoLibraryUsageDescription $(PRODUCT_NAME) would like access to your photo gallery to share image in a message. NSCameraUsageDescription @@ -54,8 +56,8 @@ UIViewControllerBasedStatusBarAppearance ITSAppUsesNonExemptEncryption - - FirebaseAppDelegateProxyEnabled - + + FirebaseAppDelegateProxyEnabled + - + \ No newline at end of file diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json index 41623086cc..f23b5acc1c 100644 --- a/examples/SampleApp/package.json +++ b/examples/SampleApp/package.json @@ -28,6 +28,7 @@ "@op-engineering/op-sqlite": "^14.0.4", "@react-native-async-storage/async-storage": "^2.2.0", "@react-native-camera-roll/camera-roll": "^7.10.0", + "@react-native-community/geolocation": "^3.4.0", "@react-native-community/netinfo": "^11.4.1", "@react-native-documents/picker": "^10.1.3", "@react-native-firebase/app": "22.2.1", @@ -36,9 +37,9 @@ "@react-navigation/drawer": "7.4.1", "@react-navigation/native": "^7.1.10", "@react-navigation/stack": "^7.3.3", - "react": "19.0.0", "emoji-mart": "^5.6.0", "lodash.mergewith": "^4.6.2", + "react": "19.0.0", "react-native": "^0.79.3", "react-native-audio-recorder-player": "^3.6.13", "react-native-blob-util": "^0.22.2", @@ -46,6 +47,7 @@ "react-native-gesture-handler": "^2.26.0", "react-native-haptic-feedback": "^2.3.3", "react-native-image-picker": "^8.2.1", + "react-native-maps": "1.20.1", "react-native-reanimated": "^3.18.0", "react-native-safe-area-context": "^5.4.1", "react-native-screens": "^4.11.1", diff --git a/examples/SampleApp/src/components/AttachmentPickerSelectionBar.tsx b/examples/SampleApp/src/components/AttachmentPickerSelectionBar.tsx new file mode 100644 index 0000000000..0b858bcb1c --- /dev/null +++ b/examples/SampleApp/src/components/AttachmentPickerSelectionBar.tsx @@ -0,0 +1,36 @@ +import { useState } from 'react'; +import { Pressable, StyleSheet, View } from 'react-native'; +import { AttachmentPickerSelectionBar, useMessageInputContext } from 'stream-chat-react-native'; +import { ShareLocationIcon } from '../icons/ShareLocationIcon'; +import { LiveLocationCreateModal } from './LocationSharing/CreateLocationModal'; + +export const CustomAttachmentPickerSelectionBar = () => { + const [modalVisible, setModalVisible] = useState(false); + const { closeAttachmentPicker } = useMessageInputContext(); + + const onRequestClose = () => { + setModalVisible(false); + closeAttachmentPicker(); + }; + + const onOpenModal = () => { + setModalVisible(true); + }; + + return ( + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + selectionBar: { flexDirection: 'row', alignItems: 'center' }, + liveLocationButton: { + paddingLeft: 4, + }, +}); diff --git a/examples/SampleApp/src/components/LocationSharing/CreateLocationModal.tsx b/examples/SampleApp/src/components/LocationSharing/CreateLocationModal.tsx new file mode 100644 index 0000000000..2bbe10e99c --- /dev/null +++ b/examples/SampleApp/src/components/LocationSharing/CreateLocationModal.tsx @@ -0,0 +1,279 @@ +import { useState, useEffect, useMemo, useRef } from 'react'; +import { + Alert, + AlertButton, + Modal, + Text, + View, + Pressable, + StyleSheet, + useWindowDimensions, + Image, + Platform, +} from 'react-native'; +import Geolocation, { GeolocationResponse } from '@react-native-community/geolocation'; +import { + useChatContext, + useMessageComposer, + useTheme, + useTranslationContext, +} from 'stream-chat-react-native'; +import MapView, { MapMarker, Marker } from 'react-native-maps'; + +type LiveLocationCreateModalProps = { + visible: boolean; + onRequestClose: () => void; +}; + +const endedAtDurations = [60000, 600000, 3600000]; // 1 min, 10 mins, 1 hour + +export const LiveLocationCreateModal = ({ + visible, + onRequestClose, +}: LiveLocationCreateModalProps) => { + const [location, setLocation] = useState(); + const messageComposer = useMessageComposer(); + const { width, height } = useWindowDimensions(); + const { client } = useChatContext(); + const { + theme: { + colors: { accent_blue, grey, grey_whisper }, + }, + } = useTheme(); + const { t } = useTranslationContext(); + const mapRef = useRef(null); + const markerRef = useRef(null); + + const aspect_ratio = width / height; + + const region = useMemo(() => { + const latitudeDelta = 0.1; + const longitudeDelta = latitudeDelta * aspect_ratio; + if (location) { + return { + latitude: location.coords.latitude, + longitude: location.coords.longitude, + latitudeDelta, + longitudeDelta, + }; + } + }, [aspect_ratio, location]); + + useEffect(() => { + let watchId: number | null = null; + const watchLocationHandler = async () => { + watchId = await Geolocation.watchPosition( + (position) => { + setLocation(position); + const newPosition = { + latitude: position.coords.latitude, + longitude: position.coords.longitude, + latitudeDelta: 0.1, + longitudeDelta: 0.1 * aspect_ratio, + }; + if (mapRef.current?.animateToRegion) { + mapRef.current.animateToRegion(newPosition, 500); + } + // This is android only + if (Platform.OS === 'android' && markerRef.current?.animateMarkerToCoordinate) { + markerRef.current.animateMarkerToCoordinate(newPosition, 500); + } + }, + (error) => { + console.error('watchPosition', error); + }, + { + enableHighAccuracy: true, + timeout: 20000, + maximumAge: 1000, + interval: 2000, // android only + }, + ); + }; + watchLocationHandler(); + return () => { + if (watchId) { + Geolocation.clearWatch(watchId); + } + }; + }, [aspect_ratio]); + + const buttons = [ + { + text: 'Share Live Location', + description: 'Share your location in real-time', + onPress: () => { + const options: AlertButton[] = endedAtDurations.map((offsetMs) => ({ + text: t('duration/Location end at', { milliseconds: offsetMs }), + onPress: async () => { + if (location) { + await messageComposer.locationComposer.setData({ + durationMs: offsetMs, + latitude: location.coords.latitude, + longitude: location.coords.longitude, + }); + await messageComposer.sendLocation(); + onRequestClose(); + } + }, + style: 'default', + })); + + options.push({ style: 'destructive', text: 'Cancel' }); + + Alert.alert( + 'Share Live Location', + 'Select the duration for which you want to share your live location.', + options, + ); + }, + }, + { + text: 'Share Current Location', + description: 'Share your current location once', + onPress: async () => { + Geolocation.getCurrentPosition(async (position) => { + if (position.coords) { + await messageComposer.locationComposer.setData({ + latitude: position.coords.latitude, + longitude: position.coords.longitude, + }); + await messageComposer.sendLocation(); + onRequestClose(); + } + }); + }, + }, + ]; + + if (!location && client) { + return null; + } + + return ( + + + + Cancel + + Share Location + + + + + {location && ( + + + + + + )} + + + {buttons.map((button, index) => ( + [ + { + borderColor: pressed ? accent_blue : grey_whisper, + }, + styles.button, + ]} + > + {button.text} + {button.description} + + ))} + + + ); +}; + +const IMAGE_SIZE = 35; + +const styles = StyleSheet.create({ + mapView: { + width: 'auto', + flex: 3, + }, + textStyle: { + fontSize: 12, + color: 'gray', + marginHorizontal: 12, + marginVertical: 4, + }, + modalHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingTop: 16, + paddingBottom: 12, + }, + cancelText: { + fontSize: 16, + }, + headerTitle: { + fontSize: 16, + fontWeight: '600', + textAlign: 'center', + }, + rightContent: { + flex: 1, + flexShrink: 1, + }, + leftContent: { + flex: 1, + flexShrink: 1, + }, + buttons: { + flex: 1, + marginVertical: 16, + }, + button: { + borderWidth: 1, + borderRadius: 8, + marginVertical: 4, + marginHorizontal: 16, + padding: 8, + }, + buttonTitle: { + fontWeight: '600', + marginVertical: 4, + }, + buttonDescription: { + fontSize: 12, + marginVertical: 4, + }, + markerWrapper: { + overflow: 'hidden', // REQUIRED for rounded corners to show on Android + }, + markerImage: { + width: IMAGE_SIZE, + height: IMAGE_SIZE, + borderRadius: IMAGE_SIZE / 2, + resizeMode: 'cover', // or 'contain' if image is cropped + borderWidth: 2, + }, +}); diff --git a/examples/SampleApp/src/components/LocationSharing/MessageLocation.tsx b/examples/SampleApp/src/components/LocationSharing/MessageLocation.tsx new file mode 100644 index 0000000000..86a3cadc86 --- /dev/null +++ b/examples/SampleApp/src/components/LocationSharing/MessageLocation.tsx @@ -0,0 +1,197 @@ +import React, { useMemo, useCallback, useEffect, useRef } from 'react'; +import { + Platform, + Image, + StyleSheet, + useWindowDimensions, + Text, + View, + Pressable, +} from 'react-native'; +import { + MessageLocationProps, + useChannelContext, + useChatContext, + useTheme, +} from 'stream-chat-react-native'; +import MapView, { MapMarker, Marker } from 'react-native-maps'; +import { SharedLocationResponse, StreamChat } from 'stream-chat'; + +const MessageLocationFooter = ({ + client, + shared_location, +}: { + client: StreamChat; + shared_location: SharedLocationResponse; +}) => { + const { channel } = useChannelContext(); + const { end_at, user_id } = shared_location; + const { + theme: { + colors: { grey }, + }, + } = useTheme(); + const liveLocationActive = end_at && new Date(end_at) > new Date(); + const endedAtDate = end_at ? new Date(end_at) : null; + const formattedEndedAt = endedAtDate ? endedAtDate.toLocaleString() : ''; + + const stopSharingLiveLocation = useCallback(async () => { + await channel.stopLiveLocationSharing(shared_location); + }, [channel, shared_location]); + + if (!end_at) { + return null; + } + const isCurrentUser = user_id === client.user?.id; + if (!isCurrentUser) { + return ( + + + {liveLocationActive ? `Live until: ${formattedEndedAt}` : 'Live Location ended'} + + + ); + } + + if (liveLocationActive) { + return ( + + Stop Sharing + + ); + } + return ( + + Live Location ended + + ); +}; + +const MessageLocationComponent = ({ + shared_location, +}: { + shared_location: SharedLocationResponse; +}) => { + const { client } = useChatContext(); + const { end_at, latitude, longitude } = shared_location || {}; + const mapRef = useRef(null); + const markerRef = useRef(null); + + const { width, height } = useWindowDimensions(); + const aspect_ratio = width / height; + + const { + theme: { + colors: { accent_blue }, + }, + } = useTheme(); + + const region = useMemo(() => { + const latitudeDelta = 0.1; + const longitudeDelta = latitudeDelta * aspect_ratio; + return { + latitude, + longitude, + latitudeDelta, + longitudeDelta, + }; + }, [aspect_ratio, latitude, longitude]); + + useEffect(() => { + if (!region) { + return; + } + const newPosition = { + latitude, + longitude, + latitudeDelta: 0.1, + longitudeDelta: 0.1 * aspect_ratio, + }; + // Animate the map to the new position + if (mapRef.current?.animateToRegion) { + mapRef.current.animateToRegion(newPosition, 500); + } + // This is android only + if (Platform.OS === 'android' && markerRef.current?.animateMarkerToCoordinate) { + markerRef.current.animateMarkerToCoordinate(newPosition, 500); + } + }, [aspect_ratio, latitude, longitude, region]); + + if (!region) { + return null; + } + + return ( + + + {end_at ? ( + + + + + + ) : ( + + )} + + + + ); +}; + +export const MessageLocation = ({ message }: MessageLocationProps) => { + const { shared_location } = message; + + if (!shared_location) { + return null; + } + + return ; +}; + +const IMAGE_SIZE = 35; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + mapView: { + height: 250, + width: 250, + }, + textStyle: { + fontSize: 12, + color: 'gray', + marginHorizontal: 12, + marginVertical: 4, + }, + markerWrapper: { + overflow: 'hidden', // REQUIRED for rounded corners to show on Android + }, + markerImage: { + width: IMAGE_SIZE, + height: IMAGE_SIZE, + borderRadius: IMAGE_SIZE / 2, + resizeMode: 'cover', // or 'contain' if image is cropped + borderWidth: 2, + }, + footer: { + marginVertical: 8, + }, + footerText: { + textAlign: 'center', + fontSize: 14, + }, + footerDescription: { + textAlign: 'center', + fontSize: 12, + }, +}); diff --git a/examples/SampleApp/src/components/Reminders/ReminderItem.tsx b/examples/SampleApp/src/components/Reminders/ReminderItem.tsx index 9f1b578bc1..66ae96f25a 100644 --- a/examples/SampleApp/src/components/Reminders/ReminderItem.tsx +++ b/examples/SampleApp/src/components/Reminders/ReminderItem.tsx @@ -16,7 +16,7 @@ import { getPreviewFromMessage } from '../../utils/getPreviewOfMessage'; export const ReminderItem = ( item: ReminderResponse & { onDeleteHandler?: (id: string) => void }, ) => { - const { channel, message, onDeleteHandler } = item; + const { channel, message } = item; const navigation = useNavigation(); const { client } = useChatContext(); const { t } = useTranslationContext(); @@ -54,16 +54,15 @@ export const ReminderItem = ( text: 'Remove', onPress: async () => { await client.reminders.deleteReminder(item.message_id); - onDeleteHandler?.(item.message_id); }, style: 'destructive', }, ]); - }, [client.reminders, item.message_id, onDeleteHandler]); + }, [client.reminders, item.message_id]); const updateButtons = useMemo(() => { const buttons: AlertButton[] = client.reminders.scheduledOffsetsMs.map((offsetMs) => ({ - text: t('timestamp/Remind me', { milliseconds: offsetMs }), + text: t('duration/Remind Me', { milliseconds: offsetMs }), onPress: async () => { await client.reminders.upsertReminder({ messageId: item.message_id, diff --git a/examples/SampleApp/src/context/StreamChatContext.tsx b/examples/SampleApp/src/context/StreamChatContext.tsx new file mode 100644 index 0000000000..f9f34a7847 --- /dev/null +++ b/examples/SampleApp/src/context/StreamChatContext.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { PropsWithChildren, createContext, useState } from 'react'; +import { Channel as ChannelType } from 'stream-chat'; +import { ThreadContextValue } from 'stream-chat-react-native'; + +export type StreamChatContextType = { + channel: ChannelType | undefined; + setChannel: React.Dispatch>; + setThread: React.Dispatch>; + thread: ThreadContextValue['thread'] | undefined; +}; + +export const StreamChatContext = createContext({ + channel: undefined, + setChannel: () => {}, + setThread: () => {}, + thread: undefined, +}); + +export const StreamChatProvider = ({ children }: PropsWithChildren) => { + const [channel, setChannel] = useState(undefined); + const [thread, setThread] = useState(undefined); + + return ( + + {children} + + ); +}; + +export const useStreamChatContext = () => { + const context = React.useContext(StreamChatContext); + if (!context) { + throw new Error('useStreamChatContext must be used within a StreamChatProvider'); + } + return context; +}; diff --git a/examples/SampleApp/src/icons/ShareLocationIcon.tsx b/examples/SampleApp/src/icons/ShareLocationIcon.tsx new file mode 100644 index 0000000000..79c1e13584 --- /dev/null +++ b/examples/SampleApp/src/icons/ShareLocationIcon.tsx @@ -0,0 +1,25 @@ +import Svg, { Path } from 'react-native-svg'; +import { useTheme } from 'stream-chat-react-native'; + +// Icon for "Share Location" button, next to input box. +export const ShareLocationIcon = () => { + const { + theme: { + colors: { grey }, + }, + } = useTheme(); + return ( + + + + + ); +}; diff --git a/examples/SampleApp/src/screens/ChannelListScreen.tsx b/examples/SampleApp/src/screens/ChannelListScreen.tsx index e7b5e4ef96..796406673d 100644 --- a/examples/SampleApp/src/screens/ChannelListScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelListScreen.tsx @@ -18,6 +18,7 @@ import { useAppContext } from '../context/AppContext'; import { usePaginatedSearchedMessages } from '../hooks/usePaginatedSearchedMessages'; import type { ChannelSort } from 'stream-chat'; +import { useStreamChatContext } from '../context/StreamChatContext'; const styles = StyleSheet.create({ channelListContainer: { @@ -77,6 +78,7 @@ export const ChannelListScreen: React.FC = () => { colors: { black, grey, grey_gainsboro, grey_whisper, white, white_snow }, }, } = useTheme(); + const { setChannel } = useStreamChatContext(); const searchInputRef = useRef(null); const scrollRef = useRef | null>(null); @@ -124,11 +126,12 @@ export const ChannelListScreen: React.FC = () => { const onSelect = useCallback( (channel: Channel) => { + setChannel(channel); navigation.navigate('ChannelScreen', { channel, }); }, - [navigation], + [navigation, setChannel], ); const setScrollRef = useCallback( diff --git a/examples/SampleApp/src/screens/ChannelScreen.tsx b/examples/SampleApp/src/screens/ChannelScreen.tsx index 50066cb1f1..4a98a4317e 100644 --- a/examples/SampleApp/src/screens/ChannelScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelScreen.tsx @@ -29,6 +29,9 @@ import { NetworkDownIndicator } from '../components/NetworkDownIndicator'; import { useCreateDraftFocusEffect } from '../utils/useCreateDraftFocusEffect.tsx'; import { MessageReminderHeader } from '../components/Reminders/MessageReminderHeader.tsx'; import { channelMessageActions } from '../utils/messageActions.tsx'; +import { MessageLocation } from '../components/LocationSharing/MessageLocation.tsx'; +import { useStreamChatContext } from '../context/StreamChatContext.tsx'; +import { CustomAttachmentPickerSelectionBar } from '../components/AttachmentPickerSelectionBar.tsx'; export type ChannelScreenNavigationProp = StackNavigationProp< StackNavigatorParamList, @@ -122,6 +125,7 @@ export const ChannelScreen: React.FC = ({ theme: { colors }, } = useTheme(); const { t } = useTranslationContext(); + const { setThread } = useStreamChatContext(); const [channel, setChannel] = useState(channelFromProp); @@ -151,15 +155,27 @@ export const ChannelScreen: React.FC = ({ setSelectedThread(undefined); }); + const onPressMessage: NonNullable['onPressMessage']> = ( + payload, + ) => { + const { message, defaultHandler, emitter } = payload; + const { shared_location } = message ?? {}; + if (emitter === 'messageContent' && shared_location) { + navigation.navigate('MapScreen', shared_location); + } + defaultHandler?.(); + }; + const onThreadSelect = useCallback( (thread: LocalMessage | null) => { setSelectedThread(thread); + setThread(thread); navigation.navigate('ThreadScreen', { channel, thread, }); }, - [channel, navigation], + [channel, navigation, setThread], ); const messageActions = useCallback( @@ -185,13 +201,16 @@ export const ChannelScreen: React.FC = ({ null} thread={selectedThread} diff --git a/examples/SampleApp/src/screens/MapScreen.tsx b/examples/SampleApp/src/screens/MapScreen.tsx new file mode 100644 index 0000000000..062bacce09 --- /dev/null +++ b/examples/SampleApp/src/screens/MapScreen.tsx @@ -0,0 +1,248 @@ +import { StackNavigationProp } from '@react-navigation/stack'; +import { StackNavigatorParamList } from '../types'; +import { RouteProp } from '@react-navigation/native'; +import { + Platform, + Pressable, + useWindowDimensions, + StyleSheet, + View, + Image, + Text, +} from 'react-native'; +import { useMemo, useCallback, useRef } from 'react'; +import { useChatContext, useHandleLiveLocationEvents, useTheme } from 'stream-chat-react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import MapView, { MapMarker, Marker } from 'react-native-maps'; +import { SharedLocationResponse, StreamChat } from 'stream-chat'; +import { useStreamChatContext } from '../context/StreamChatContext'; + +export type MapScreenNavigationProp = StackNavigationProp; +export type MapScreenRouteProp = RouteProp; +export type MapScreenProps = { + navigation: MapScreenNavigationProp; + route: MapScreenRouteProp; +}; + +export type SharedLiveLocationParamsStringType = SharedLocationResponse & { + latitude: string; + longitude: string; +}; + +const MapScreenFooter = ({ + client, + shared_location, + locationResponse, + isLiveLocationStopped, +}: { + client: StreamChat; + shared_location: SharedLocationResponse; + locationResponse?: SharedLocationResponse; + isLiveLocationStopped: boolean | null; +}) => { + const { channel } = useStreamChatContext(); + const { end_at, user_id } = shared_location; + const { + theme: { + colors: { accent_blue, accent_red, grey }, + }, + } = useTheme(); + const liveLocationActive = isLiveLocationStopped + ? false + : end_at && new Date(end_at) > new Date(); + const endedAtDate = end_at ? new Date(end_at) : null; + const formattedEndedAt = endedAtDate ? endedAtDate.toLocaleString() : ''; + + const stopSharingLiveLocation = useCallback(async () => { + if (!locationResponse) { + return; + } + await channel?.stopLiveLocationSharing(locationResponse); + }, [channel, locationResponse]); + + if (!end_at) { + return null; + } + + const isCurrentUser = user_id === client.user?.id; + if (!isCurrentUser) { + return ( + + + {liveLocationActive ? 'Live Location' : 'Live Location ended'} + + + {liveLocationActive + ? `Live until: ${formattedEndedAt}` + : `Location last updated at: ${formattedEndedAt}`} + + + ); + } + + if (liveLocationActive) { + return ( + + [styles.footerButton, { opacity: pressed ? 0.5 : 1 }]} + onPress={stopSharingLiveLocation} + hitSlop={10} + > + Stop Sharing + + + + Live until: {formattedEndedAt} + + + ); + } + + return ( + + Live Location ended + + Location last updated at: {formattedEndedAt} + + + ); +}; + +export const MapScreen = ({ route }: MapScreenProps) => { + const { client } = useChatContext(); + const shared_location = route.params as SharedLocationResponse; + const { channel } = useStreamChatContext(); + const mapRef = useRef(null); + const markerRef = useRef(null); + const { + theme: { + colors: { accent_blue }, + }, + } = useTheme(); + + const { width, height } = useWindowDimensions(); + const aspect_ratio = width / height; + + const onLocationUpdate = useCallback( + (location: SharedLocationResponse) => { + const newPosition = { + latitude: location.latitude, + longitude: location.longitude, + latitudeDelta: 0.1, + longitudeDelta: 0.1 * aspect_ratio, + }; + // Animate the map to the new position + if (mapRef.current?.animateToRegion) { + mapRef.current.animateToRegion(newPosition, 500); + } + // This is android only + if (Platform.OS === 'android' && markerRef.current?.animateMarkerToCoordinate) { + markerRef.current.animateMarkerToCoordinate(newPosition, 500); + } + }, + [aspect_ratio], + ); + + const { isLiveLocationStopped, locationResponse } = useHandleLiveLocationEvents({ + channel, + messageId: shared_location.message_id, + onLocationUpdate, + }); + + const initialRegion = useMemo(() => { + const latitudeDelta = 0.1; + const longitudeDelta = latitudeDelta * aspect_ratio; + + return { + latitude: shared_location.latitude, + longitude: shared_location.longitude, + latitudeDelta, + longitudeDelta, + }; + }, [aspect_ratio, shared_location.latitude, shared_location.longitude]); + + const region = useMemo(() => { + const latitudeDelta = 0.1; + const longitudeDelta = latitudeDelta * aspect_ratio; + return { + latitude: locationResponse?.latitude ?? 0, + longitude: locationResponse?.longitude ?? 0, + latitudeDelta, + longitudeDelta, + }; + }, [aspect_ratio, locationResponse]); + + return ( + + + {shared_location.end_at ? ( + + + + + + ) : ( + + )} + + + + ); +}; + +const IMAGE_SIZE = 35; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + mapView: { + width: 'auto', + flex: 3, + }, + markerWrapper: { + overflow: 'hidden', // REQUIRED for rounded corners to show on Android + }, + markerImage: { + width: IMAGE_SIZE, + height: IMAGE_SIZE, + borderRadius: IMAGE_SIZE / 2, + resizeMode: 'cover', // or 'contain' if image is cropped + borderWidth: 2, + }, + footer: { + marginVertical: 8, + }, + footerButton: { + padding: 4, + }, + footerText: { + textAlign: 'center', + fontSize: 14, + }, + footerDescription: { + textAlign: 'center', + fontSize: 12, + marginTop: 4, + }, +}); diff --git a/examples/SampleApp/src/screens/ThreadScreen.tsx b/examples/SampleApp/src/screens/ThreadScreen.tsx index a648c6491c..dcba48e518 100644 --- a/examples/SampleApp/src/screens/ThreadScreen.tsx +++ b/examples/SampleApp/src/screens/ThreadScreen.tsx @@ -22,6 +22,10 @@ import { LocalMessage, ThreadState, UserResponse } from 'stream-chat'; import { useCreateDraftFocusEffect } from '../utils/useCreateDraftFocusEffect.tsx'; import { MessageReminderHeader } from '../components/Reminders/MessageReminderHeader.tsx'; import { channelMessageActions } from '../utils/messageActions.tsx'; +import { useStreamChatContext } from '../context/StreamChatContext.tsx'; +import { StackNavigationProp } from '@react-navigation/stack'; +import { CustomAttachmentPickerSelectionBar } from '../components/AttachmentPickerSelectionBar.tsx'; +import { MessageLocation } from '../components/LocationSharing/MessageLocation.tsx'; const selector = (nextValue: ThreadState) => ({ parentMessage: nextValue.parentMessage }) as const; @@ -31,9 +35,11 @@ const styles = StyleSheet.create({ }, }); +type ThreadScreenNavigationProp = StackNavigationProp; type ThreadScreenRouteProp = RouteProp; type ThreadScreenProps = { + navigation: ThreadScreenNavigationProp; route: ThreadScreenRouteProp; }; @@ -63,6 +69,7 @@ const ThreadHeader: React.FC = ({ thread }) => { }; export const ThreadScreen: React.FC = ({ + navigation, route: { params: { channel, thread }, }, @@ -74,6 +81,18 @@ export const ThreadScreen: React.FC = ({ } = useTheme(); const { client: chatClient } = useChatContext(); const { t } = useTranslationContext(); + const { setThread } = useStreamChatContext(); + + const onPressMessage: NonNullable['onPressMessage']> = ( + payload, + ) => { + const { message, defaultHandler, emitter } = payload; + const { shared_location } = message ?? {}; + if (emitter === 'messageContent' && shared_location) { + navigation.navigate('MapScreen', shared_location); + } + defaultHandler?.(); + }; const messageActions = useCallback( (params: MessageActionsParams) => { @@ -89,21 +108,28 @@ export const ThreadScreen: React.FC = ({ [chatClient, t], ); + const onThreadDismount = useCallback(() => { + setThread(null); + }, [setThread]); + return ( - + diff --git a/examples/SampleApp/src/types.ts b/examples/SampleApp/src/types.ts index 39b958ad28..1fc11fc7f6 100644 --- a/examples/SampleApp/src/types.ts +++ b/examples/SampleApp/src/types.ts @@ -1,4 +1,4 @@ -import type { Channel, LocalMessage, UserResponse } from 'stream-chat'; +import type { Channel, LocalMessage, SharedLocationResponse, UserResponse } from 'stream-chat'; import type { ThreadType } from 'stream-chat-react-native'; import type { Theme } from '@react-navigation/native'; @@ -23,6 +23,7 @@ export type StackNavigatorParamList = { channelId?: string; messageId?: string; }; + MapScreen: SharedLocationResponse; GroupChannelDetailsScreen: { channel: Channel; }; diff --git a/examples/SampleApp/src/utils/messageActions.tsx b/examples/SampleApp/src/utils/messageActions.tsx index 7033c318b9..e462763e22 100644 --- a/examples/SampleApp/src/utils/messageActions.tsx +++ b/examples/SampleApp/src/utils/messageActions.tsx @@ -66,7 +66,7 @@ export function channelMessageActions({ 'Select Reminder Time', 'When would you like to be reminded?', chatClient.reminders.scheduledOffsetsMs.map((offsetMs) => ({ - text: t('timestamp/Remind me', { milliseconds: offsetMs }), + text: t('duration/Remind Me', { milliseconds: offsetMs }), onPress: () => { chatClient.reminders .upsertReminder({ diff --git a/examples/SampleApp/src/utils/watchLocation.ts b/examples/SampleApp/src/utils/watchLocation.ts new file mode 100644 index 0000000000..8604ee5249 --- /dev/null +++ b/examples/SampleApp/src/utils/watchLocation.ts @@ -0,0 +1,28 @@ +import GeoLocation from '@react-native-community/geolocation'; + +type LocationHandler = (value: { latitude: number; longitude: number }) => void; + +export const watchLocation = (handler: LocationHandler) => { + let watchId: number | null = null; + watchId = GeoLocation.watchPosition( + (position) => { + const { latitude, longitude } = position.coords; + handler({ latitude, longitude }); + }, + (error) => { + console.warn('Error watching location:', error); + }, + { + enableHighAccuracy: true, + timeout: 20000, + maximumAge: 1000, + interval: 2000, // android only + }, + ); + + return () => { + if (watchId) { + GeoLocation.clearWatch(watchId); + } + }; +}; diff --git a/examples/SampleApp/yarn.lock b/examples/SampleApp/yarn.lock index ade5c62f9c..0ff2b91ca8 100644 --- a/examples/SampleApp/yarn.lock +++ b/examples/SampleApp/yarn.lock @@ -2105,6 +2105,11 @@ prompts "^2.4.2" semver "^7.5.2" +"@react-native-community/geolocation@^3.4.0": + version "3.4.0" + resolved "https://registry.yarnpkg.com/@react-native-community/geolocation/-/geolocation-3.4.0.tgz#8b6ee024a71cf94526ab796af1e9ae140684802c" + integrity sha512-bzZH89/cwmpkPMKKveoC72C4JH0yF4St5Ceg/ZM9pA1SqX9MlRIrIrrOGZ/+yi++xAvFDiYfihtn9TvXWU9/rA== + "@react-native-community/netinfo@^11.4.1": version "11.4.1" resolved "https://registry.yarnpkg.com/@react-native-community/netinfo/-/netinfo-11.4.1.tgz#a3c247aceab35f75dd0aa4bfa85d2be5a4508688" @@ -2498,6 +2503,11 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.6.tgz#628effeeae2064a1b4e79f78e81d87b7e5fc7b50" integrity sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw== +"@types/geojson@^7946.0.13": + version "7946.0.16" + resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.16.tgz#8ebe53d69efada7044454e3305c19017d97ced2a" + integrity sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg== + "@types/graceful-fs@^4.1.3": version "4.1.9" resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.9.tgz#2a06bc0f68a20ab37b3e36aa238be6abdf49e8b4" @@ -7291,6 +7301,13 @@ react-native-lightbox@^0.7.0: dependencies: prop-types "^15.5.10" +react-native-maps@1.20.1: + version "1.20.1" + resolved "https://registry.yarnpkg.com/react-native-maps/-/react-native-maps-1.20.1.tgz#f613364886b2f72db56cafcd2bd7d3a35f470dfb" + integrity sha512-NZI3B5Z6kxAb8gzb2Wxzu/+P2SlFIg1waHGIpQmazDSCRkNoHNY4g96g+xS0QPSaG/9xRBbDNnd2f2/OW6t6LQ== + dependencies: + "@types/geojson" "^7946.0.13" + react-native-markdown-package@1.8.2: version "1.8.2" resolved "https://registry.yarnpkg.com/react-native-markdown-package/-/react-native-markdown-package-1.8.2.tgz#19db1047e174077f9b9f80303938c775b1c223c0" @@ -7922,10 +7939,10 @@ stream-chat-react-native-core@8.1.0: version "0.0.0" uid "" -stream-chat@^9.11.0: - version "9.11.0" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.11.0.tgz#412b5f2f55cc3b76e862d88cb3e95b9720493575" - integrity sha512-mnf86LMBPmCZUrmrU6bdaOUoxRqpXUvw7prl4b5q6WPNWZ+p/41UN8vllBAaZvAS7jPj9JYWvbhUe40VZ6o8/g== +stream-chat@^9.12.0: + version "9.12.0" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.12.0.tgz#d27b1319844d100ca419c61463f5c65f53c87952" + integrity sha512-/A4y8jBmWdP53RUY9f8dlc8dRjC/irR5KUMiOhn0IiAEmK2fKuD/7IpUzXi7cNmR8QMxlHdDMJZdB2wMDkiskQ== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14" diff --git a/examples/TypeScriptMessaging/yarn.lock b/examples/TypeScriptMessaging/yarn.lock index 0795351e72..a188e27bd0 100644 --- a/examples/TypeScriptMessaging/yarn.lock +++ b/examples/TypeScriptMessaging/yarn.lock @@ -7288,10 +7288,10 @@ stream-chat-react-native-core@8.1.0: version "0.0.0" uid "" -stream-chat@^9.11.0: - version "9.11.0" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.11.0.tgz#412b5f2f55cc3b76e862d88cb3e95b9720493575" - integrity sha512-mnf86LMBPmCZUrmrU6bdaOUoxRqpXUvw7prl4b5q6WPNWZ+p/41UN8vllBAaZvAS7jPj9JYWvbhUe40VZ6o8/g== +stream-chat@^9.12.0: + version "9.12.0" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.12.0.tgz#d27b1319844d100ca419c61463f5c65f53c87952" + integrity sha512-/A4y8jBmWdP53RUY9f8dlc8dRjC/irR5KUMiOhn0IiAEmK2fKuD/7IpUzXi7cNmR8QMxlHdDMJZdB2wMDkiskQ== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14" diff --git a/package/package.json b/package/package.json index aaf988bc70..b7b52bf93c 100644 --- a/package/package.json +++ b/package/package.json @@ -78,7 +78,7 @@ "path": "0.12.7", "react-native-markdown-package": "1.8.2", "react-native-url-polyfill": "^2.0.0", - "stream-chat": "^9.11.0", + "stream-chat": "^9.12.0", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 802e4a2632..d16a921829 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -352,6 +352,7 @@ export type ChannelPropsWithContext = Pick & | 'MessageFooter' | 'MessageHeader' | 'MessageList' + | 'MessageLocation' | 'MessageMenu' | 'MessagePinnedHeader' | 'MessageReplies' @@ -634,6 +635,7 @@ const ChannelWithContext = (props: PropsWithChildren) = 'ai_text', 'text', 'attachments', + 'location', ], MessageDeleted = MessageDeletedDefault, MessageEditedTimestamp = MessageEditedTimestampDefault, @@ -642,6 +644,7 @@ const ChannelWithContext = (props: PropsWithChildren) = MessageHeader, messageId, MessageList = MessageListDefault, + MessageLocation, MessageMenu = MessageMenuDefault, MessagePinnedHeader = MessagePinnedHeaderDefault, MessageReactionPicker = MessageReactionPickerDefault, @@ -1866,6 +1869,7 @@ const ChannelWithContext = (props: PropsWithChildren) = MessageFooter, MessageHeader, MessageList, + MessageLocation, MessageMenu, MessagePinnedHeader, MessageReactionPicker, diff --git a/package/src/components/Channel/hooks/useCreateMessagesContext.ts b/package/src/components/Channel/hooks/useCreateMessagesContext.ts index a6ff1ef0a8..a1b21d0f16 100644 --- a/package/src/components/Channel/hooks/useCreateMessagesContext.ts +++ b/package/src/components/Channel/hooks/useCreateMessagesContext.ts @@ -66,6 +66,7 @@ export const useCreateMessagesContext = ({ MessageFooter, MessageHeader, MessageList, + MessageLocation, MessageMenu, MessagePinnedHeader, MessageReactionPicker, @@ -181,6 +182,7 @@ export const useCreateMessagesContext = ({ MessageFooter, MessageHeader, MessageList, + MessageLocation, MessageMenu, MessagePinnedHeader, MessageReactionPicker, diff --git a/package/src/components/ChannelPreview/hooks/useLatestMessagePreview.ts b/package/src/components/ChannelPreview/hooks/useLatestMessagePreview.ts index aa522bcc3e..548b1f6094 100644 --- a/package/src/components/ChannelPreview/hooks/useLatestMessagePreview.ts +++ b/package/src/components/ChannelPreview/hooks/useLatestMessagePreview.ts @@ -120,6 +120,13 @@ const getLatestMessageDisplayText = ( ]; } } + // Location messages + if (message.shared_location) { + return [ + { bold: false, text: '📍' }, + { bold: false, text: t('Location') }, + ]; + } if (message.text) { // rough guess optimization to limit string preview to max 100 characters diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index b3f42cd86d..957f3f72b1 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -444,6 +444,8 @@ const MessageWithContext = (props: MessagePropsWithContext) => { return !!message.poll_id; case 'ai_text': return isAIGenerated; + case 'location': + return !!message.shared_location; case 'text': default: return !!message.text; @@ -917,6 +919,17 @@ const areEqual = (prevProps: MessagePropsWithContext, nextProps: MessagePropsWit return false; } + const prevSharedLocation = prevMessage.shared_location; + const nextSharedLocation = nextMessage.shared_location; + const sharedLocationEqual = + prevSharedLocation?.latitude === nextSharedLocation?.latitude && + prevSharedLocation?.longitude === nextSharedLocation?.longitude && + prevSharedLocation?.end_at === nextSharedLocation?.end_at; + + if (!sharedLocationEqual) { + return false; + } + return true; }; diff --git a/package/src/components/Message/MessageSimple/MessageContent.tsx b/package/src/components/Message/MessageSimple/MessageContent.tsx index c3885e99c2..6c7ca849bd 100644 --- a/package/src/components/Message/MessageSimple/MessageContent.tsx +++ b/package/src/components/Message/MessageSimple/MessageContent.tsx @@ -92,6 +92,7 @@ export type MessageContentPropsWithContext = Pick< | 'Gallery' | 'isAttachmentEqual' | 'MessageError' + | 'MessageLocation' | 'myMessageTheme' | 'Reply' | 'StreamingMessageView' @@ -136,6 +137,7 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => { messageContentOrder, messageGroupedSingleOrBottom = false, MessageError, + MessageLocation, noBorder, onLongPress, onPress, @@ -339,6 +341,13 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => { /> ) : null; } + case 'location': + return MessageLocation ? ( + + ) : null; case 'ai_text': return isAIGenerated ? ( { Gallery, isAttachmentEqual, MessageError, + MessageLocation, myMessageTheme, Reply, StreamingMessageView, @@ -558,6 +579,7 @@ export const MessageContent = (props: MessageContentProps) => { message, messageContentOrder, MessageError, + MessageLocation, myMessageTheme, onLongPress, onPress, diff --git a/package/src/components/Reply/Reply.tsx b/package/src/components/Reply/Reply.tsx index 26b4cd6be3..1ad146941e 100644 --- a/package/src/components/Reply/Reply.tsx +++ b/package/src/components/Reply/Reply.tsx @@ -282,21 +282,23 @@ const ReplyWithContext = (props: ReplyPropsWithContext) => { text: quotedMessage.type === 'deleted' ? `_${t('Message deleted')}_` - : pollName - ? `📊 ${pollName}` - : quotedMessage.text - ? quotedMessage.text.length > 170 - ? `${quotedMessage.text.slice(0, 170)}...` - : quotedMessage.text - : messageType === FileTypes.Image - ? t('Photo') - : messageType === FileTypes.Video - ? t('Video') - : messageType === FileTypes.File || - messageType === FileTypes.Audio || - messageType === FileTypes.VoiceRecording - ? trimmedLastAttachmentTitle || '' - : '', + : quotedMessage.shared_location + ? '📍' + t('Location') + : pollName + ? `📊 ${pollName}` + : quotedMessage.text + ? quotedMessage.text.length > 170 + ? `${quotedMessage.text.slice(0, 170)}...` + : quotedMessage.text + : messageType === FileTypes.Image + ? t('Photo') + : messageType === FileTypes.Video + ? t('Video') + : messageType === FileTypes.File || + messageType === FileTypes.Audio || + messageType === FileTypes.VoiceRecording + ? trimmedLastAttachmentTitle || '' + : '', }} onlyEmojis={onlyEmojis} styles={{ diff --git a/package/src/contexts/index.ts b/package/src/contexts/index.ts index a17bebab42..0ec7f3d636 100644 --- a/package/src/contexts/index.ts +++ b/package/src/contexts/index.ts @@ -27,3 +27,4 @@ export * from './translationContext'; export * from './typingContext/TypingContext'; export * from './utils/getDisplayName'; export * from './pollContext'; +export * from './liveLocationManagerContext'; diff --git a/package/src/contexts/liveLocationManagerContext/LiveLocationManagerContext.tsx b/package/src/contexts/liveLocationManagerContext/LiveLocationManagerContext.tsx new file mode 100644 index 0000000000..7172b939d1 --- /dev/null +++ b/package/src/contexts/liveLocationManagerContext/LiveLocationManagerContext.tsx @@ -0,0 +1,61 @@ +import React, { createContext, useContext, useEffect, useMemo } from 'react'; + +import { Platform } from 'react-native'; + +import { LiveLocationManager, WatchLocation } from 'stream-chat'; + +import { useChatContext } from '../chatContext/ChatContext'; + +interface LiveLocationManagerContextValue { + liveLocationManager: LiveLocationManager | null; +} + +const LiveLocationManagerContext = createContext({ + liveLocationManager: null, +}); + +export const useLiveLocationManagerContext = () => { + return useContext(LiveLocationManagerContext); +}; + +export type LiveLocationManagerProviderProps = { + watchLocation: WatchLocation; + getDeviceId?: () => string; +}; + +export const LiveLocationManagerProvider = ( + props: React.PropsWithChildren, +) => { + const { client } = useChatContext(); + const { watchLocation, getDeviceId, children } = props; + + const liveLocationManager = useMemo(() => { + if (!client) { + return null; + } + // Create a new instance of LiveLocationManager with the client and utility functions + return new LiveLocationManager({ + client, + getDeviceId: getDeviceId ?? (() => `react-native-${Platform.OS}-${client.userID}`), + watchLocation, + }); + }, [client, getDeviceId, watchLocation]); + + useEffect(() => { + if (!liveLocationManager) { + return; + } + // Initialize the live location manager + liveLocationManager.init(); + + return () => { + liveLocationManager.unregisterSubscriptions(); + }; + }, [liveLocationManager]); + + return ( + + {children} + + ); +}; diff --git a/package/src/contexts/liveLocationManagerContext/hooks/useHandleLiveLocationEvents.ts b/package/src/contexts/liveLocationManagerContext/hooks/useHandleLiveLocationEvents.ts new file mode 100644 index 0000000000..d380989d27 --- /dev/null +++ b/package/src/contexts/liveLocationManagerContext/hooks/useHandleLiveLocationEvents.ts @@ -0,0 +1,61 @@ +import { useEffect, useState } from 'react'; + +import { Channel, Event, SharedLocationResponse } from 'stream-chat'; + +import { useChatContext } from '../../../contexts/chatContext/ChatContext'; + +export type UseLiveLocationsEventsParams = { + /** + * The channel where the live location is shared. + */ + channel: Channel; + /** + * The ID of the message containing the shared location. + */ + messageId: string; + /** + * Callback function to handle location updates. + * It receives the updated shared location response. + */ + onLocationUpdate?: (location: SharedLocationResponse) => void; +}; + +/** + * Custom hook to handle live location events. + */ +export const useHandleLiveLocationEvents = ({ + channel, + messageId, + onLocationUpdate, +}: UseLiveLocationsEventsParams) => { + const { client } = useChatContext(); + const [locationResponse, setLocationResponse] = useState( + undefined, + ); + const [isLiveLocationStopped, setIsLiveLocationStopped] = useState(null); + + useEffect(() => { + const handleMessageUpdate = (event: Event) => { + const { message } = event; + if (!message || !message.shared_location) { + return; + } + const { shared_location } = message; + if (message.id === messageId) { + setLocationResponse(message.shared_location); + onLocationUpdate?.(message.shared_location); + } + if (shared_location.end_at && shared_location.end_at <= new Date().toISOString()) { + setIsLiveLocationStopped(true); + } + }; + + const listener = [channel.on('message.updated', handleMessageUpdate)]; + + return () => { + listener.forEach((l) => l.unsubscribe()); + }; + }, [channel, client, messageId, onLocationUpdate]); + + return { isLiveLocationStopped, locationResponse }; +}; diff --git a/package/src/contexts/liveLocationManagerContext/index.ts b/package/src/contexts/liveLocationManagerContext/index.ts new file mode 100644 index 0000000000..b36c2559f7 --- /dev/null +++ b/package/src/contexts/liveLocationManagerContext/index.ts @@ -0,0 +1,2 @@ +export * from './LiveLocationManagerContext'; +export * from './hooks/useHandleLiveLocationEvents'; diff --git a/package/src/contexts/messageInputContext/MessageInputContext.tsx b/package/src/contexts/messageInputContext/MessageInputContext.tsx index 17b2cfb425..c84726f9c3 100644 --- a/package/src/contexts/messageInputContext/MessageInputContext.tsx +++ b/package/src/contexts/messageInputContext/MessageInputContext.tsx @@ -579,10 +579,7 @@ export const MessageInputProvider = ({ try { const composition = await messageComposer.compose(); - // This is added to ensure the input box is cleared if there's no change and user presses on the send button. - if (!composition && editedMessage) { - clearEditingState(); - } + if (!composition || !composition.message) return; const { localMessage, message, sendOptions } = composition; diff --git a/package/src/contexts/messagesContext/MessagesContext.tsx b/package/src/contexts/messagesContext/MessagesContext.tsx index 6910bf09a8..441faa5a8f 100644 --- a/package/src/contexts/messagesContext/MessagesContext.tsx +++ b/package/src/contexts/messagesContext/MessagesContext.tsx @@ -82,9 +82,14 @@ export type MessageContentType = | 'quoted_reply' | 'poll' | 'ai_text' - | 'text'; + | 'text' + | 'location'; export type DeletedMessagesVisibilityType = 'always' | 'never' | 'receiver' | 'sender'; +export type MessageLocationProps = { + message: LocalMessage; +}; + export type MessagesContextValue = Pick & { /** * UI component for Attachment. @@ -225,6 +230,7 @@ export type MessagesContextValue = Pick; MessageList: React.ComponentType; + MessageLocation?: React.ComponentType; /** * UI component for MessageMenu */ diff --git a/package/src/i18n/en.json b/package/src/i18n/en.json index 50c8e05620..3e9465eda0 100644 --- a/package/src/i18n/en.json +++ b/package/src/i18n/en.json @@ -26,6 +26,8 @@ "Device camera is used to take photos or videos.": "Device camera is used to take photos or videos.", "Device gallery permissions is used to take photos or videos.": "Device gallery permissions is used to take photos or videos.", "Do you want to send a copy of this message to a moderator for further investigation?": "Do you want to send a copy of this message to a moderator for further investigation?", + "Due since {{ dueSince }}": "Due since {{ dueSince }}", + "Due {{ timeLeft }}": "Due {{ timeLeft }}", "Edit Message": "Edit Message", "Edited": "Edited", "Editing Message": "Editing Message", @@ -52,6 +54,7 @@ "Loading messages...": "Loading messages...", "Loading threads...": "Loading threads...", "Loading...": "Loading...", + "Location": "Location", "Mark as Unread": "Mark as Unread", "Maximum number of files reached": "Maximum number of files reached", "Maximum votes per person": "Maximum votes per person", @@ -87,11 +90,13 @@ "Resend": "Resend", "SEND": "SEND", "Search": "Search", + "See all {{count}} options_many": "See all {{count}} options_many", "See all {{count}} options_one": "See all {{count}} options", "See all {{count}} options_other": "See all {{count}} options", "Select More Photos": "Select More Photos", "Select one": "Select one", "Select one or more": "Select one or more", + "Select up to {{count}}_many": "Select up to {{count}}", "Select up to {{count}}_one": "Select up to {{count}}", "Select up to {{count}}_other": "Select up to {{count}}", "Send Anyway": "Send Anyway", @@ -116,12 +121,16 @@ "Update your comment": "Update your comment", "Video": "Video", "View Results": "View Results", + "View {{count}} comments_many": "View {{count}} comments", "View {{count}} comments_one": "View {{count}} comment", "View {{count}} comments_other": "View {{count}} comments", "Voice message": "Voice message", "Vote ended": "Vote ended", "You": "You", "You can't send messages in this channel": "You can't send messages in this channel", + "duration/Location end at": "{{ milliseconds | durationFormatter(withSuffix: false) }}", + "duration/Message reminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", + "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "replied to": "replied to", "timestamp/ChannelPreviewStatus": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[Yesterday]\", \"lastWeek\":\"dddd\", \"nextDay\":\"[Tomorrow]\", \"nextWeek\":\"dddd [at] LT\", \"sameDay\":\"LT\", \"sameElse\":\"L\"}) }}", "timestamp/ImageGalleryHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", @@ -130,6 +139,7 @@ "timestamp/MessageSystem": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(format: LT) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[Yesterday]\", \"lastWeek\":\"dddd\", \"nextDay\":\"[Tomorrow]\", \"nextWeek\":\"dddd [at] LT\", \"sameDay\":\"LT\", \"sameElse\":\"L\"}) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/StickyHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/ThreadListItem": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[Yesterday]\", \"lastWeek\":\"dddd\", \"nextDay\":\"[Tomorrow]\", \"nextWeek\":\"dddd [at] LT\", \"sameDay\":\"LT\", \"sameElse\":\"L\"}) }}", "{{ firstUser }} and {{ nonSelfUserLength }} more are typing": "{{ firstUser }} and {{ nonSelfUserLength }} more are typing", @@ -137,6 +147,7 @@ "{{ replyCount }} Replies": "{{ replyCount }} Replies", "{{ replyCount }} Thread Replies": "{{ replyCount }} Thread Replies", "{{ user }} is typing": "{{ user }} is typing", + "{{count}} votes_many": "{{count}} votes", "{{count}} votes_one": "{{count}} vote", "{{count}} votes_other": "{{count}} votes", "🏙 Attachment...": "🏙 Attachment..." diff --git a/package/src/i18n/es.json b/package/src/i18n/es.json index 942b04bab5..74657b6a1b 100644 --- a/package/src/i18n/es.json +++ b/package/src/i18n/es.json @@ -26,6 +26,8 @@ "Device camera is used to take photos or videos.": "La cámara del dispositivo se utiliza para tomar fotografías o vídeos.", "Device gallery permissions is used to take photos or videos.": "Los permisos de la galería del dispositivo se utilizan para tomar fotos o videos.", "Do you want to send a copy of this message to a moderator for further investigation?": "¿Deseas enviar una copia de este mensaje a un moderador para una investigación adicional?", + "Due since {{ dueSince }}": "Debido desde {{ dueSince }}", + "Due {{ timeLeft }}": "Debido {{ timeLeft }}", "Edit Message": "Editar mensaje", "Edited": "Editado", "Editing Message": "Editando mensaje", @@ -52,6 +54,7 @@ "Loading messages...": "Cargando mensajes...", "Loading threads...": "Cargando hilos...", "Loading...": "Cargando...", + "Location": "Ubicación", "Mark as Unread": "Marcar como no leído", "Maximum number of files reached": "Número máximo de archivos alcanzado", "Maximum votes per person": "Máximo de votos por persona", @@ -125,14 +128,19 @@ "Vote ended": "Votación finalizada", "You": "Tú", "You can't send messages in this channel": "No puedes enviar mensajes en este canal", + "duration/Message reminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", + "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "replied to": "respondió a", "timestamp/ChannelPreviewStatus": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[Ayer]\", \"lastWeek\":\"dddd\", \"nextDay\":\"[Mañana]\", \"nextWeek\":\"dddd [a las] LT\", \"sameDay\":\"LT\", \"sameElse\":\"L\"}) }}", "timestamp/ImageGalleryHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/InlineDateSeparator": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/Location end at": "{{ milliseconds | durationFormatter(withSuffix: false) }}", "timestamp/MessageEditedTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageSystem": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(format: LT) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[Ayer]\", \"lastWeek\":\"dddd\", \"nextDay\":\"[Mañana]\", \"nextWeek\":\"dddd [a las] LT\", \"sameDay\":\"LT\", \"sameElse\":\"L\"}) }}", + "timestamp/Remind me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/StickyHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/ThreadListItem": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[Ayer]\", \"lastWeek\":\"dddd\", \"nextDay\":\"[Mañana]\", \"nextWeek\":\"dddd [a las] LT\", \"sameDay\":\"LT\", \"sameElse\":\"L\"}) }}", "{{ firstUser }} and {{ nonSelfUserLength }} more are typing": "{{ firstUser }} y {{ nonSelfUserLength }} más están escribiendo", diff --git a/package/src/i18n/fr.json b/package/src/i18n/fr.json index 6d88580b3e..7ac6cfe1ea 100644 --- a/package/src/i18n/fr.json +++ b/package/src/i18n/fr.json @@ -26,6 +26,8 @@ "Device camera is used to take photos or videos.": "L'appareil photo de l'appareil est utilisé pour prendre des photos ou des vidéos.", "Device gallery permissions is used to take photos or videos.": "Les autorisations de la galerie de l'appareil sont utilisées pour prendre des photos ou des vidéos.", "Do you want to send a copy of this message to a moderator for further investigation?": "Voulez-vous envoyer une copie de ce message à un modérateur pour une enquête plus approfondie?", + "Due since {{ dueSince }}": "Échéance depuis {{ dueSince }}", + "Due {{ timeLeft }}": "Échéance {{ timeLeft }}", "Edit Message": "Éditer un message", "Edited": "Édité", "Editing Message": "Édite un message", @@ -52,6 +54,7 @@ "Loading messages...": "Chargement des messages...", "Loading threads...": "Chargement des fils...", "Loading...": "Chargement...", + "Location": "Emplacement", "Mark as Unread": "Marquer comme non lu", "Maximum number of files reached": "Nombre maximal de fichiers atteint", "Maximum votes per person": "Maximum de votes par personne", @@ -125,6 +128,9 @@ "Vote ended": "Vote terminé", "You": "Toi", "You can't send messages in this channel": "You can't send messages in this channel", + "duration/Location end at": "{{ milliseconds | durationFormatter(withSuffix: false) }}", + "duration/Message reminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", + "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "replied to": "a répondu à", "timestamp/ChannelPreviewStatus": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[Hier]\", \"lastWeek\":\"dddd\", \"nextDay\":\"[Demain]\", \"nextWeek\":\"dddd [à] LT\", \"sameDay\":\"LT\", \"sameElse\":\"L\"}) }}", "timestamp/ImageGalleryHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", @@ -133,6 +139,7 @@ "timestamp/MessageSystem": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(format: LT) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[Hier]\", \"lastWeek\":\"dddd\", \"nextDay\":\"[Demain]\", \"nextWeek\":\"dddd [à] LT\", \"sameDay\":\"LT\", \"sameElse\":\"L\"}) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/StickyHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/ThreadListItem": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[Hier]\", \"lastWeek\":\"dddd\", \"nextDay\":\"[Demain]\", \"nextWeek\":\"dddd [à] LT\", \"sameDay\":\"LT\", \"sameElse\":\"L\"}) }}", "{{ firstUser }} and {{ nonSelfUserLength }} more are typing": "{{ firstUser }} et {{ nonSelfUserLength }} autres sont en train d'écrire", diff --git a/package/src/i18n/he.json b/package/src/i18n/he.json index 7bba444c1c..2e913471ee 100644 --- a/package/src/i18n/he.json +++ b/package/src/i18n/he.json @@ -26,6 +26,8 @@ "Device camera is used to take photos or videos.": "מצלמת המכשיר משמשת לצילום תמונות או סרטונים.", "Device gallery permissions is used to take photos or videos.": "הרשאות גלריית המכשיר משמשות לצילום תמונות או סרטונים.", "Do you want to send a copy of this message to a moderator for further investigation?": "האם את/ה רוצה לשלוח עותק של הודעה זו למנחה להמשך חקירה?", + "Due since {{ dueSince }}": "מועד אחרון מאז {{ dueSince }}", + "Due {{ timeLeft }}": "מועד אחרון {{ timeLeft }}", "Edit Message": "ערוך הודעה", "Edited": "נערך", "Editing Message": "הודעה בעריכה", @@ -52,6 +54,7 @@ "Loading messages...": "ההודעות בטעינה..", "Loading threads...": "טוען שרשורים...", "Loading...": "טוען...", + "Location": "מיקום", "Mark as Unread": "סמן כלא נקרא", "Maximum number of files reached": "הגעת למספר המרבי של קבצים", "Maximum votes per person": "מקסימום הצבעות לאדם", @@ -125,6 +128,9 @@ "Vote ended": "ההצבעה הסתיימה", "You": "את/ה", "You can't send messages in this channel": "את/ב לא יכול/ה לשלוח הודעות בשיחה זו", + "duration/Location end at": "{{ milliseconds | durationFormatter(withSuffix: false) }}", + "duration/Message reminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", + "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "replied to": "הגיב ל", "timestamp/ChannelPreviewStatus": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[אתמול]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[מחר]\",\"nextWeek\":\"dddd [בשעה] LT\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", "timestamp/ImageGalleryHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", @@ -133,6 +139,7 @@ "timestamp/MessageSystem": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(format: LT) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[אתמול]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[מחר]\",\"nextWeek\":\"dddd [בשעה] LT\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/StickyHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/ThreadListItem": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[אתמול]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[מחר]\",\"nextWeek\":\"dddd [בשעה] LT\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", "{{ firstUser }} and {{ nonSelfUserLength }} more are typing": "{{ firstUser }} ו-{{ nonSelfUserLength }} משתמש/ים אחר/ים מקלידים", diff --git a/package/src/i18n/hi.json b/package/src/i18n/hi.json index 6df7889c03..5f2b5bcb4a 100644 --- a/package/src/i18n/hi.json +++ b/package/src/i18n/hi.json @@ -1,4 +1,5 @@ { + "+{{count}}_many": "+{{count}}", "+{{count}}_one": "+{{count}}", "+{{count}}_other": "+{{count}}", "1 Reply": "1 रिप्लाई", @@ -25,6 +26,8 @@ "Device camera is used to take photos or videos.": "डिवाइस कैमरे का उपयोग फ़ोटो या वीडियो लेने के लिए किया जाता है।", "Device gallery permissions is used to take photos or videos.": "डिवाइस गैलरी की अनुमतियों का उपयोग फोटो या वीडियो लेने के लिए किया जाता है।", "Do you want to send a copy of this message to a moderator for further investigation?": "क्या आप इस संदेश की एक प्रति आगे की जाँच के लिए किसी मॉडरेटर को भेजना चाहते हैं?", + "Due since {{ dueSince }}": "{{ dueSince }} से देय है", + "Due {{ timeLeft }}": "{{ timeLeft }} बचा है", "Edit Message": "मैसेज में बदलाव करे", "Edited": "मैसेज बदला गया है", "Editing Message": "मैसेज बदला जा रहा है", @@ -51,6 +54,7 @@ "Loading messages...": "मेसेजस लोड हो रहे हैं...", "Loading threads...": "थ्रेड्स लोड हो रहे हैं...", "Loading...": "लोड हो रहा है...", + "Location": "स्थान", "Mark as Unread": "अपठित मार्क करें", "Maximum number of files reached": "फ़ाइलों की अधिकतम संख्या पहुँच गई", "Maximum votes per person": "प्रति व्यक्ति अधिकतम वोट", @@ -86,11 +90,13 @@ "Resend": "पुन: भेजें", "SEND": "भेजें", "Search": "खोजें", + "See all {{count}} options_many": "सभी {{count}} विकल्प देखें", "See all {{count}} options_one": "सभी {{count}} विकल्प देखें", "See all {{count}} options_other": "सभी {{count}} विकल्प देखें", "Select More Photos": "अधिक फ़ोटो चुनें", "Select one": "एक चुनें", "Select one or more": "एक या अधिक चुनें", + "Select up to {{count}}_many": "{{count}} तक चुनें", "Select up to {{count}}_one": "{{count}} तक चुनें", "Select up to {{count}}_other": "{{count}} तक चुनें", "Send Anyway": "फिर भी भेजें", @@ -115,12 +121,16 @@ "Update your comment": "अपनी टिप्पणी अपडेट करें", "Video": "वीडियो", "View Results": "परिणाम देखें", + "View {{count}} comments_many": "सभी {{count}} टिप्पणियाँ देखें", "View {{count}} comments_one": "{{count}} टिप्पणी देखें", "View {{count}} comments_other": "{{count}} टिप्पणियाँ देखें", "Voice message": "वॉइस संदेश", "Vote ended": "वोट समाप्त", "You": "आप", "You can't send messages in this channel": "आप इस चैनल में संदेश नहीं भेज सकते", + "duration/Location end at": "{{ milliseconds | durationFormatter(withSuffix: false) }}", + "duration/Message reminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", + "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "replied to": "को उत्तर दिया", "timestamp/ChannelPreviewStatus": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[कल]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[कल]\",\"nextWeek\":\"dddd [को] LT\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", "timestamp/ImageGalleryHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", @@ -129,6 +139,7 @@ "timestamp/MessageSystem": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(format: LT) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[कल]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[कल]\",\"nextWeek\":\"dddd [को] LT\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/StickyHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/ThreadListItem": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[कल]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[कल]\",\"nextWeek\":\"dddd [को] LT\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", "{{ firstUser }} and {{ nonSelfUserLength }} more are typing": "{{ firstUser }} और {{ nonSelfUserLength }} अधिक टाइप कर रहे हैं", @@ -136,6 +147,7 @@ "{{ replyCount }} Replies": "{{ replyCount }} रिप्लाई", "{{ replyCount }} Thread Replies": "{{ replyCount }}} थ्रेड उत्तर", "{{ user }} is typing": "{{ user }} टाइप कर रहा है", + "{{count}} votes_many": "{{count}} वोट", "{{count}} votes_one": "{{count}} वोट", "{{count}} votes_other": "{{count}} वोट", "🏙 Attachment...": "🏙 अटैचमेंट..." diff --git a/package/src/i18n/it.json b/package/src/i18n/it.json index 144729cf36..728d8a44d5 100644 --- a/package/src/i18n/it.json +++ b/package/src/i18n/it.json @@ -26,6 +26,8 @@ "Device camera is used to take photos or videos.": "La fotocamera del dispositivo viene utilizzata per scattare foto o video.", "Device gallery permissions is used to take photos or videos.": "Le autorizzazioni della galleria del dispositivo vengono utilizzate per scattare foto o video.", "Do you want to send a copy of this message to a moderator for further investigation?": "Vuoi inviare una copia di questo messaggio a un moderatore per ulteriori indagini?", + "Due since {{ dueSince }}": "Scadenza dal {{ dueSince }}", + "Due {{ timeLeft }}": "Scadenza {{ timeLeft }}", "Edit Message": "Modifica Messaggio", "Edited": "Modificato", "Editing Message": "Modificando il Messaggio", @@ -52,6 +54,7 @@ "Loading messages...": "Caricamento messaggi...", "Loading threads...": "Caricamento dei thread...", "Loading...": "Caricamento...", + "Location": "Posizione", "Mark as Unread": "Segna come non letto", "Maximum number of files reached": "Numero massimo di file raggiunto", "Maximum votes per person": "Massimo voti per persona", @@ -125,6 +128,9 @@ "Vote ended": "Votazione terminata", "You": "Tu", "You can't send messages in this channel": "Non puoi inviare messaggi in questo canale", + "duration/Location end at": "{{ milliseconds | durationFormatter(withSuffix: false) }}", + "duration/Message reminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", + "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "replied to": "ha risposto a", "timestamp/ChannelPreviewStatus": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[Ieri]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[Domani]\",\"nextWeek\":\"dddd [alle] LT\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", "timestamp/ImageGalleryHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", @@ -133,6 +139,7 @@ "timestamp/MessageSystem": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(format: LT) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[Ieri]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[Domani]\",\"nextWeek\":\"dddd [alle] LT\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/StickyHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/ThreadListItem": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[Ieri]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[Domani]\",\"nextWeek\":\"dddd [alle] LT\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", "{{ firstUser }} and {{ nonSelfUserLength }} more are typing": "{{ firstUser }} e altri {{ nonSelfUserLength }} stanno scrivendo", diff --git a/package/src/i18n/ja.json b/package/src/i18n/ja.json index f92a1e564f..099a53591d 100644 --- a/package/src/i18n/ja.json +++ b/package/src/i18n/ja.json @@ -1,4 +1,6 @@ { + "+{{count}}_many": "+{{count}}", + "+{{count}}_one": "+{{count}}", "+{{count}}_other": "+{{count}}", "1 Reply": "1件の返信", "1 Thread Reply": "1件のスレッド返信", @@ -24,6 +26,8 @@ "Device camera is used to take photos or videos.": "デバイスのカメラは写真やビデオの撮影に使用されます。", "Device gallery permissions is used to take photos or videos.": "デバイスギャラリーの権限は写真やビデオを撮るために使用されます。", "Do you want to send a copy of this message to a moderator for further investigation?": "このメッセージのコピーをモデレーターに送信して、さらに調査しますか?", + "Due since {{ dueSince }}": "期限は {{ dueSince }} からです", + "Due {{ timeLeft }}": "期限は {{ timeLeft }} です", "Edit Message": "メッセージを編集", "Edited": "編集済み", "Editing Message": "メッセージを編集中", @@ -50,6 +54,7 @@ "Loading messages...": "メッセージを読み込み中。。。", "Loading threads...": "スレッドを読み込み中...", "Loading...": "読み込み中。。。", + "Location": "位置情報", "Mark as Unread": "未読としてマーク", "Maximum number of files reached": "ファイルの最大数に達しました", "Maximum votes per person": "1人あたりの最大投票数", @@ -85,11 +90,13 @@ "Resend": "再送", "SEND": "送信", "Search": "検索", + "See all {{count}} options_many": "すべての{{count}}オプションを表示", "See all {{count}} options_one": "{{count}} 個のオプションをすべて表示", "See all {{count}} options_other": "{{count}} 個のオプションをすべて表示", "Select More Photos": "さらに写真を選択", "Select one": "1つ選択", "Select one or more": "1つ以上選択", + "Select up to {{count}}_many": "{{count}} まで選択", "Select up to {{count}}_one": "{{count}} まで選択", "Select up to {{count}}_other": "{{count}} まで選択", "Send Anyway": "とにかく送信", @@ -114,12 +121,16 @@ "Update your comment": "コメントを更新", "Video": "ビデオ", "View Results": "結果を表示", + "View {{count}} comments_many": "すべての{{count}}コメントを表示", "View {{count}} comments_one": "{{count}} 件のコメントを表示", "View {{count}} comments_other": "{{count}} 件のコメントを表示", "Voice message": "ボイスメッセージ", "Vote ended": "投票終了", "You": "あなた", "You can't send messages in this channel": "このチャンネルではメッセージを送信できません", + "duration/Location end at": "{{ milliseconds | durationFormatter(withSuffix: false) }}", + "duration/Message reminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", + "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "replied to": "に返信しました", "timestamp/ChannelPreviewStatus": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[昨日]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[明日]\",\"nextWeek\":\"dddd [の] LT\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", "timestamp/ImageGalleryHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", @@ -128,6 +139,7 @@ "timestamp/MessageSystem": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(format: LT) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[昨日]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[明日]\",\"nextWeek\":\"dddd [の] LT\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/StickyHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/ThreadListItem": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[昨日]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[明日]\",\"nextWeek\":\"dddd [の] LT\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", "{{ firstUser }} and {{ nonSelfUserLength }} more are typing": "{{ firstUser }}と{{ nonSelfUserLength }}人がタイピングしています", @@ -135,6 +147,7 @@ "{{ replyCount }} Replies": "{{ replyCount }}件の返信", "{{ replyCount }} Thread Replies": "{{ replyCount }}件のスレッド返信", "{{ user }} is typing": "{{ user }}はタイピング中", + "{{count}} votes_many": "{{count}}票", "{{count}} votes_one": "{{count}} 票", "{{count}} votes_other": "{{count}} 票", "🏙 Attachment...": "🏙 アタッチメント..." diff --git a/package/src/i18n/ko.json b/package/src/i18n/ko.json index 19a10d2db7..c297cbd67b 100644 --- a/package/src/i18n/ko.json +++ b/package/src/i18n/ko.json @@ -1,4 +1,6 @@ { + "+{{count}}_many": "+{{count}}", + "+{{count}}_one": "+{{count}}", "+{{count}}_other": "+{{count}}", "1 Reply": "답장 1개", "1 Thread Reply": "1개의 스레드\u3000답글", @@ -24,6 +26,8 @@ "Device camera is used to take photos or videos.": "기기 카메라는 사진이나 동영상을 촬영하는 데 사용됩니다.", "Device gallery permissions is used to take photos or videos.": "장치 갤러리 권한은 사진 또는 비디오를 촬영하는 데 사용됩니다.", "Do you want to send a copy of this message to a moderator for further investigation?": "이 메시지의 복사본을 운영자에게 보내 추가 조사를합니까?", + "Due since {{ dueSince }}": "기한은 {{ dueSince }}부터입니다.", + "Due {{ timeLeft }}": "기한은 {{ timeLeft }}입니다.", "Edit Message": "메시지 수정", "Edited": "편집됨", "Editing Message": "메시지 편집중", @@ -50,6 +54,7 @@ "Loading messages...": "메시지를 로딩 중...", "Loading threads...": "스레드 로딩 중...", "Loading...": "로딩 중...", + "Location": "위치", "Mark as Unread": "읽지 않음으로 표시", "Maximum number of files reached": "최대 파일 수에 도달했습니다", "Maximum votes per person": "사람당 최대 투표 수", @@ -85,11 +90,13 @@ "Resend": "재전송", "SEND": "보내기", "Search": "검색", + "See all {{count}} options_many": "모든 {{count}} 옵션 보기", "See all {{count}} options_one": "모든 {{count}} 옵션 보기", "See all {{count}} options_other": "모든 {{count}} 옵션 보기", "Select More Photos": "추가 사진 선택", "Select one": "하나 선택", "Select one or more": "하나 이상 선택", + "Select up to {{count}}_many": "{{count}} 까지 선택", "Select up to {{count}}_one": "{{count}}까지 선택", "Select up to {{count}}_other": "{{count}}까지 선택", "Send Anyway": "그래도 보내기", @@ -114,12 +121,16 @@ "Update your comment": "댓글 수정", "Video": "동영상", "View Results": "결과 보기", + "View {{count}} comments_many": "모든 {{count}} 댓글 보기", "View {{count}} comments_one": "{{count}}개의 댓글 보기", "View {{count}} comments_other": "{{count}}개의 댓글 보기", "Voice message": "음성 메시지", "Vote ended": "투표 종료됨", "You": "당신", "You can't send messages in this channel": "이 채널에서는 메세지를 전송할 수 없습니다", + "duration/Location end at": "{{ milliseconds | durationFormatter(withSuffix: false) }}", + "duration/Message reminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", + "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "replied to": "에 답장했습니다", "timestamp/ChannelPreviewStatus": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[어제]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[내일]\",\"nextWeek\":\"dddd [LT에]\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", "timestamp/ImageGalleryHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", @@ -128,6 +139,7 @@ "timestamp/MessageSystem": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(format: LT) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[어제]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[내일]\",\"nextWeek\":\"dddd [LT에]\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/StickyHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/ThreadListItem": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[어제]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[내일]\",\"nextWeek\":\"dddd [LT에]\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", "{{ firstUser }} and {{ nonSelfUserLength }} more are typing": "{{ firstUser }} 외 {{ nonSelfUserLength }}명이 입력 중입니다", @@ -135,6 +147,7 @@ "{{ replyCount }} Replies": "{{ replyCount }} 답글", "{{ replyCount }} Thread Replies": "{{ replyCount }}\u3000스레드\u3000답글", "{{ user }} is typing": "{{ user }} 타이핑 중", + "{{count}} votes_many": "{{count}} 표", "{{count}} votes_one": "{{count}} 표", "{{count}} votes_other": "{{count}} 표", "🏙 Attachment...": "🏙 부착..." diff --git a/package/src/i18n/nl.json b/package/src/i18n/nl.json index 7c10de1c72..0c68500e11 100644 --- a/package/src/i18n/nl.json +++ b/package/src/i18n/nl.json @@ -1,4 +1,5 @@ { + "+{{count}}_many": "+{{count}}", "+{{count}}_one": "+{{count}}", "+{{count}}_other": "+{{count}}", "1 Reply": "1 Antwoord", @@ -25,6 +26,8 @@ "Device camera is used to take photos or videos.": "De camera van het apparaat wordt gebruikt om foto's of video's te maken.", "Device gallery permissions is used to take photos or videos.": "Apparaatgallerijmachtigingen worden gebruikt om foto’s of video’s te maken.", "Do you want to send a copy of this message to a moderator for further investigation?": "Wil je een kopie van dit bericht naar een moderator sturen voor verder onderzoek?", + "Due since {{ dueSince }}": "Vervaldatum sinds {{ dueSince }}", + "Due {{ timeLeft }}": "Vervaldatum {{ timeLeft }}", "Edit Message": "Pas bericht aan", "Edited": "Bewerkt", "Editing Message": "Bericht aanpassen", @@ -51,6 +54,7 @@ "Loading messages...": "Berichten aan het laden...", "Loading threads...": "Threads laden...", "Loading...": "Aan het laden...", + "Location": "Locatie", "Mark as Unread": "Markeer als ongelezen", "Maximum number of files reached": "Maximaal aantal bestanden bereikt", "Maximum votes per person": "Maximaal aantal stemmen per persoon", @@ -86,11 +90,13 @@ "Resend": "Opnieuw versturen", "SEND": "VERZENDEN", "Search": "Zoeken", + "See all {{count}} options_many": "Bekijk alle {{count}} opties", "See all {{count}} options_one": "Bekijk alle {{count}} opties", "See all {{count}} options_other": "Bekijk alle {{count}} opties", "Select More Photos": "Selecteer Meer foto's", "Select one": "Kies één", "Select one or more": "Kies één of meer", + "Select up to {{count}}_many": "Selecteer tot {{count}}", "Select up to {{count}}_one": "Kies tot {{count}}", "Select up to {{count}}_other": "Kies tot {{count}}", "Send Anyway": "Toch verzenden", @@ -115,12 +121,16 @@ "Update your comment": "Werk je reactie bij", "Video": "Video", "View Results": "Bekijk resultaten", + "View {{count}} comments_many": "Bekijk {{count}} reacties", "View {{count}} comments_one": "Bekijk {{count}} reactie", "View {{count}} comments_other": "Bekijk {{count}} reacties", "Voice message": "Spraakbericht", "Vote ended": "Stemmen beëindigd", "You": "U", "You can't send messages in this channel": "Je kan geen berichten sturen in dit kanaal", + "duration/Location end at": "{{ milliseconds | durationFormatter(withSuffix: false) }}", + "duration/Message reminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", + "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "replied to": "reageerde op", "timestamp/ChannelPreviewStatus": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[Gisteren]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[Morgen]\",\"nextWeek\":\"dddd [om] LT\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", "timestamp/ImageGalleryHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", @@ -129,6 +139,7 @@ "timestamp/MessageSystem": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(format: LT) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[Gisteren]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[Morgen]\",\"nextWeek\":\"dddd [om] LT\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/StickyHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/ThreadListItem": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[Gisteren]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[Morgen]\",\"nextWeek\":\"dddd [om] LT\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", "{{ firstUser }} and {{ nonSelfUserLength }} more are typing": "{{ firstUser }} en {{ nonSelfUserLength }} anderen zijn aan het typen", @@ -136,6 +147,7 @@ "{{ replyCount }} Replies": "{{ replyCount }} Antwoorden", "{{ replyCount }} Thread Replies": "{{replyCount}} Discussiereacties", "{{ user }} is typing": "{{ user }} is aan het typen", + "{{count}} votes_many": "{{count}} stemmen", "{{count}} votes_one": "{{count}} stem", "{{count}} votes_other": "{{count}} stemmen", "🏙 Attachment...": "🏙 Bijlage..." diff --git a/package/src/i18n/pt-br.json b/package/src/i18n/pt-br.json index 600a5733a2..d8e975f378 100644 --- a/package/src/i18n/pt-br.json +++ b/package/src/i18n/pt-br.json @@ -26,6 +26,8 @@ "Device camera is used to take photos or videos.": "A câmera do dispositivo é usada para tirar fotos ou vídeos.", "Device gallery permissions is used to take photos or videos.": "As permissões da galeria do dispositivo são usadas para tirar fotos ou vídeos.", "Do you want to send a copy of this message to a moderator for further investigation?": "Deseja enviar uma cópia desta mensagem para um moderador para investigação adicional?", + "Due since {{ dueSince }}": "Vencido desde {{ dueSince }}", + "Due {{ timeLeft }}": "Vencido {{ timeLeft }}", "Edit Message": "Editar Mensagem", "Edited": "Editado", "Editing Message": "Editando Mensagem", @@ -52,6 +54,7 @@ "Loading messages...": "Carregando mensagens...", "Loading threads...": "Carregando tópicos...", "Loading...": "Carregando...", + "Location": "Localização", "Mark as Unread": "Marcar como não lido", "Maximum number of files reached": "Número máximo de arquivos atingido", "Maximum votes per person": "Máximo de votos por pessoa", @@ -125,6 +128,9 @@ "Vote ended": "Votação encerrada", "You": "Você", "You can't send messages in this channel": "Você não pode enviar mensagens neste canal", + "duration/Location end at": "{{ milliseconds | durationFormatter(withSuffix: false) }}", + "duration/Message reminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", + "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "replied to": "respondeu a", "timestamp/ChannelPreviewStatus": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[Ontem]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[Amanhã]\",\"nextWeek\":\"dddd [às] LT\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", "timestamp/ImageGalleryHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", @@ -133,6 +139,7 @@ "timestamp/MessageSystem": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(format: LT) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[Ontem]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[Amanhã]\",\"nextWeek\":\"dddd [às] LT\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/StickyHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/ThreadListItem": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[Ontem]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[Amanhã]\",\"nextWeek\":\"dddd [às] LT\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", "{{ firstUser }} and {{ nonSelfUserLength }} more are typing": "{{ firstUser }} e mais {{ nonSelfUserLength }} pessoa(s) estão digitando", diff --git a/package/src/i18n/ru.json b/package/src/i18n/ru.json index 621fcb7d41..ea29cf9806 100644 --- a/package/src/i18n/ru.json +++ b/package/src/i18n/ru.json @@ -27,6 +27,8 @@ "Device camera is used to take photos or videos.": "Камера устройства используется для съемки фотографий или видео.", "Device gallery permissions is used to take photos or videos.": "Разрешения галереи устройства используются для съемки фото или видео.", "Do you want to send a copy of this message to a moderator for further investigation?": "Вы хотите отправить копию этого сообщения модератору для дальнейшего изучения?", + "Due since {{ dueSince }}": "Срок с {{ dueSince }}", + "Due {{ timeLeft }}": "Срок {{ timeLeft }}", "Edit Message": "Редактировать сообщение", "Edited": "Отредактировано", "Editing Message": "Редактирование сообщения", @@ -53,6 +55,7 @@ "Loading messages...": "Загружаю сообщения...", "Loading threads...": "Загрузка потоков...", "Loading...": "Загружаю...", + "Location": "Местоположение", "Mark as Unread": "Отметить как непрочитанное", "Maximum number of files reached": "Достигнуто максимальное количество файлов", "Maximum votes per person": "Максимальное количество голосов на человека", @@ -129,6 +132,9 @@ "Vote ended": "Голосование завершено", "You": "Вы", "You can't send messages in this channel": "Вы не можете отправлять сообщения в этот канал", + "duration/Location end at": "{{ milliseconds | durationFormatter(withSuffix: false) }}", + "duration/Message reminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", + "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "replied to": "ответил на", "timestamp/ChannelPreviewStatus": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[Вчера]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[Завтра]\",\"nextWeek\":\"dddd [в] LT\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", "timestamp/ImageGalleryHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", @@ -137,6 +143,7 @@ "timestamp/MessageSystem": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(format: LT) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[Вчера]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[Завтра]\",\"nextWeek\":\"dddd [в] LT\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/StickyHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/ThreadListItem": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[Вчера]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[Завтра]\",\"nextWeek\":\"dddd [в] LT\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", "{{ firstUser }} and {{ nonSelfUserLength }} more are typing": "{{ firstUser }} и еще {{ nonSelfUserLength }} пишут", diff --git a/package/src/i18n/tr.json b/package/src/i18n/tr.json index 860ee05199..3305b84b6a 100644 --- a/package/src/i18n/tr.json +++ b/package/src/i18n/tr.json @@ -1,4 +1,5 @@ { + "+{{count}}_many": "+{{count}}", "+{{count}}_one": "+{{count}}", "+{{count}}_other": "+{{count}}", "1 Reply": "1 Cevap", @@ -25,6 +26,8 @@ "Device camera is used to take photos or videos.": "Cihaz kamerası fotoğraf veya video çekmek için kullanılır.", "Device gallery permissions is used to take photos or videos.": "Cihaz galerisi izinleri fotoğraf veya video çekmek için kullanılır.", "Do you want to send a copy of this message to a moderator for further investigation?": "Detaylı inceleme için bu mesajın kopyasını moderatöre göndermek istiyor musunuz?", + "Due since {{ dueSince }}": "Son tarihi {{ dueSince }} itibarıyla geçmiştir.", + "Due {{ timeLeft }}": "Son tarihi {{ timeLeft }}'dir.", "Edit Message": "Mesajı Düzenle", "Edited": "Düzenlendi", "Editing Message": "Mesaj Düzenleniyor", @@ -51,6 +54,7 @@ "Loading messages...": "Mesajlar yükleniyor...", "Loading threads...": "Akışlar yükleniyor...", "Loading...": "Yükleniyor...", + "Location": "Konum", "Mark as Unread": "Okunmamış olarak işaretle", "Maximum number of files reached": "Maksimum dosya sayısına ulaşıldı", "Maximum votes per person": "Kişi başına maksimum oy", @@ -86,11 +90,13 @@ "Resend": "Yeniden gönder", "SEND": "GÖNDER", "Search": "Ara", + "See all {{count}} options_many": "Tüm {{count}} seçeneklerini gör", "See all {{count}} options_one": "Tüm {{count}} seçeneği gör", "See all {{count}} options_other": "Tüm {{count}} seçeneği gör", "Select More Photos": "Daha Fazla Fotoğraf Seçin", "Select one": "Birini seç", "Select one or more": "Bir veya daha fazlasını seç", + "Select up to {{count}}_many": "Seçenekleri {{count}} kadar seç", "Select up to {{count}}_one": "{{count}} kadar seç", "Select up to {{count}}_other": "{{count}} kadar seç", "Send Anyway": "Yine de Gönder", @@ -115,12 +121,16 @@ "Update your comment": "Yorumunu güncelle", "Video": "Video", "View Results": "Sonuçları görüntüle", + "View {{count}} comments_many": "Tüm {{count}} yorumları görüntüle", "View {{count}} comments_one": "{{count}} yorumu görüntüle", "View {{count}} comments_other": "{{count}} yorumu görüntüle", "Voice message": "Sesli mesaj", "Vote ended": "Oylama sona erdi", "You": "Sen", "You can't send messages in this channel": "Bu konuşmaya mesaj gönderemezsiniz", + "duration/Location end at": "{{ milliseconds | durationFormatter(withSuffix: false) }}", + "duration/Message reminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", + "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "replied to": "yanıtladı", "timestamp/ChannelPreviewStatus": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[Dün]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[Yarın]\",\"nextWeek\":\"dddd [saat] LT\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", "timestamp/ImageGalleryHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", @@ -129,6 +139,7 @@ "timestamp/MessageSystem": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(format: LT) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[Dün]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[Yarın]\",\"nextWeek\":\"dddd [saat] LT\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/StickyHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/ThreadListItem": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[Dün]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[Yarın]\",\"nextWeek\":\"dddd [saat] LT\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", "{{ firstUser }} and {{ nonSelfUserLength }} more are typing": "{{ firstUser }} ve {{ nonSelfUserLength }} kişi daha yazıyor", @@ -136,6 +147,7 @@ "{{ replyCount }} Replies": "{{ replyCount }} Cevap", "{{ replyCount }} Thread Replies": "{{responseCount}} Konu Cevapı", "{{ user }} is typing": "{{ user }} yazıyor", + "{{count}} votes_many": "{{count}} oy", "{{count}} votes_one": "{{count}} oy", "{{count}} votes_other": "{{count}} oy", "🏙 Attachment...": "🏙 Ek..." diff --git a/package/src/store/SqliteClient.ts b/package/src/store/SqliteClient.ts index 12951105c8..74380960c6 100644 --- a/package/src/store/SqliteClient.ts +++ b/package/src/store/SqliteClient.ts @@ -28,7 +28,7 @@ import type { PreparedBatchQueries, PreparedQueries, Scalar, Table } from './typ * This way usage @op-engineering/op-sqlite package is scoped to a single class/file. */ export class SqliteClient { - static dbVersion = 12; + static dbVersion = 13; static dbName = DB_NAME; static dbLocation = DB_LOCATION; diff --git a/package/src/store/apis/deleteMessage.ts b/package/src/store/apis/deleteMessage.ts index df209c373a..ac8264cd74 100644 --- a/package/src/store/apis/deleteMessage.ts +++ b/package/src/store/apis/deleteMessage.ts @@ -4,12 +4,6 @@ import { SqliteClient } from '../SqliteClient'; export const deleteMessage = async ({ execute = true, id }: { id: string; execute?: boolean }) => { const queries = []; - queries.push( - createDeleteQuery('reactions', { - messageId: id, - }), - ); - queries.push( createDeleteQuery('messages', { id, diff --git a/package/src/store/apis/getChannelActiveLocations.ts b/package/src/store/apis/getChannelActiveLocations.ts new file mode 100644 index 0000000000..c9601fd270 --- /dev/null +++ b/package/src/store/apis/getChannelActiveLocations.ts @@ -0,0 +1,25 @@ +import { SharedLocationResponse } from 'stream-chat'; + +import { selectActiveLocationsForChannels } from './queries/selectActiveLocationsForChannels'; + +import { mapStorableToSharedLocation } from '../mappers/mapStorableToSharedLocation'; + +export const getChannelActiveLocations = async ({ + channelIds, +}: { + channelIds: string[]; +}): Promise> => { + const cidVsLiveLocations: Record = {}; + + // Query to select active live locations for the given channel ids where the end_at is not empty and it is greater than the current date. + const locations = await selectActiveLocationsForChannels(channelIds); + + locations.forEach((location) => { + if (!cidVsLiveLocations[location.channelCid]) { + cidVsLiveLocations[location.channelCid] = []; + } + cidVsLiveLocations[location.channelCid].push(mapStorableToSharedLocation(location)); + }); + + return cidVsLiveLocations; +}; diff --git a/package/src/store/apis/getChannelMessages.ts b/package/src/store/apis/getChannelMessages.ts index 567829135b..932bb706da 100644 --- a/package/src/store/apis/getChannelMessages.ts +++ b/package/src/store/apis/getChannelMessages.ts @@ -62,6 +62,19 @@ export const getChannelMessages = async ({ messageIdsVsReminders[reminder.messageId] = reminder; }); + const messagesWithSharedLocations = messageRows.filter((message) => !!message.shared_location); + const messageIdsVsLocations: Record> = {}; + const sharedLocationRows = (await SqliteClient.executeSql.apply( + null, + createSelectQuery('locations', ['*'], { + messageId: messagesWithSharedLocations.map((message) => message.id), + }), + )) as unknown as TableRow<'locations'>[]; + + sharedLocationRows.forEach((location) => { + messageIdsVsLocations[location.messageId] = location; + }); + // Populate the messages. const cidVsMessages: Record = {}; messageRows.forEach((m) => { diff --git a/package/src/store/apis/getChannels.ts b/package/src/store/apis/getChannels.ts index 5258584ec0..094981c2aa 100644 --- a/package/src/store/apis/getChannels.ts +++ b/package/src/store/apis/getChannels.ts @@ -1,5 +1,6 @@ import type { ChannelAPIResponse } from 'stream-chat'; +import { getChannelActiveLocations } from './getChannelActiveLocations'; import { getChannelMessages } from './getChannelMessages'; import { getDraftForChannels } from './getDraftsForChannels'; import { getMembers } from './getMembers'; @@ -27,20 +28,23 @@ export const getChannels = async ({ }): Promise[]> => { SqliteClient.logger?.('info', 'getChannels', { channelIds, currentUserId }); - const [channels, cidVsDraft, cidVsMembers, cidVsReads, cidVsMessages] = await Promise.all([ - selectChannels({ channelIds }), - getDraftForChannels({ channelIds, currentUserId }), - getMembers({ channelIds }), - getReads({ channelIds }), - getChannelMessages({ - channelIds, - currentUserId, - }), - ]); + const [channels, cidVsDraft, cidVsMembers, cidVsReads, cidVsMessages, cidVsActiveLocations] = + await Promise.all([ + selectChannels({ channelIds }), + getDraftForChannels({ channelIds, currentUserId }), + getMembers({ channelIds }), + getReads({ channelIds }), + getChannelMessages({ + channelIds, + currentUserId, + }), + getChannelActiveLocations({ channelIds }), + ]); // Enrich the channels with state return channels.map((c) => ({ ...mapStorableToChannel(c), + active_live_locations: cidVsActiveLocations[c.cid] || [], draft: cidVsDraft[c.cid], members: cidVsMembers[c.cid] || [], membership: (cidVsMembers[c.cid] || []).find((member) => member.user_id === currentUserId), diff --git a/package/src/store/apis/queries/selectActiveLocationsForChannels.ts b/package/src/store/apis/queries/selectActiveLocationsForChannels.ts new file mode 100644 index 0000000000..ac71ffa17e --- /dev/null +++ b/package/src/store/apis/queries/selectActiveLocationsForChannels.ts @@ -0,0 +1,18 @@ +import { TableRow } from '../../../store/types'; +import { SqliteClient } from '../../SqliteClient'; + +export const selectActiveLocationsForChannels = async ( + cids: string[], +): Promise[]> => { + const questionMarks = Array(cids.length).fill('?').join(','); + SqliteClient.logger?.('info', 'selectActiveLocationsForChannels', { + cids, + }); + // Query to select active live locations for the given channel ids where the end_at is not empty and it is greater than the current date. + const locations = await SqliteClient.executeSql( + `SELECT * FROM locations WHERE channelCid IN (${questionMarks}) AND endAt IS NOT NULL AND endAt > ?`, + [...cids, new Date().toISOString()], + ); + + return locations as unknown as TableRow<'locations'>[]; +}; diff --git a/package/src/store/apis/upsertChannels.ts b/package/src/store/apis/upsertChannels.ts index 8bfc83896b..105c939382 100644 --- a/package/src/store/apis/upsertChannels.ts +++ b/package/src/store/apis/upsertChannels.ts @@ -1,6 +1,7 @@ import type { ChannelAPIResponse, ChannelMemberResponse } from 'stream-chat'; import { upsertDraft } from './upsertDraft'; +import { upsertLocation } from './upsertLocation'; import { upsertMembers } from './upsertMembers'; import { upsertMessages } from './upsertMessages'; @@ -32,7 +33,7 @@ export const upsertChannels = async ({ for (const channel of channels) { queries.push(createUpsertQuery('channels', mapChannelDataToStorable(channel.channel))); - const { draft, members, membership, messages, read } = channel; + const { active_live_locations, draft, members, membership, messages, read } = channel; if ( membership && !members.includes((m: ChannelMemberResponse) => m.user?.id === membership.user?.id) @@ -40,6 +41,17 @@ export const upsertChannels = async ({ members.push({ ...membership, user_id: membership.user?.id }); } + if (active_live_locations && active_live_locations.length > 0) { + active_live_locations.forEach(async (location) => { + queries = queries.concat( + await upsertLocation({ + execute: false, + location, + }), + ); + }); + } + if (draft) { queries = queries.concat(await upsertDraft({ draft, execute: false })); } diff --git a/package/src/store/apis/upsertLocation.ts b/package/src/store/apis/upsertLocation.ts new file mode 100644 index 0000000000..7bd1512588 --- /dev/null +++ b/package/src/store/apis/upsertLocation.ts @@ -0,0 +1,30 @@ +import type { SharedLocationResponse } from 'stream-chat'; + +import { mapSharedLocationToStorable } from '../mappers/mapSharedLocationToStorable'; +import { createUpsertQuery } from '../sqlite-utils/createUpsertQuery'; +import { SqliteClient } from '../SqliteClient'; +import type { PreparedQueries } from '../types'; + +export const upsertLocation = async ({ + execute = true, + location, +}: { + location: SharedLocationResponse; + execute?: boolean; +}) => { + const queries: PreparedQueries[] = []; + + queries.push(createUpsertQuery('locations', mapSharedLocationToStorable(location))); + + SqliteClient.logger?.('info', 'upsertLocation', { + cid: location.channel_cid, + execute, + location, + }); + + if (execute) { + await SqliteClient.executeSqlBatch(queries); + } + + return queries; +}; diff --git a/package/src/store/apis/upsertMessages.ts b/package/src/store/apis/upsertMessages.ts index d0d733dad3..419bb53216 100644 --- a/package/src/store/apis/upsertMessages.ts +++ b/package/src/store/apis/upsertMessages.ts @@ -4,6 +4,7 @@ import { mapMessageToStorable } from '../mappers/mapMessageToStorable'; import { mapPollToStorable } from '../mappers/mapPollToStorable'; import { mapReactionToStorable } from '../mappers/mapReactionToStorable'; import { mapReminderToStorable } from '../mappers/mapReminderToStorable'; +import { mapSharedLocationToStorable } from '../mappers/mapSharedLocationToStorable'; import { mapUserToStorable } from '../mappers/mapUserToStorable'; import { createUpsertQuery } from '../sqlite-utils/createUpsertQuery'; import { SqliteClient } from '../SqliteClient'; @@ -20,6 +21,7 @@ export const upsertMessages = async ({ const storableReactions: Array> = []; const storablePolls: Array> = []; const storableReminders: Array> = []; + const storableLocations: Array> = []; messages?.forEach((message: MessageResponse | LocalMessage) => { storableMessages.push(mapMessageToStorable(message)); @@ -38,6 +40,9 @@ export const upsertMessages = async ({ if (message.reminder) { storableReminders.push(mapReminderToStorable(message.reminder)); } + if (message.shared_location) { + storableLocations.push(mapSharedLocationToStorable(message.shared_location)); + } }); const finalQueries = [ @@ -50,10 +55,14 @@ export const upsertMessages = async ({ ...storableReminders.map((storableReminder) => createUpsertQuery('reminders', storableReminder), ), + ...storableLocations.map((storableLocation) => + createUpsertQuery('locations', storableLocation), + ), ]; SqliteClient.logger?.('info', 'upsertMessages', { execute, + locations: storableLocations, messages: storableMessages, polls: storablePolls, reactions: storableReactions, diff --git a/package/src/store/mappers/mapMessageToStorable.ts b/package/src/store/mappers/mapMessageToStorable.ts index 017a537aa4..f307355af2 100644 --- a/package/src/store/mappers/mapMessageToStorable.ts +++ b/package/src/store/mappers/mapMessageToStorable.ts @@ -24,6 +24,7 @@ export const mapMessageToStorable = ( // eslint-disable-next-line @typescript-eslint/no-unused-vars reminder, reaction_groups, + shared_location, text, type, updated_at, @@ -41,6 +42,7 @@ export const mapMessageToStorable = ( messageTextUpdatedAt: mapDateTimeToStorable(message_text_updated_at), poll_id: poll_id || '', reactionGroups: JSON.stringify(reaction_groups), + shared_location: JSON.stringify(shared_location), text, type, updatedAt: mapDateTimeToStorable(updated_at), diff --git a/package/src/store/mappers/mapSharedLocationToStorable.ts b/package/src/store/mappers/mapSharedLocationToStorable.ts new file mode 100644 index 0000000000..9b8a4b2f98 --- /dev/null +++ b/package/src/store/mappers/mapSharedLocationToStorable.ts @@ -0,0 +1,33 @@ +import type { SharedLocationResponse } from 'stream-chat'; + +import { mapDateTimeToStorable } from './mapDateTimeToStorable'; + +import type { TableRow } from '../types'; + +export const mapSharedLocationToStorable = ( + location: SharedLocationResponse, +): TableRow<'locations'> => { + const { + channel_cid, + created_at, + created_by_device_id, + end_at, + latitude, + longitude, + message_id, + updated_at, + user_id, + } = location; + + return { + channelCid: channel_cid, + createdAt: mapDateTimeToStorable(created_at), + createdByDeviceId: created_by_device_id, + endAt: mapDateTimeToStorable(end_at), + latitude, + longitude, + messageId: message_id, + updatedAt: mapDateTimeToStorable(updated_at), + userId: user_id, + }; +}; diff --git a/package/src/store/mappers/mapStorableToMessage.ts b/package/src/store/mappers/mapStorableToMessage.ts index 80c917c89c..30ca649ee5 100644 --- a/package/src/store/mappers/mapStorableToMessage.ts +++ b/package/src/store/mappers/mapStorableToMessage.ts @@ -28,6 +28,7 @@ export const mapStorableToMessage = ({ messageTextUpdatedAt, poll_id, reactionGroups, + shared_location, updatedAt, user, ...rest @@ -46,6 +47,7 @@ export const mapStorableToMessage = ({ own_reactions: ownReactions, poll_id, reaction_groups: reactionGroups ? JSON.parse(reactionGroups) : {}, + shared_location: shared_location ? JSON.parse(shared_location) : null, updated_at: updatedAt, user: mapStorableToUser(user), ...(pollRow ? { poll: mapStorableToPoll(pollRow) } : {}), diff --git a/package/src/store/mappers/mapStorableToSharedLocation.ts b/package/src/store/mappers/mapStorableToSharedLocation.ts new file mode 100644 index 0000000000..8424e246c4 --- /dev/null +++ b/package/src/store/mappers/mapStorableToSharedLocation.ts @@ -0,0 +1,29 @@ +import { SharedLocationResponse } from 'stream-chat'; + +import type { TableRow } from '../types'; + +export const mapStorableToSharedLocation = (row: TableRow<'locations'>): SharedLocationResponse => { + const { + channelCid, + createdAt, + createdByDeviceId, + endAt, + latitude, + longitude, + messageId, + updatedAt, + userId, + } = row; + + return { + channel_cid: channelCid, + created_at: createdAt, + created_by_device_id: createdByDeviceId, + end_at: endAt, + latitude, + longitude, + message_id: messageId, + updated_at: updatedAt, + user_id: userId, + }; +}; diff --git a/package/src/store/schema.ts b/package/src/store/schema.ts index 340c3eee77..50ec7ee2aa 100644 --- a/package/src/store/schema.ts +++ b/package/src/store/schema.ts @@ -101,6 +101,35 @@ export const tables: Tables = { }, primaryKey: ['id'], }, + locations: { + columns: { + channelCid: 'TEXT NOT NULL', + createdAt: 'TEXT', + createdByDeviceId: 'TEXT', + endAt: 'TEXT', + latitude: 'REAL NOT NULL', + longitude: 'REAL NOT NULL', + messageId: 'TEXT NOT NULL', + updatedAt: 'TEXT', + userId: 'TEXT NOT NULL', + }, + foreignKeys: [ + { + column: 'messageId', + onDeleteAction: 'CASCADE', + referenceTable: 'messages', + referenceTableColumn: 'id', + }, + ], + indexes: [ + { + columns: ['channelCid', 'messageId'], + name: 'index_locations', + unique: false, + }, + ], + primaryKey: ['channelCid', 'messageId'], + }, members: { columns: { archivedAt: 'TEXT', @@ -146,6 +175,7 @@ export const tables: Tables = { messageTextUpdatedAt: 'TEXT', poll_id: 'TEXT', reactionGroups: 'TEXT', + shared_location: 'TEXT', text: "TEXT DEFAULT ''", type: 'TEXT', updatedAt: 'TEXT', @@ -258,6 +288,14 @@ export const tables: Tables = { updatedAt: 'TEXT', userId: 'TEXT NOT NULL', }, + foreignKeys: [ + { + column: 'messageId', + onDeleteAction: 'CASCADE', + referenceTable: 'messages', + referenceTableColumn: 'id', + }, + ], indexes: [ { columns: ['messageId'], @@ -375,6 +413,7 @@ export type Schema = { messageTextUpdatedAt: string; poll_id: string; reactionGroups: string; + shared_location: string; type: MessageLabel; updatedAt: string; text?: string; @@ -436,6 +475,17 @@ export type Schema = { userId: string; remindAt?: string; }; + locations: { + channelCid: string; + createdAt: string; + createdByDeviceId: string; + endAt?: string; + latitude: number; + longitude: number; + messageId: string; + updatedAt: string; + userId: string; + }; users: { id: string; banned?: boolean; diff --git a/package/src/utils/__tests__/Streami18n.test.js b/package/src/utils/__tests__/Streami18n.test.js index 977916a8e8..4ea41a955c 100644 --- a/package/src/utils/__tests__/Streami18n.test.js +++ b/package/src/utils/__tests__/Streami18n.test.js @@ -50,7 +50,7 @@ describe('Streami18n instance - with built-in language', () => { it('should provide dutch translator', async () => { const { t: _t } = await streami18n.getTranslators(); for (const key in nlTranslations) { - if (key.indexOf('{{') > -1 && key.indexOf('}}') > -1) { + if ((key.indexOf('{{') > -1 && key.indexOf('}}') > -1) || key.indexOf('duration/') > -1) { continue; } @@ -74,7 +74,7 @@ describe('Streami18n instance - with built-in language', () => { it('should provide dutch translator', async () => { const { t: _t } = await streami18n.getTranslators(); for (const key in nlTranslations) { - if (key.indexOf('{{') > -1 && key.indexOf('}}') > -1) { + if ((key.indexOf('{{') > -1 && key.indexOf('}}') > -1) || key.indexOf('duration/') > -1) { continue; } @@ -188,7 +188,8 @@ describe('setLanguage - switch to french', () => { const { t: _t } = await streami18n.getTranslators(); for (const key in frTranslations) { - if (key.indexOf('{{') > -1 && key.indexOf('}}') > -1) { + // Skip keys with template strings or duration keys + if ((key.indexOf('{{') > -1 && key.indexOf('}}') > -1) || key.indexOf('duration/') > -1) { continue; } diff --git a/package/yarn.lock b/package/yarn.lock index a095fbfbc3..1c03d720f7 100644 --- a/package/yarn.lock +++ b/package/yarn.lock @@ -8314,10 +8314,10 @@ statuses@~1.5.0: resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== -stream-chat@^9.11.0: - version "9.11.0" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.11.0.tgz#412b5f2f55cc3b76e862d88cb3e95b9720493575" - integrity sha512-mnf86LMBPmCZUrmrU6bdaOUoxRqpXUvw7prl4b5q6WPNWZ+p/41UN8vllBAaZvAS7jPj9JYWvbhUe40VZ6o8/g== +stream-chat@^9.12.0: + version "9.12.0" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.12.0.tgz#d27b1319844d100ca419c61463f5c65f53c87952" + integrity sha512-/A4y8jBmWdP53RUY9f8dlc8dRjC/irR5KUMiOhn0IiAEmK2fKuD/7IpUzXi7cNmR8QMxlHdDMJZdB2wMDkiskQ== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14"