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"