diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 552191d1316..c224a818747 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -19,6 +19,14 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/java/chat/rocket/reactnative/LocationService.kt b/android/app/src/main/java/chat/rocket/reactnative/LocationService.kt
new file mode 100644
index 00000000000..1e029286b12
--- /dev/null
+++ b/android/app/src/main/java/chat/rocket/reactnative/LocationService.kt
@@ -0,0 +1,21 @@
+package chat.rocket.reactnative
+
+import android.app.Service
+import android.content.Intent
+import android.os.IBinder
+
+/**
+ * Minimal stub for a location foreground service.
+ * We don't start it in the current app flow; this exists to satisfy
+ * Android's requirement that FOREGROUND_SERVICE_LOCATION has a matching
+ * service declaration. If started accidentally, stop immediately.
+ */
+class LocationService : Service() {
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ // Not used in current implementation. Ensure we stop if invoked.
+ stopSelf()
+ return START_NOT_STICKY
+ }
+}
diff --git a/android/java.util.concurrent.ExecutionException: b/android/java.util.concurrent.ExecutionException:
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/app.json b/app.json
index 5d8e3008504..1973d6dd6a5 100644
--- a/app.json
+++ b/app.json
@@ -1,3 +1,4 @@
{
- "name": "RocketChatRN"
+ "name": "RocketChatRN",
+ "plugins": ["expo-web-browser"]
}
diff --git a/app/containers/MessageComposer/components/Buttons/ActionsButton.tsx b/app/containers/MessageComposer/components/Buttons/ActionsButton.tsx
index 6d122552386..f504cae6e7a 100644
--- a/app/containers/MessageComposer/components/Buttons/ActionsButton.tsx
+++ b/app/containers/MessageComposer/components/Buttons/ActionsButton.tsx
@@ -1,4 +1,6 @@
import React, { useContext } from 'react';
+import { Platform, PermissionsAndroid, InteractionManager, Alert } from 'react-native';
+import * as Location from 'expo-location';
import { getSubscriptionByRoomId } from '../../../../lib/database/services/Subscription';
import { BaseButton } from './BaseButton';
@@ -10,6 +12,12 @@ import { useAppSelector } from '../../../../lib/hooks/useAppSelector';
import { usePermissions } from '../../../../lib/hooks/usePermissions';
import { useCanUploadFile, useChooseMedia } from '../../hooks';
import { useRoomContext } from '../../../../views/RoomView/context';
+import { showErrorAlert } from '../../../../lib/methods/helpers';
+import { getCurrentPositionOnce } from '../../../../views/LocationShare/services/staticLocation';
+import type { MapProviderName } from '../../../../views/LocationShare/services/mapProviders';
+import { isLiveLocationActive, reopenLiveLocationModal } from '../../../../views/LocationShare/LiveLocationPreviewModal';
+import { useUserPreferences } from '../../../../lib/methods/userPreferences';
+import { MAP_PROVIDER_PREFERENCE_KEY, MAP_PROVIDER_DEFAULT } from '../../../../lib/constants/keys';
export const ActionsButton = () => {
'use memo';
@@ -25,6 +33,26 @@ export const ActionsButton = () => {
});
const { showActionSheet, hideActionSheet } = useActionSheet();
const isMasterDetail = useAppSelector(state => state.app.isMasterDetail);
+ const userId = useAppSelector(state => state.login.user.id);
+
+ const [mapProvider] = useUserPreferences(`${MAP_PROVIDER_PREFERENCE_KEY}_${userId}`, MAP_PROVIDER_DEFAULT);
+
+ const sheetBusyRef = React.useRef(false);
+ const openSheetSafely = (fn: () => void, delayMs = 350) => {
+ if (sheetBusyRef.current) return;
+ sheetBusyRef.current = true;
+
+ hideActionSheet();
+ InteractionManager.runAfterInteractions(() => {
+ setTimeout(() => {
+ try {
+ fn();
+ } finally {
+ sheetBusyRef.current = false;
+ }
+ }, delayMs);
+ });
+ };
const createDiscussion = async () => {
if (!rid) return;
@@ -37,15 +65,136 @@ export const ActionsButton = () => {
}
};
+ const openCurrentPreview = async (provider: MapProviderName) => {
+ try {
+ if (!rid) {
+ showErrorAlert(I18n.t('Room_not_available'), I18n.t('Oops'));
+ return;
+ }
+
+ if (Platform.OS === 'android') {
+ const granted = await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION);
+ if (granted !== PermissionsAndroid.RESULTS.GRANTED) {
+ showErrorAlert(I18n.t('Location_permission_required'), I18n.t('Oops'));
+ return;
+ }
+ } else {
+ const { status } = await Location.requestForegroundPermissionsAsync();
+ if (status !== 'granted') {
+ showErrorAlert(I18n.t('Location_permission_required'), I18n.t('Oops'));
+ return;
+ }
+ }
+
+ const coords = await getCurrentPositionOnce();
+
+ const params = {
+ rid,
+ tmid,
+ provider,
+ coords
+ };
+
+ InteractionManager.runAfterInteractions(() => {
+ if (isMasterDetail) {
+ Navigation.navigate('ModalStackNavigator', { screen: 'LocationPreviewModal', params });
+ } else {
+ Navigation.navigate('LocationPreviewModal', params);
+ }
+ });
+ } catch (e) {
+ const error = e as Error;
+ showErrorAlert(error?.message || I18n.t('Could_not_get_location'), I18n.t('Oops'));
+ }
+ };
+
+ const openLivePreview = async (provider: MapProviderName) => {
+ try {
+ if (isLiveLocationActive()) {
+ Alert.alert(I18n.t('Live_Location_Active'), I18n.t('Live_Location_Active_Block_Message'), [
+ { text: I18n.t('View_Current_Session'), onPress: () => reopenLiveLocationModal() },
+ { text: I18n.t('Cancel'), style: 'cancel' }
+ ]);
+ return;
+ }
+ if (!rid) {
+ showErrorAlert(I18n.t('Room_not_available'), I18n.t('Oops'));
+ return;
+ }
+
+ if (Platform.OS === 'android') {
+ const res = await PermissionsAndroid.requestMultiple([
+ PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
+ PermissionsAndroid.PERMISSIONS.ACCESS_COARSE_LOCATION
+ ]);
+ const fine = res[PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION];
+ const coarse = res[PermissionsAndroid.PERMISSIONS.ACCESS_COARSE_LOCATION];
+ if (fine !== PermissionsAndroid.RESULTS.GRANTED && coarse !== PermissionsAndroid.RESULTS.GRANTED) {
+ throw new Error(I18n.t('Permission_denied'));
+ }
+ } else {
+ const { status } = await Location.requestForegroundPermissionsAsync();
+ if (status !== 'granted') {
+ throw new Error(I18n.t('Location_permission_required'));
+ }
+ }
+
+ const params = {
+ rid,
+ tmid,
+ provider
+ };
+
+ InteractionManager.runAfterInteractions(() => {
+ // @ts-ignore
+ if (isMasterDetail) {
+ Navigation.navigate('ModalStackNavigator', { screen: 'LiveLocationPreviewModal', params });
+ } else {
+ Navigation.navigate('LiveLocationPreviewModal', params);
+ }
+ });
+ } catch (e) {
+ const error = e as Error;
+ showErrorAlert(error?.message || I18n.t('Could_not_get_location'), I18n.t('Oops'));
+ }
+ };
+
+ const openModeSheetForProvider = (provider: MapProviderName) => {
+ const modeOptions: TActionSheetOptionsItem[] = [
+ {
+ title: I18n.t('Share_current_location'),
+ icon: 'pin-map',
+ onPress: () => {
+ openSheetSafely(() => openCurrentPreview(provider));
+ }
+ },
+ {
+ title: I18n.t('Start_live_location'),
+ icon: 'live',
+ onPress: () => {
+ openSheetSafely(() => openLivePreview(provider));
+ }
+ }
+ ];
+ showActionSheet({ options: modeOptions });
+ };
+
const onPress = () => {
const options: TActionSheetOptionsItem[] = [];
+
if (t === 'l' && permissionToViewCannedResponses) {
options.push({
title: I18n.t('Canned_Responses'),
icon: 'canned-response',
- onPress: () => Navigation.navigate('CannedResponsesListView', { rid })
+ onPress: () => {
+ hideActionSheet();
+ InteractionManager.runAfterInteractions(() => {
+ Navigation.navigate('CannedResponsesListView', { rid });
+ });
+ }
});
}
+
if (permissionToUpload) {
options.push(
{
@@ -53,10 +202,9 @@ export const ActionsButton = () => {
icon: 'camera-photo',
onPress: () => {
hideActionSheet();
- // This is necessary because the action sheet does not close properly on Android
- setTimeout(() => {
+ InteractionManager.runAfterInteractions(() => {
takePhoto();
- }, 250);
+ });
}
},
{
@@ -64,10 +212,9 @@ export const ActionsButton = () => {
icon: 'camera',
onPress: () => {
hideActionSheet();
- // This is necessary because the action sheet does not close properly on Android
- setTimeout(() => {
+ InteractionManager.runAfterInteractions(() => {
takeVideo();
- }, 250);
+ });
}
},
{
@@ -75,16 +222,20 @@ export const ActionsButton = () => {
icon: 'image',
onPress: () => {
hideActionSheet();
- // This is necessary because the action sheet does not close properly on Android
- setTimeout(() => {
+ InteractionManager.runAfterInteractions(() => {
chooseFromLibrary();
- }, 250);
+ });
}
},
{
title: I18n.t('Choose_file'),
icon: 'attach',
- onPress: () => chooseFile()
+ onPress: () => {
+ hideActionSheet();
+ InteractionManager.runAfterInteractions(() => {
+ chooseFile();
+ });
+ }
}
);
}
@@ -92,7 +243,20 @@ export const ActionsButton = () => {
options.push({
title: I18n.t('Create_Discussion'),
icon: 'discussions',
- onPress: () => createDiscussion()
+ onPress: () => {
+ hideActionSheet();
+ InteractionManager.runAfterInteractions(() => {
+ createDiscussion();
+ });
+ }
+ });
+
+ options.push({
+ title: I18n.t('Share_Location'),
+ icon: 'pin-map',
+ onPress: () => {
+ openSheetSafely(() => openModeSheetForProvider(mapProvider));
+ }
});
closeEmojiKeyboardAndAction(showActionSheet, { options });
diff --git a/app/containers/message/Components/Attachments/Attachments.tsx b/app/containers/message/Components/Attachments/Attachments.tsx
index b7ffb7e5481..ab7121e126e 100644
--- a/app/containers/message/Components/Attachments/Attachments.tsx
+++ b/app/containers/message/Components/Attachments/Attachments.tsx
@@ -7,16 +7,22 @@ import Audio from './Audio';
import Video from './Video';
import CollapsibleQuote from './CollapsibleQuote';
import AttachedActions from './AttachedActions';
+import LiveLocationAttachment from './LiveLocationAttachment';
import MessageContext from '../../Context';
import { type IMessageAttachments } from '../../interfaces';
import { type IAttachment } from '../../../../definitions';
import { getMessageFromAttachment } from '../../utils';
const removeQuote = (file?: IAttachment) =>
- file?.image_url || file?.audio_url || file?.video_url || (file?.actions?.length || 0) > 0 || file?.collapsed;
+ file?.image_url ||
+ file?.audio_url ||
+ file?.video_url ||
+ (file?.actions?.length || 0) > 0 ||
+ file?.collapsed ||
+ file?.type === 'live-location';
const Attachments: React.FC = React.memo(
- ({ attachments, timeFormat, showAttachment, getCustomEmoji, author }: IMessageAttachments) => {
+ ({ attachments, timeFormat, showAttachment, getCustomEmoji, author, id, rid }: IMessageAttachments) => {
'use memo';
const { translateLanguage } = useContext(MessageContext);
@@ -68,6 +74,9 @@ const Attachments: React.FC = React.memo(
return ;
}
+ if (file.type === 'live-location' && file.live) {
+ return ;
+ }
return null;
});
return {attachmentsElements};
diff --git a/app/containers/message/Components/Attachments/LiveLocationAttachment.tsx b/app/containers/message/Components/Attachments/LiveLocationAttachment.tsx
new file mode 100644
index 00000000000..c63f8a1bc09
--- /dev/null
+++ b/app/containers/message/Components/Attachments/LiveLocationAttachment.tsx
@@ -0,0 +1,127 @@
+import React from 'react';
+import { View, Text, TouchableOpacity, StyleSheet, Alert } from 'react-native';
+
+import { useTheme } from '../../../../theme';
+import type { IAttachment } from '../../../../definitions';
+import Navigation from '../../../../lib/navigation/appNavigation';
+import { useAppSelector } from '../../../../lib/hooks/useAppSelector';
+import { reopenLiveLocationModal, isLiveLocationActive } from '../../../../views/LocationShare/LiveLocationPreviewModal';
+import I18n from '../../../../i18n';
+
+interface ILiveLocationAttachment {
+ attachment: IAttachment;
+ messageId?: string;
+ roomId?: string;
+}
+
+const styles = StyleSheet.create({
+ container: {
+ borderRadius: 8,
+ padding: 12,
+ marginVertical: 4,
+ borderLeftWidth: 4
+ },
+ disabledContainer: {
+ opacity: 0.6
+ },
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginBottom: 8
+ },
+ icon: {
+ fontSize: 20,
+ marginRight: 8
+ },
+ title: {
+ fontSize: 16,
+ fontWeight: '600',
+ flex: 1
+ },
+ status: {
+ fontSize: 12,
+ fontWeight: '500'
+ },
+ footer: {
+ flexDirection: 'row',
+ justifyContent: 'center',
+ alignItems: 'center'
+ },
+ action: {
+ fontSize: 14,
+ fontWeight: '500'
+ },
+ disabledAction: {
+ fontWeight: '400'
+ }
+});
+
+const LiveLocationAttachment: React.FC = ({ attachment, messageId, roomId }) => {
+ const { colors } = useTheme();
+ const currentUserId = useAppSelector(state => state.login.user.id);
+ const isActive = !!attachment.live?.isActive;
+
+ const { live } = attachment;
+
+ if (!live) {
+ return null;
+ }
+
+ const handlePress = () => {
+ if (!messageId || !roomId) {
+ return;
+ }
+
+ const isOwner = currentUserId === live?.ownerId;
+
+ if (isOwner) {
+ reopenLiveLocationModal();
+ } else {
+ if (isLiveLocationActive()) {
+ Alert.alert(I18n.t('Cannot_View_Live_Location'), I18n.t('Cannot_View_Live_Location_Message'), [{ text: I18n.t('OK') }]);
+ return;
+ }
+
+ Navigation.navigate('LiveLocationViewerModal', {
+ rid: roomId,
+ msgId: messageId
+ });
+ }
+ };
+
+ return (
+
+
+ π
+ {I18n.t('Live_Location')}
+
+ {isActive ? `π΄ ${I18n.t('Active')}` : `β« ${I18n.t('Ended')}`}
+
+
+
+
+
+ {isActive ? I18n.t('Tap_to_view_live_location') : I18n.t('Location_sharing_ended')}
+
+
+
+ );
+};
+
+export default LiveLocationAttachment;
diff --git a/app/containers/message/Components/Attachments/Reply.tsx b/app/containers/message/Components/Attachments/Reply.tsx
index faa796f9634..706d192215c 100644
--- a/app/containers/message/Components/Attachments/Reply.tsx
+++ b/app/containers/message/Components/Attachments/Reply.tsx
@@ -210,7 +210,7 @@ const Reply = React.memo(
const [loading, setLoading] = useState(false);
const { theme } = useTheme();
- const { baseUrl, user, id, e2e, isEncrypted } = useContext(MessageContext);
+ const { baseUrl, user, id, rid, e2e, isEncrypted } = useContext(MessageContext);
if (!attachment || (isEncrypted && !e2e)) {
return null;
@@ -258,6 +258,8 @@ const Reply = React.memo(
getCustomEmoji={getCustomEmoji}
timeFormat={timeFormat}
showAttachment={showAttachment}
+ id={id}
+ rid={rid}
/>
{loading ? (
diff --git a/app/containers/message/Components/CurrentLocationCard.test.ts b/app/containers/message/Components/CurrentLocationCard.test.ts
new file mode 100644
index 00000000000..e1b49ce8b96
--- /dev/null
+++ b/app/containers/message/Components/CurrentLocationCard.test.ts
@@ -0,0 +1,169 @@
+import { extractCoordsFromMessage } from './CurrentLocationCard';
+
+describe('extractCoordsFromMessage', () => {
+ it('parses geo:lat,lon', () => {
+ expect(extractCoordsFromMessage('geo:37.7749,-122.4194')).toEqual({
+ latitude: 37.7749,
+ longitude: -122.4194
+ });
+ });
+
+ it('parses geo:0,0?q=lat,lon', () => {
+ expect(extractCoordsFromMessage('geo:0,0?q=40.7128,-74.0060')).toEqual({
+ latitude: 40.7128,
+ longitude: -74.0060
+ });
+ expect(extractCoordsFromMessage('geo:0,0?q=40.7128%2C-74.0060')).toEqual({
+ latitude: 40.7128,
+ longitude: -74.0060
+ });
+ });
+
+ it('parses Apple Maps ll param', () => {
+ expect(extractCoordsFromMessage('https://maps.apple.com/?ll=51.5074,-0.1278')).toEqual({
+ latitude: 51.5074,
+ longitude: -0.1278
+ });
+ });
+
+ it('parses Apple Maps q param', () => {
+ expect(extractCoordsFromMessage('https://maps.apple.com/?q=48.8566,2.3522')).toEqual({
+ latitude: 48.8566,
+ longitude: 2.3522
+ });
+ });
+
+ it('parses comgooglemaps://?q=lat,lon', () => {
+ expect(extractCoordsFromMessage('comgooglemaps://?q=34.0522,-118.2437')).toEqual({
+ latitude: 34.0522,
+ longitude: -118.2437
+ });
+ expect(extractCoordsFromMessage('comgooglemaps://?q=34.0522%2C-118.2437')).toEqual({
+ latitude: 34.0522,
+ longitude: -118.2437
+ });
+ });
+
+ it('parses google.com/maps/?q=lat,lon', () => {
+ expect(extractCoordsFromMessage('https://www.google.com/maps/?q=35.6895,139.6917')).toEqual({
+ latitude: 35.6895,
+ longitude: 139.6917
+ });
+ expect(extractCoordsFromMessage('https://www.google.com/maps/?q=35.6895%2C139.6917')).toEqual({
+ latitude: 35.6895,
+ longitude: 139.6917
+ });
+ });
+
+ it('parses google.com/maps/@lat,lon,zoom', () => {
+ expect(extractCoordsFromMessage('https://www.google.com/maps/@55.7558,37.6173,15z')).toEqual({
+ latitude: 55.7558,
+ longitude: 37.6173
+ });
+ });
+
+ it('parses openstreetmap.org/?mlat=lat&mlon=lon', () => {
+ expect(extractCoordsFromMessage('https://www.openstreetmap.org/?mlat=52.52&mlon=13.405')).toEqual({
+ latitude: 52.52,
+ longitude: 13.405
+ });
+ });
+
+ it('parses openstreetmap.org/#map=z/lat/lon', () => {
+ expect(extractCoordsFromMessage('https://www.openstreetmap.org/#map=12/41.9028/12.4964')).toEqual({
+ latitude: 41.9028,
+ longitude: 12.4964
+ });
+ });
+
+ it('returns null for non-location messages', () => {
+ expect(extractCoordsFromMessage('Hello world!')).toBeNull();
+ expect(extractCoordsFromMessage('')).toBeNull();
+ expect(extractCoordsFromMessage(undefined)).toBeNull();
+ });
+
+ describe('geo: robustness', () => {
+ it('handles extra query params in geo:', () => {
+ expect(extractCoordsFromMessage('geo:0,0?q=51.5,0.12&zoom=14')).toEqual({
+ latitude: 51.5,
+ longitude: 0.12
+ });
+ });
+
+ it('supports signed coordinates', () => {
+ expect(extractCoordsFromMessage('geo:+12.3456,-098.7654')).toEqual({
+ latitude: 12.3456,
+ longitude: -98.7654
+ });
+ });
+ });
+
+ describe('Apple Maps robustness', () => {
+ it('works with additional params / param reordering', () => {
+ expect(
+ extractCoordsFromMessage('https://maps.apple.com/?t=m&foo=bar&ll=35.6762,139.6503&z=12')
+ ).toEqual({
+ latitude: 35.6762,
+ longitude: 139.6503
+ });
+ expect(
+ extractCoordsFromMessage('https://maps.apple.com/?t=m&q=34.6937,135.5023&foo=bar')
+ ).toEqual({
+ latitude: 34.6937,
+ longitude: 135.5023
+ });
+ });
+
+ it('handles whitespace around URLs', () => {
+ expect(extractCoordsFromMessage(' https://maps.apple.com/?ll=10.1,20.2 ')).toEqual({
+ latitude: 10.1,
+ longitude: 20.2
+ });
+ });
+ });
+
+ describe('Google Maps β web variants', () => {
+ it('parses center= and query=', () => {
+ expect(extractCoordsFromMessage('https://www.google.com/maps/?center=52.52,13.405')).toEqual({
+ latitude: 52.52,
+ longitude: 13.405
+ });
+ expect(extractCoordsFromMessage('https://www.google.com/maps/?query=-23.5505,-46.6333')).toEqual({
+ latitude: -23.5505,
+ longitude: -46.6333
+ });
+ });
+
+ it('handles fragments and extra params', () => {
+ expect(extractCoordsFromMessage('https://www.google.com/maps/?q=40.4168,-3.7038&foo=bar#frag')).toEqual({
+ latitude: 40.4168,
+ longitude: -3.7038
+ });
+ });
+
+ it('supports encoded comma and signed longitude', () => {
+ expect(extractCoordsFromMessage('https://www.google.com/maps/?q=-12.5,%2B99.25')).toEqual({
+ latitude: -12.5,
+ longitude: 99.25
+ });
+ });
+ });
+
+ describe('Google Maps β app scheme', () => {
+ it('parses center=lat,lon', () => {
+ expect(extractCoordsFromMessage('comgooglemaps://?center=59.3293,18.0686&zoom=12')).toEqual({
+ latitude: 59.3293,
+ longitude: 18.0686
+ });
+ });
+ });
+
+ describe('OpenStreetMap robustness', () => {
+ it('parses with swapped order of mlat/mlon among other params', () => {
+ expect(extractCoordsFromMessage('https://www.openstreetmap.org/?foo=bar&mlon=13.405&mlat=52.52')).toEqual({
+ latitude: 52.52,
+ longitude: 13.405
+ });
+ });
+ });
+});
diff --git a/app/containers/message/Components/CurrentLocationCard.tsx b/app/containers/message/Components/CurrentLocationCard.tsx
new file mode 100644
index 00000000000..c2be22c216f
--- /dev/null
+++ b/app/containers/message/Components/CurrentLocationCard.tsx
@@ -0,0 +1,279 @@
+import React, { useMemo } from 'react';
+import { View, Text, StyleSheet, TouchableOpacity, Linking, Pressable } from 'react-native';
+import * as Haptics from 'expo-haptics';
+import { Image as ExpoImage } from 'expo-image';
+
+import { useAppSelector } from '../../../lib/hooks/useAppSelector';
+import { useUserPreferences } from '../../../lib/methods/userPreferences';
+import { MAP_PROVIDER_DEFAULT, MAP_PROVIDER_PREFERENCE_KEY } from '../../../lib/constants/keys';
+import {
+ mapsDeepLink,
+ staticMapUrl,
+ providerAttribution,
+ providerLabel
+} from '../../../views/LocationShare/services/mapProviders';
+import type { MapProviderName } from '../../../views/LocationShare/services/mapProviders';
+import I18n from '../../../i18n';
+import { useTheme } from '../../../theme';
+
+// ---------- Types ----------
+type Props = {
+ msg?: string | null;
+};
+
+type Coords = {
+ latitude: number;
+ longitude: number;
+};
+
+// ---------- Utils ----------
+function toNumber(n?: string): number | undefined {
+ if (!n) return undefined;
+ const x = Number(n);
+ return Number.isFinite(x) ? x : undefined;
+}
+
+function parsePair(raw?: string): Coords | null {
+ if (!raw) return null;
+ try {
+ const decoded = decodeURIComponent(raw);
+ const [la, lo] = decoded.split(',');
+ const lat = toNumber(la?.trim());
+ const lon = toNumber(lo?.trim());
+ if (lat !== undefined && lon !== undefined) return { latitude: lat, longitude: lon };
+ } catch {
+ // ignore decode errors
+ }
+ return null;
+}
+
+export function extractCoordsFromMessage(msg?: string | null): Coords | null {
+ if (!msg) return null;
+ const s = msg.trim();
+
+ let m = s.match(/[?&]q=([^&]+)/i);
+ if (m) {
+ const pair = parsePair(m[1]);
+ if (pair) return pair;
+ }
+
+ m = s.match(/^geo:\s*([+\-%0-9.]+),([+\-%0-9.]+)/i);
+ if (m) {
+ const lat = toNumber(decodeURIComponent(m[1]));
+ const lon = toNumber(decodeURIComponent(m[2]));
+ if (lat !== undefined && lon !== undefined) return { latitude: lat, longitude: lon };
+ }
+
+ m = s.match(/maps\.apple\.com\/[^]*?[?&]ll=([^&]+)/i);
+ if (m) {
+ const pair = parsePair(m[1]);
+ if (pair) return pair;
+ }
+
+ m = s.match(/maps\.apple\.com\/[^]*?[?&]q=([^&]+)/i);
+ if (m) {
+ const pair = parsePair(m[1]);
+ if (pair) return pair;
+ }
+
+ m = s.match(/google\.com\/maps\/[^]*?[?&](?:q|query|center)=([^&]+)/i);
+ if (m) {
+ const pair = parsePair(m[1]);
+ if (pair) return pair;
+ }
+
+ m = s.match(/google\.com\/maps\/[^@]*@([+\-%0-9.]+),([+\-%0-9.]+)/i);
+ if (m) {
+ const lat = toNumber(decodeURIComponent(m[1]));
+ const lon = toNumber(decodeURIComponent(m[2]));
+ if (lat !== undefined && lon !== undefined) return { latitude: lat, longitude: lon };
+ }
+
+ m = s.match(/comgooglemaps:\/\/[^]*?[?&](?:q|center)=([^&]+)/i);
+ if (m) {
+ const pair = parsePair(m[1]);
+ if (pair) return pair;
+ }
+
+ const mlat = s.match(/[?&]mlat=([^&]+)/i)?.[1];
+ const mlon = s.match(/[?&]mlon=([^&]+)/i)?.[1];
+ if (mlat && mlon) {
+ const lat = toNumber(decodeURIComponent(mlat));
+ const lon = toNumber(decodeURIComponent(mlon));
+ if (lat !== undefined && lon !== undefined) return { latitude: lat, longitude: lon };
+ }
+
+ m = s.match(/openstreetmap\.org\/[^#]*#map=\d+\/([+\-%0-9.]+)\/([+\-%0-9.]+)/i);
+ if (m) {
+ const lat = toNumber(decodeURIComponent(m[1]));
+ const lon = toNumber(decodeURIComponent(m[2]));
+ if (lat !== undefined && lon !== undefined) return { latitude: lat, longitude: lon };
+ }
+
+ return null;
+}
+
+export function isCurrentLocationMessage(msg?: string | null): boolean {
+ return !!extractCoordsFromMessage(msg);
+}
+
+// ---------- Constants ----------
+const OSM_HEADERS = {
+ 'User-Agent': 'RocketChatMobile/1.0 (+https://rocket.chat) contact: mobile@rocket.chat',
+ Referer: 'https://rocket.chat'
+};
+
+// ---------- Styles ----------
+const styles = StyleSheet.create({
+ card: {
+ borderRadius: 12,
+ borderWidth: 1,
+ overflow: 'hidden'
+ },
+ header: {
+ paddingHorizontal: 12,
+ paddingTop: 12,
+ paddingBottom: 4
+ },
+ title: {
+ fontSize: 16,
+ fontWeight: '700'
+ },
+ coords: {
+ fontSize: 13,
+ marginTop: 2
+ },
+ link: {
+ fontSize: 14,
+ fontWeight: '700',
+ paddingHorizontal: 12,
+ paddingVertical: 8
+ },
+ mapContainer: {
+ borderTopWidth: 1,
+ borderBottomWidth: 1
+ },
+ mapImage: {
+ width: '100%',
+ height: 180
+ },
+ pinOverlay: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ pinText: {
+ fontSize: 22
+ },
+ pressed: {
+ opacity: 0.92
+ },
+ attribution: {
+ fontSize: 10,
+ textAlign: 'center',
+ paddingVertical: 6
+ }
+});
+
+// ---------- Component ----------
+const CurrentLocationCard: React.FC = ({ msg }) => {
+ const { colors } = useTheme();
+ const coords = useMemo(() => extractCoordsFromMessage(msg), [msg]);
+ const userId = useAppSelector(state => state.login.user.id);
+ const [viewerProvider] = useUserPreferences(
+ `${MAP_PROVIDER_PREFERENCE_KEY}_${userId}`,
+ MAP_PROVIDER_DEFAULT
+ );
+
+ const mapUrl = useMemo(() => {
+ if (!coords) return undefined;
+ return staticMapUrl('osm', coords, { zoom: 15 }).url;
+ }, [coords]);
+
+ const cacheKey = useMemo(
+ () => (coords ? `osm-${coords.latitude.toFixed(5)}-${coords.longitude.toFixed(5)}-z15-v2` : undefined),
+ [coords]
+ );
+
+ const openLabel = useMemo(
+ () => I18n.t('Open_in_provider', { provider: providerLabel(viewerProvider) }),
+ [viewerProvider]
+ );
+
+ const onOpen = async () => {
+ if (!coords) return;
+ try {
+ await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ } catch {
+ // noop
+ }
+ const deep = await mapsDeepLink(viewerProvider, coords);
+ try {
+ const canOpen = await Linking.canOpenURL(deep);
+ if (canOpen) {
+ await Linking.openURL(deep);
+ }
+ } catch {
+ // ignore
+ }
+ };
+
+ if (!coords) return null;
+
+ return (
+
+
+
+ π {I18n.t('Location')}
+
+
+ {coords.latitude.toFixed(5)}, {coords.longitude.toFixed(5)}
+
+
+
+ {mapUrl ? (
+ [
+ styles.mapContainer,
+ { borderColor: colors.strokeLight },
+ pressed && styles.pressed
+ ]}>
+
+
+ π
+
+
+ ) : null}
+
+
+ {providerAttribution('osm')}
+
+
+
+
+ πΊοΈ {openLabel}
+
+
+
+ );
+};
+
+export default CurrentLocationCard;
diff --git a/app/containers/message/Components/LiveLocationCard.test.tsx b/app/containers/message/Components/LiveLocationCard.test.tsx
new file mode 100644
index 00000000000..8fa0d9449f9
--- /dev/null
+++ b/app/containers/message/Components/LiveLocationCard.test.tsx
@@ -0,0 +1,243 @@
+import React from 'react';
+import { render, fireEvent, act } from '@testing-library/react-native';
+import { Alert } from 'react-native';
+
+import LiveLocationCard from './LiveLocationCard';
+
+jest.mock('../../../theme', () => ({
+ useTheme: () => ({
+ colors: {
+ surfaceLight: '#fff',
+ surfaceDisabled: '#eee',
+ statusFontSuccess: 'green',
+ statusFontDanger: 'red',
+ strokeLight: '#ccc',
+ fontDefault: '#000',
+ fontTitlesLabels: '#111'
+ }
+ })
+}));
+
+const mockNavigate = jest.fn();
+jest.mock('../../../lib/navigation/appNavigation', () => ({
+ __esModule: true,
+ default: { navigate: (...args: any[]) => mockNavigate(...args) }
+}));
+
+jest.mock('../../../i18n', () => ({
+ __esModule: true,
+ default: { t: (key: string) => key },
+ t: (key: string) => key
+}));
+
+jest.mock('../../../views/LocationShare/LiveLocationPreviewModal', () => {
+ const state = {
+ statusListeners: [] as Array<(b: boolean) => void>,
+ currentParams: {} as any,
+ currentActive: false,
+ reopenMock: jest.fn()
+ };
+ return {
+ __esModule: true,
+ addStatusChangeListener: (fn: (b: boolean) => void) => state.statusListeners.push(fn),
+ removeStatusChangeListener: (fn: (b: boolean) => void) => {
+ const i = state.statusListeners.indexOf(fn);
+ if (i >= 0) state.statusListeners.splice(i, 1);
+ },
+ getCurrentLiveParams: () => state.currentParams,
+ reopenLiveLocationModal: () => state.reopenMock(),
+ isLiveLocationActive: () => state.currentActive,
+ __emitStatus: (b: boolean) => state.statusListeners.forEach(l => l(b)),
+ __setCurrentParams: (p: any) => { state.currentParams = p ?? {}; },
+ __setIsActive: (b: boolean) => { state.currentActive = b; },
+ __getReopenMock: () => state.reopenMock
+ };
+});
+
+jest.mock('../../../views/LocationShare/services/handleLiveLocationUrl', () => {
+ const ended = new Set();
+ const listeners: Array<(id: string) => void> = [];
+ return {
+ __esModule: true,
+ isLiveLocationEnded: (id: string) => Promise.resolve(ended.has(id)),
+ addLiveLocationEndedListener: (fn: (id: string) => void) => listeners.push(fn),
+ removeLiveLocationEndedListener: (fn: (id: string) => void) => {
+ const i = listeners.indexOf(fn);
+ if (i >= 0) listeners.splice(i, 1);
+ },
+ __emitEnded: (id: string) => listeners.forEach(l => l(id)),
+ __markEnded: (id: string) => ended.add(id),
+ __clearEnded: () => ended.clear()
+ };
+});
+
+jest.spyOn(Alert, 'alert').mockImplementation(jest.fn());
+
+const flush = () => new Promise(res => setTimeout(res, 0));
+const freshLiveId = () => `live_${Date.now()}_abc`;
+const oldLiveId = (mins = 31) => `live_${Date.now() - mins * 60 * 1000}_abc`;
+
+const PreviewModal = require('../../../views/LocationShare/LiveLocationPreviewModal');
+const HandleUrl = require('../../../views/LocationShare/services/handleLiveLocationUrl');
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ PreviewModal.__setCurrentParams({});
+ PreviewModal.__setIsActive(false);
+ HandleUrl.__clearEnded();
+});
+
+describe('LiveLocationCard', () => {
+ it('renders ACTIVE text for a fresh id + recent timestamp', () => {
+ const id = freshLiveId();
+ const { getByText } = render(
+
+ );
+ expect(getByText('Live_Location')).toBeTruthy();
+ expect(getByText('Active_Tap_to_view')).toBeTruthy();
+ });
+
+ it('navigates to viewer on press when active and not current session', () => {
+ const id = freshLiveId();
+ const { getByText } = render(
+
+ );
+ const card = getByText('Live_Location').parent?.parent;
+ fireEvent.press(card!);
+ expect(mockNavigate).toHaveBeenCalledWith('LiveLocationViewerModal', { rid: 'GENERAL', msgId: id });
+ });
+
+ it('shows block alert when another session is active', () => {
+ const id = freshLiveId();
+ PreviewModal.__setIsActive(true);
+ const { getByText } = render(
+
+ );
+ const card = getByText('Live_Location').parent?.parent;
+ fireEvent.press(card!);
+ expect(Alert.alert).toHaveBeenCalledWith('Live_Location_Active', 'Live_Location_Active_Block_Message');
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
+ it('renders INACTIVE if messageTimestamp > 10 minutes old', () => {
+ const id = freshLiveId();
+ const ts = Date.now() - 11 * 60 * 1000;
+ const { getByText } = render(
+
+ );
+ expect(getByText('Inactive')).toBeTruthy();
+ });
+
+ it('renders INACTIVE if encoded id time is older than 30 minutes', () => {
+ const id = oldLiveId(31);
+ const { getByText } = render(
+
+ );
+ expect(getByText('Inactive')).toBeTruthy();
+ });
+
+ it('pressing inactive card shows ended alert and calls onPress', async () => {
+ const id = freshLiveId();
+ const onPress = jest.fn();
+ HandleUrl.__markEnded(id);
+ const { getByText, findByText } = render(
+
+ );
+ await findByText('Inactive');
+ const card = getByText('Live_Location').parent?.parent;
+ fireEvent.press(card!);
+ expect(Alert.alert).toHaveBeenCalledWith('Live_Location_Ended_Title', 'Live_Location_Ended_Message');
+ expect(onPress).toHaveBeenCalled();
+ });
+
+ it('reacts to status change event (active β inactive)', async () => {
+ const id = freshLiveId();
+ const { getByText, findByText } = render(
+
+ );
+ expect(getByText('Active_Tap_to_view')).toBeTruthy();
+ PreviewModal.__setCurrentParams({ liveLocationId: id });
+ await act(async () => {
+ PreviewModal.__emitStatus(false);
+ await flush();
+ });
+ await findByText('Inactive');
+ expect(await findByText('Inactive')).toBeTruthy();
+ });
+
+ it('reacts to ended event by flipping to inactive', async () => {
+ const id = freshLiveId();
+ const { getByText, findByText } = render(
+
+ );
+ expect(getByText('Active_Tap_to_view')).toBeTruthy();
+ await act(async () => {
+ HandleUrl.__markEnded(id);
+ HandleUrl.__emitEnded(id);
+ await flush();
+ });
+ expect(await findByText('Inactive')).toBeTruthy();
+ });
+
+ it('syncs with current session when ids match (reopen viewer)', () => {
+ const id = freshLiveId();
+ PreviewModal.__setCurrentParams({ liveLocationId: id });
+ PreviewModal.__setIsActive(true);
+ const reopenMock: jest.Mock = PreviewModal.__getReopenMock();
+ const { getByText } = render(
+
+ );
+ const card = getByText('Live_Location').parent?.parent;
+ fireEvent.press(card!);
+ expect(reopenMock).toHaveBeenCalled();
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
+ it('renders with missing id and with empty msg', () => {
+ const { getByText: getByText1 } = render(
+
+ );
+ expect(getByText1('Live_Location')).toBeTruthy();
+ const { getByText: getByText2 } = render(
+
+ );
+ expect(getByText2('Live_Location')).toBeTruthy();
+ });
+});
diff --git a/app/containers/message/Components/LiveLocationCard.tsx b/app/containers/message/Components/LiveLocationCard.tsx
new file mode 100644
index 00000000000..1bacc01ccec
--- /dev/null
+++ b/app/containers/message/Components/LiveLocationCard.tsx
@@ -0,0 +1,293 @@
+import React, { useEffect, useRef, useState, useMemo } from 'react';
+import { View, Text, TouchableOpacity, StyleSheet, Alert } from 'react-native';
+
+import { useTheme } from '../../../theme';
+import Navigation from '../../../lib/navigation/appNavigation';
+import {
+ addStatusChangeListener,
+ removeStatusChangeListener,
+ getCurrentLiveParams,
+ reopenLiveLocationModal,
+ isLiveLocationActive
+} from '../../../views/LocationShare/LiveLocationPreviewModal';
+import {
+ isLiveLocationEnded,
+ addLiveLocationEndedListener,
+ removeLiveLocationEndedListener
+} from '../../../views/LocationShare/services/handleLiveLocationUrl';
+import I18n from '../../../i18n';
+
+interface LiveLocationCardProps {
+ msg: string;
+ isActive?: boolean;
+ messageTimestamp?: string | Date | number;
+ onPress?: () => void;
+}
+
+const LiveLocationCard: React.FC = ({ msg, isActive = true, messageTimestamp, onPress }) => {
+ const { colors } = useTheme();
+
+ const initialActiveState = useMemo(() => {
+ const linkMatch = msg?.match(/rocketchat:\/\/live-location\?([^)]+)/);
+ if (!linkMatch) return isActive;
+
+ const params = new URLSearchParams(linkMatch[1]);
+ const liveLocationId = params.get('liveLocationId');
+ if (!liveLocationId) return isActive;
+
+ return isActive;
+ }, [msg, isActive]);
+
+ const [cardIsActive, setCardIsActive] = useState(initialActiveState);
+
+ const cardIsActiveRef = useRef(cardIsActive);
+ useEffect(() => {
+ cardIsActiveRef.current = cardIsActive;
+ }, [cardIsActive]);
+
+ const isMessageTooOld = (
+ timestamp?: string | Date | number | { $date?: number; valueOf?: () => number; getTime?: () => number }
+ ) => {
+ if (timestamp == null) {
+ return false;
+ }
+
+ try {
+ let t: number;
+
+ if (typeof timestamp === 'number') {
+ t = timestamp;
+ } else if (typeof timestamp === 'string') {
+ t = new Date(timestamp).getTime();
+ } else if (timestamp instanceof Date) {
+ t = timestamp.getTime();
+ } else if (typeof timestamp === 'object') {
+ t =
+ timestamp.$date ||
+ timestamp.valueOf?.() ||
+ timestamp.getTime?.() ||
+ new Date(timestamp as unknown as string | number).getTime();
+ } else {
+ t = new Date(timestamp as unknown as string | number).getTime();
+ }
+
+ if (Number.isNaN(t) || t <= 0) {
+ return false;
+ }
+
+ const now = Date.now();
+ const TEN_MINUTES_MS = 10 * 60 * 1000;
+ const ageMs = now - t;
+ const isTooOld = ageMs > TEN_MINUTES_MS;
+
+ return isTooOld;
+ } catch (error) {
+ return false;
+ }
+ };
+
+ useEffect(() => {
+ const linkMatch = msg?.match(/rocketchat:\/\/live-location\?([^)]+)/);
+ if (!linkMatch) return;
+
+ const params = new URLSearchParams(linkMatch[1]);
+ const thisCardLiveLocationId = params.get('liveLocationId') || null;
+
+ if (!thisCardLiveLocationId) return;
+
+ const currentParams = getCurrentLiveParams();
+ const isOwnLiveLocation = currentParams && currentParams.liveLocationId === thisCardLiveLocationId;
+
+ if (isOwnLiveLocation) {
+ Promise.resolve().then(() => setCardIsActive(isLiveLocationActive()));
+ } else {
+ const now = Date.now();
+ let shouldBeActive = true;
+
+ const idMatch = thisCardLiveLocationId.match(/^live_(\d+)_/);
+ if (idMatch) {
+ const messageTime = parseInt(idMatch[1], 10);
+ const THIRTY_MINUTES_MS = 30 * 60 * 1000;
+ const ageMs = now - messageTime;
+
+
+ if (ageMs > THIRTY_MINUTES_MS) {
+ shouldBeActive = false;
+ }
+ }
+
+ setCardIsActive(shouldBeActive);
+
+ isLiveLocationEnded(thisCardLiveLocationId).then(ended => {
+ if (ended) {
+ setCardIsActive(false);
+ }
+ });
+ }
+
+ if (isMessageTooOld(messageTimestamp) && cardIsActiveRef.current) {
+ setCardIsActive(false);
+ }
+
+ const handleStatusChange = (active: boolean) => {
+ const current = getCurrentLiveParams();
+ if (current && current.liveLocationId === thisCardLiveLocationId) {
+ setCardIsActive(active);
+ }
+ };
+
+ const handleLiveLocationEnded = (endedId: string) => {
+ if (endedId === thisCardLiveLocationId) {
+ setCardIsActive(false);
+ }
+ };
+
+ addStatusChangeListener(handleStatusChange);
+ addLiveLocationEndedListener(handleLiveLocationEnded);
+
+ const staleCheck = setInterval(() => {
+ if (isMessageTooOld(messageTimestamp) && cardIsActiveRef.current) {
+ setCardIsActive(false);
+ }
+ }, 5 * 60 * 1000);
+
+ return () => {
+ removeStatusChangeListener(handleStatusChange);
+ removeLiveLocationEndedListener(handleLiveLocationEnded);
+ clearInterval(staleCheck);
+ };
+ }, [msg, isActive, messageTimestamp]);
+
+ const handleCardPress = () => {
+ if (!cardIsActive) {
+ Alert.alert(I18n.t('Live_Location_Ended_Title'), I18n.t('Live_Location_Ended_Message'));
+ onPress?.();
+ return;
+ }
+
+ const linkMatch = msg.match(/rocketchat:\/\/live-location\?([^)]+)/);
+ const thisCardLiveLocationId = linkMatch ? new URLSearchParams(linkMatch[1]).get('liveLocationId') : null;
+ const currentParams = getCurrentLiveParams();
+
+ if (currentParams && currentParams.liveLocationId === thisCardLiveLocationId) {
+ reopenLiveLocationModal();
+ } else if (isLiveLocationActive()) {
+ Alert.alert(I18n.t('Live_Location_Active'), I18n.t('Live_Location_Active_Block_Message'));
+ } else if (linkMatch) {
+ const params = new URLSearchParams(linkMatch[1]);
+ const liveLocationId = params.get('liveLocationId');
+ const rid = params.get('rid');
+ const msgId = params.get('msgId');
+ if (!rid || !(msgId || liveLocationId)) {
+ Alert.alert(I18n.t('Error'), I18n.t('Could_not_open_live_location'));
+ return;
+ }
+ Navigation.navigate('LiveLocationViewerModal', {
+ rid,
+ msgId: msgId || (liveLocationId as string)
+ });
+ } else {
+ Alert.alert(I18n.t('Error'), I18n.t('Could_not_open_live_location'));
+ }
+ onPress?.();
+ };
+
+ return (
+
+
+
+ π
+
+
+
+ {I18n.t('Live_Location')}
+
+ {cardIsActive ? I18n.t('Active_Tap_to_view') : I18n.t('Inactive')}
+
+
+ {cardIsActive && (
+
+
+
+
+
+ )}
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ card: {
+ borderRadius: 12,
+ borderWidth: 1,
+ padding: 16,
+ marginVertical: 4,
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.1,
+ shadowRadius: 4,
+ elevation: 3
+ },
+ cardContent: {
+ flexDirection: 'row',
+ alignItems: 'center'
+ },
+ iconContainer: {
+ position: 'relative',
+ marginRight: 12
+ },
+ icon: {
+ fontSize: 24
+ },
+ statusDot: {
+ position: 'absolute',
+ top: -2,
+ right: -2,
+ width: 8,
+ height: 8,
+ borderRadius: 4
+ },
+ textContainer: {
+ flex: 1
+ },
+ title: {
+ fontSize: 16,
+ fontWeight: '600',
+ marginBottom: 2
+ },
+ status: {
+ fontSize: 14,
+ fontWeight: '500'
+ },
+ pulseContainer: {
+ position: 'relative',
+ width: 20,
+ height: 20,
+ justifyContent: 'center',
+ alignItems: 'center'
+ },
+ pulse: {
+ position: 'absolute',
+ width: 12,
+ height: 12,
+ borderRadius: 6,
+ opacity: 0.6
+ },
+ pulse1: { transform: [{ scale: 1 }] },
+ pulse2: { transform: [{ scale: 1.4 }], opacity: 0.4 },
+ pulse3: { transform: [{ scale: 1.8 }], opacity: 0.2 }
+});
+
+export default LiveLocationCard;
diff --git a/app/containers/message/Content.tsx b/app/containers/message/Content.tsx
index fb2d34fc5d4..c4730188f70 100644
--- a/app/containers/message/Content.tsx
+++ b/app/containers/message/Content.tsx
@@ -11,12 +11,32 @@ import MessageContext from './Context';
import { type IMessageContent } from './interfaces';
import { useTheme } from '../../theme';
import { themes } from '../../lib/constants/colors';
-import { type MessageTypesValues } from '../../definitions';
+import type { MessageTypesValues, IUserMessage } from '../../definitions';
+import LiveLocationCard from './Components/LiveLocationCard';
-const Content = React.memo(
- (props: IMessageContent) => {
- 'use memo';
+type MaybeTimestampProps = {
+ ts?: Date | string | number;
+ _updatedAt?: Date | string | number;
+ updatedAt?: Date | string | number;
+};
+
+const LIVE_LOCATION_REGEX = /rocketchat:\/\/live-location\?/;
+
+function coerceToDate(v: unknown): Date | undefined {
+ if (v instanceof Date) return v;
+ if (typeof v === 'string' || typeof v === 'number') {
+ const d = new Date(v);
+ return isNaN(d.getTime()) ? undefined : d;
+ }
+ return undefined;
+}
+
+function deriveMessageTimestamp(p: Partial & MaybeTimestampProps): Date | undefined {
+ return coerceToDate(p.ts) ?? coerceToDate(p._updatedAt) ?? coerceToDate(p.updatedAt);
+}
+const Content = React.memo(
+ (props: IMessageContent & { author?: IUserMessage }) => {
const { theme } = useTheme();
const { user, onLinkPress } = useContext(MessageContext);
@@ -41,7 +61,14 @@ const Content = React.memo(
}
const isPreview = props.tmid && !props.isThreadRoom;
- let content = null;
+ let content: React.ReactNode | null = null;
+
+ const isLiveLocationMessage = typeof props.msg === 'string' && LIVE_LOCATION_REGEX.test(props.msg);
+ if (isLiveLocationMessage && props.msg) {
+ const messageTs = deriveMessageTimestamp(props);
+
+ content = ;
+ }
if (props.isEncrypted) {
content = (
@@ -52,9 +79,9 @@ const Content = React.memo(
{I18n.t('Encrypted_message')}
);
- } else if (isPreview) {
+ } else if (!content && isPreview) {
content = ;
- } else if (props.msg) {
+ } else if (!content && props.msg) {
content = (
{
<>
-
+
>
>
@@ -80,15 +81,18 @@ const MessageInner = React.memo((props: IMessageInner) => {
}
if (!content) {
+ const isLocation = isCurrentLocationMessage(props.msg as string);
content = (
<>
{showTimeLarge ? : null}
-
-
-
+ {!isLocation ? : null}
+ {/* Render a card for "current location" messages; returns null otherwise */}
+
+
+ {!isLocation ? : null}
@@ -161,7 +165,7 @@ const Message = React.memo((props: IMessageTouchable & IMessage) => {
{props.isInfo && props.type === 'message_pinned' ? (
-
+
) : null}
diff --git a/app/containers/message/interfaces.ts b/app/containers/message/interfaces.ts
index 7c396e68a0b..578ab8b50bc 100644
--- a/app/containers/message/interfaces.ts
+++ b/app/containers/message/interfaces.ts
@@ -1,5 +1,5 @@
import { type Root } from '@rocket.chat/message-parser';
-import { type StyleProp } from 'react-native';
+import { type StyleProp, type ViewStyle } from 'react-native';
import { type ImageStyle } from 'expo-image';
import { type IUserChannel } from '../markdown/interfaces';
@@ -21,6 +21,10 @@ export interface IMessageAttachments {
showAttachment?: (file: IAttachment) => void;
getCustomEmoji: TGetCustomEmoji;
author?: IUserMessage;
+ style?: StyleProp;
+ isReply?: boolean;
+ id: string;
+ rid: string;
}
export interface IMessageAvatar {
diff --git a/app/definitions/IAttachment.ts b/app/definitions/IAttachment.ts
index 34e3fcd22d4..9951c49dd1c 100644
--- a/app/definitions/IAttachment.ts
+++ b/app/definitions/IAttachment.ts
@@ -45,6 +45,16 @@ export interface IAttachment {
hashes?: {
sha256: string;
};
+ live?: {
+ isActive: boolean;
+ ownerId: string;
+ coords?: { lat: number; lon: number; acc?: number };
+ startedAt: string;
+ lastUpdateAt: string;
+ expiresAt?: string;
+ stoppedAt?: string;
+ version: number;
+ };
}
export interface IServerAttachment {
diff --git a/app/definitions/rest/v1/index.ts b/app/definitions/rest/v1/index.ts
index 0642359a1bb..034ec17b883 100644
--- a/app/definitions/rest/v1/index.ts
+++ b/app/definitions/rest/v1/index.ts
@@ -21,6 +21,7 @@ import { type PushEndpoints } from './push';
import { type DirectoryEndpoint } from './directory';
import { type AutoTranslateEndpoints } from './autotranslate';
import { type ModerationEndpoints } from './moderation';
+import { type LiveLocationEndpoints } from './liveLocation';
export type Endpoints = ChannelsEndpoints &
ChatEndpoints &
@@ -44,4 +45,5 @@ export type Endpoints = ChannelsEndpoints &
PushEndpoints &
DirectoryEndpoint &
AutoTranslateEndpoints &
- ModerationEndpoints;
+ ModerationEndpoints &
+ LiveLocationEndpoints;
diff --git a/app/definitions/rest/v1/liveLocation.ts b/app/definitions/rest/v1/liveLocation.ts
new file mode 100644
index 00000000000..27820e58f36
--- /dev/null
+++ b/app/definitions/rest/v1/liveLocation.ts
@@ -0,0 +1,41 @@
+import type { IMessage } from '../../IMessage';
+import type { IServerRoom } from '../../IRoom';
+
+export type LiveLocationEndpoints = {
+ 'liveLocation.start': {
+ POST: (params: { rid: IServerRoom['_id']; durationSec?: number; initial?: { lat: number; lon: number; acc?: number } }) => {
+ msgId: string;
+ };
+ };
+ 'liveLocation.update': {
+ POST: (params: { rid: IServerRoom['_id']; msgId: IMessage['_id']; coords: { lat: number; lon: number; acc?: number } }) => {
+ updated?: boolean;
+ ignored?: boolean;
+ reason?: string;
+ };
+ };
+ 'liveLocation.stop': {
+ POST: (params: {
+ rid: IServerRoom['_id'];
+ msgId: IMessage['_id'];
+ finalCoords?: { lat: number; lon: number; acc?: number };
+ }) => {
+ stopped?: boolean;
+ };
+ };
+ 'liveLocation.get': {
+ GET: (params: { rid: IServerRoom['_id']; msgId: IMessage['_id'] }) => {
+ messageId: string;
+ ownerId: string;
+ ownerUsername?: string;
+ ownerName?: string;
+ isActive: boolean;
+ startedAt?: string;
+ lastUpdateAt?: string;
+ stoppedAt?: string;
+ coords?: { lat: number; lon: number };
+ expiresAt?: string;
+ version: number;
+ };
+ };
+};
diff --git a/app/i18n/locales/en.json b/app/i18n/locales/en.json
index 4c36b2afa3b..509e4fc483d 100644
--- a/app/i18n/locales/en.json
+++ b/app/i18n/locales/en.json
@@ -17,7 +17,10 @@
"Accessibility_and_Appearance": "Accessibility & appearance",
"Accessibility_statement": "Accessibility statement",
"Accounts_Default_User_Preferences_alsoSendThreadToChannel_Description": "Allow users to select the 'Also send to channel' behavior",
+ "Accuracy": "Accuracy: Β±{{meters}}m",
"Actions": "Actions",
+ "Active": "Active",
+ "Active_Tap_to_view": "Active β’ Tap to view",
"Activity": "Activity",
"Add_Channel_to_Team": "Add channel to team",
"Add_Existing": "Add existing",
@@ -56,7 +59,11 @@
"Animals_and_nature": "Animals and nature",
"Announcement": "Announcement",
"announcement": "announcement",
+ "API_key_required": "{{provider}} API key required",
+ "API_Keys": "API Keys",
+ "API_Keys_Info": "API keys are stored securely on your device and are only used for map services",
"App_users_are_not_allowed_to_log_in_directly": "App users are not allowed to log in directly.",
+ "Apple_Maps": "Apple Maps",
"Apply_Certificate": "Apply certificate",
"ARCHIVE": "Archive",
"archive": "archive",
@@ -117,6 +124,8 @@
"Cannot_delete": "Cannot delete",
"Cannot_leave": "Cannot leave",
"Cannot_remove": "Cannot remove",
+ "Cannot_View_Live_Location": "Cannot View Live Location",
+ "Cannot_View_Live_Location_Message": "You cannot view others' live locations while sharing your own. Please stop sharing your location first.",
"Certificate_password": "Certificate password",
"Change_Language": "Change language",
"Change_language_loading": "Changing language.",
@@ -160,6 +169,7 @@
"Community_edition_push_quota": "Community push quota",
"Condensed": "Condensed",
"conference_call": "Conference call",
+ "Configured": "Configured",
"Confirm": "Confirm",
"Confirm_password": "Confirm password",
"Confirm_Password": "Confirm Password",
@@ -183,8 +193,12 @@
"Converted__roomName__to_a_channel": "converted #{{roomName}} to channel",
"Converted__roomName__to_a_team": "converted #{{roomName}} to a team",
"Converting_Team_To_Channel": "Converting team to channel",
+ "Coordinates": "Coordinates: {{lat}}, {{lon}}",
"Copied_to_clipboard": "Copied to clipboard!",
"Copy": "Copy",
+ "Could_not_get_location": "Could not get your location",
+ "Could_not_open_live_location": "Could not open live location",
+ "Could_not_open_maps_application": "Could not open maps application",
"Crash_report_disclaimer": "We never track the content of your chats. The crash report and analytics events only contains relevant information for us in order to identify and fix issues.",
"Create": "Create",
"Create_A_New_Channel": "Create a New Channel",
@@ -211,6 +225,7 @@
"decline": "Decline",
"Default": "Default",
"Default_browser": "Default browser",
+ "Default_Map_Provider": "Default Map Provider",
"Defined_user_as_role": "defined {{user}} as {{role}}",
"DELETE": "DELETE",
"Delete": "Delete",
@@ -314,9 +329,14 @@
"Encryption_keys_reset": "Encryption keys reset",
"Encryption_keys_reset_failed": "Encryption keys reset failed",
"End_to_end_encrypted_room": "End to end encrypted room",
+ "Ended": "Ended",
+ "Enter_API_key": "Enter API key",
"Enter_E2EE_Password": "Enter E2EE password",
"Enter_E2EE_Password_description": "To access your encrypted channels and direct messages, enter your encryption password. This is not stored on the server, so youβll need to use it on every device.",
"Enter_the_code": "Enter the code we just emailed you.",
+ "Enter_your_Google_Maps_API_key": "Enter your Google Maps API key",
+ "Enter_your_OSM_API_key": "Enter your OpenStreetMap API key",
+ "Error": "Error",
"Error_Download_file": "Error while downloading file",
"Error_incorrect_password": "Incorrect password",
"Error_play_video": "There was an error while playing this video",
@@ -335,6 +355,7 @@
"error-no-tokens-for-this-user": "There are no tokens for this user",
"error-not-allowed": "Not allowed",
"error-not-permission-to-upload-file": "You don't have permission to upload files",
+ "error-open-maps-application": "Error, could not open maps application",
"error-save-image": "Error while saving image",
"error-save-video": "Error while saving video",
"error-team-creation": "Error team creation",
@@ -374,6 +395,9 @@
"Get_link": "Get link",
"Glossary_of_simplified_terms": "Glossary of simplified terms",
"Go_to_your_device_settings_and_allow_microphone": "Go to your device settings and allow microphone access for Rocket.Chat",
+ "Google_API_Key_Instructions": "Get your Google Maps API key from the Google Cloud Console at console.cloud.google.com",
+ "Google_Maps": "Google Maps",
+ "Google_Maps_API_Key": "Google Maps API Key",
"Group_by": "Group by",
"Has_left_the_team": "has left the team",
"Help": "Help",
@@ -384,6 +408,7 @@
"Hide_System_Messages": "Hide system messages",
"Hide_type_messages": "Hide \"{{type}}\" messages",
"How_It_Works": "How it works",
+ "How_to_get_API_keys": "How to get API keys",
"I_Saved_My_E2E_Password": "I saved my E2E password",
"Ignore": "Ignore",
"Image": "Image",
@@ -394,6 +419,7 @@
"In_App_and_Desktop_Alert_info": "Displays a banner at the top of the screen when app is open, and displays a notification on desktop",
"In_app_message_notifications": "In app message notifications",
"In_App_Notification": "In-app notification",
+ "Inactive": "Inactive",
"Incoming_call_from": "Incoming call from",
"Inline_code": "Inline code",
"insert_Avatar_URL": "insert image URL here",
@@ -445,11 +471,28 @@
"Legal": "Legal",
"License": "License",
"Light": "Light",
+ "Live_Location": "Live Location",
+ "Live_Location_Active": "Live Location Active",
+ "Live_Location_Active_Block_Message": "You're already sharing your live location. Please stop the current session before starting a new one.",
+ "Live_Location_Ended_Message": "This live location session has ended",
+ "Live_Location_Ended_Title": "Live location ended",
+ "Live_Location_Get_Error": "Failed to get live location",
+ "Live_Location_Inactive": "Live location inactive",
+ "Live_Location_Invalid_Response": "Invalid response: missing required fields",
+ "Live_location_not_found": "Live location not found",
+ "Live_Location_Preview": "Live location preview",
+ "Live_Location_Start_Error": "Could not start live location. Please ensure your server has the liveLocation API routes and is reachable.",
+ "Live_Location_Start_Failed": "Failed to start live location",
+ "Live_Location_Start_Title": "Live Location Start",
+ "Live_Location_Stop_Error": "Failed to stop live location",
+ "Live_Location_Update_Error": "Failed to update live location",
+ "Live_Tracking_Active": "Live tracking active",
"Livechat_transfer_return_to_the_queue": "returned the chat to the queue",
"Load_More": "Load more",
"Load_Newer": "Load newer",
"Load_Older": "Load older",
"Loading": "Loading",
+ "Loading_live_location": "Loading live location...",
"Local_authentication_auto_lock_1800": "After 30 minutes",
"Local_authentication_auto_lock_300": "After 5 minutes",
"Local_authentication_auto_lock_3600": "After 1 hour",
@@ -463,6 +506,11 @@
"Local_authentication_info": "Note: if you forget the passcode, you'll need to delete and reinstall the app.",
"Local_authentication_unlock_option": "Unlock with passcode",
"Local_authentication_unlock_with_label": "Unlock with {{label}}",
+ "Location": "Location",
+ "Location_not_available": "Location not available",
+ "Location_permission_required": "Location permission is required to share your location",
+ "Location_Preferences": "Location preferences",
+ "Location_sharing_ended": "Location sharing ended",
"Log_analytics_events": "Log analytics events",
"Logged_out_by_server": "You've been logged out by the workspace. Please log in again.",
"Logged_out_of_other_clients_successfully": "Logged out of other clients successfully",
@@ -474,6 +522,10 @@
"Logout": "Logout",
"Logout_failed": "Logout failed!",
"Logout_from_other_logged_in_locations": "Logout from other logged in locations",
+ "Map_Preview": "Map preview",
+ "Map_preview_unavailable_open_below": "Map preview unavailable\nTap \"Open in Maps\" below to view location",
+ "Map_Provider": "Map provider",
+ "Map_Provider_Info": "Choose your preferred map provider for location sharing",
"Mark_as_unread": "Mark as unread",
"Mark_as_unread_Info": "Display room as unread when there are unread messages",
"Mark_read": "Mark read",
@@ -577,6 +629,7 @@
"no-active-video-conf-provider-header": "Conference call not enabled",
"no-videoconf-provider-app-body": "Conference call apps can be installed in the Rocket.Chat Marketplace by a workspace admin.",
"no-videoconf-provider-app-header": "Conference call not available",
+ "Not_configured": "Not configured",
"Not_in_channel": "Not in channel",
"Not_RC_Server": "Contact your workspace admin or search your email inbox for a Rocket.Chat workspace invite.",
"Nothing": "Nothing",
@@ -591,6 +644,7 @@
"Objects": "Objects",
"Off": "Off",
"Offline": "Offline",
+ "OK": "OK",
"Omnichannel": "Omnichannel",
"Omnichannel_enable_alert": "You're not available on Omnichannel. Would you like to be available?",
"Omnichannel_on_hold_chat_resumed": "On hold chat resumed: {{comment}}",
@@ -604,12 +658,18 @@
"Online": "Online",
"Only_authorized_users_can_write_new_messages": "Only authorized users can write new messages",
"Oops": "Oops!",
+ "Open_in_provider": "Open in {{provider}}",
"Open_Livechats": "Omnichannel chats in progress",
"Open_servers_history": "Open servers history",
"Open_sidebar": "Open sidebar",
"Open_your_authentication_app_and_enter_the_code": "Open your authentication app and enter the code.",
+ "OpenStreetMap": "OpenStreetMap",
"OR": "OR",
"OS": "OS",
+ "OSM_API_Key": "OpenStreetMap API Key",
+ "OSM_API_Key_Instructions": "Get your OpenStreetMap API key from LocationIQ at locationiq.com",
+ "OSM_Attribution": "Β© OpenStreetMap contributors",
+ "Other_User": "Other User",
"Overwrites_the_server_configuration_and_use_room_config": "Overwrites the workspace configuration and use room config",
"Owner": "Owner",
"Parent_channel_or_group": "Parent channel or group",
@@ -626,6 +686,7 @@
"Passwords_do_not_match": "Passwords do not match",
"Pause": "Pause",
"Permalink_copied_to_clipboard": "Permalink copied to clipboard!",
+ "Permission_denied": "Permission denied",
"Person_or_channel": "Person or channel",
"Phone": "Phone",
"Pin": "Pin",
@@ -635,12 +696,14 @@
"Play": "Play",
"Playback_speed": "{{playbackSpeed}} playback speed",
"Please_add_a_comment": "Please add a comment",
+ "Please_configure_API_key_in_settings": "Please configure your API key in Location Preferences",
"Please_Enter_your_new_password": "Please enter your new password",
"Please_enter_your_password": "Please enter your password",
"Please_wait": "Please wait.",
"Preferences": "Preferences",
"Presence_Cap_Warning_Description": "Active connections have reached the limit for the workspace, thus the service that handles user status is disabled. It can be re-enabled manually in workspace settings.",
"Presence_Cap_Warning_Title": "User status temporarily disabled",
+ "Preview_location": "Preview location",
"Privacy_Policy": " Privacy policy",
"Private": "Private",
"Private_channel": "private channel",
@@ -791,9 +854,14 @@
"Settings": "Settings",
"Settings_succesfully_changed": "Settings succesfully changed!",
"Share": "Share",
+ "Share_current_location": "Current location",
"Share_Link": "Share link",
+ "Share_Location": "Share Location",
+ "Share_Location_Message": "π **{{location}}**\n\n[πΊοΈ {{openText}}]({{link}})",
"Share_this_app": "Share this app",
+ "Shared_By": "Shared by",
"Sharing": "Sharing",
+ "Sharing_Loading": "Sharingβ¦",
"Shortcut": "Shortcut",
"Show_badge_for_mentions": "Show badge for mentions",
"Show_badge_for_mentions_Info": "Display badge for direct mentions only",
@@ -817,12 +885,16 @@
"Sound": "Sound",
"Star": "Star",
"Starred": "Starred",
+ "Start": "Start",
"Start_a_call": "Start a call",
"Start_a_Discussion": "Start a discussion",
+ "Start_live_location": "Live location",
+ "Start_Sharing": "Start sharing",
"Started_call": "Call started by {{userBy}}",
"Started_discussion": "Started a discussion:",
"Status_saved_successfully": "Status saved successfully!",
"Status_text_limit_exceeded": "{{limit}} character limit exceeded",
+ "Stop_Sharing": "Stop sharing",
"Strikethrough": "Strikethrough",
"Supported_versions_expired_description": "An admin needs to update the workspace to a supported version in order to reenable access from mobile and desktop apps.",
"Supported_versions_expired_title": "{{workspace_name}} is running an unsupported version of Rocket.Chat",
@@ -834,6 +906,8 @@
"Take_a_photo": "Take a photo",
"Take_a_video": "Take a video",
"Take_it": "Take it!",
+ "Tap_to_view_live_location": "Tap to view live location",
+ "Tap_to_view_Updates_every_10s": "Tap to view. Updates every 10s",
"Team": "Team",
"Team_hint_encrypted": "End to end encrypted team. Search will not work with encrypted teams and notifications may not show the messages content.",
"Team_hint_encrypted_not_available": "Only available for private team",
@@ -903,6 +977,7 @@
"Unstar": "Unstar",
"Unsupported_format": "Unsupported format",
"Unsupported_system_message": "Unsupported system message",
+ "Updates_every_10_seconds": "Updates every 10 seconds",
"Updating": "Updating...",
"Upload_image": "Upload image",
"Upload_in_progress": "Upload in progress",
@@ -944,6 +1019,8 @@
"Video": "Video",
"video-conf-provider-not-configured-body": "A workspace admin needs to enable the conference calls feature first.",
"video-conf-provider-not-configured-header": "Conference call not enabled",
+ "View_Current_Session": "View current session",
+ "View_Live_Location": "View Live Location",
"View_Original": "View original",
"View_Thread": "View thread",
"Wait_activation_warning": "Before you can login, your account must be manually activated by an administrator.",
diff --git a/app/index.tsx b/app/index.tsx
index 3e87e3bcaa0..9a8cfe0f27e 100644
--- a/app/index.tsx
+++ b/app/index.tsx
@@ -77,8 +77,6 @@ const parseDeepLinking = (url: string) => {
}
}
}
-
- // Return null if the URL doesn't match or is not valid
return null;
};
diff --git a/app/lib/constants/keys.ts b/app/lib/constants/keys.ts
index e2206bb3a5b..776fbab3091 100644
--- a/app/lib/constants/keys.ts
+++ b/app/lib/constants/keys.ts
@@ -22,6 +22,10 @@ export const AUTOPLAY_GIFS_PREFERENCES_KEY = 'RC_AUTOPLAY_GIFS_PREFERENCES_KEY';
export const ALERT_DISPLAY_TYPE_PREFERENCES_KEY = 'RC_ALERT_DISPLAY_TYPE_PREFERENCES_KEY';
export const CRASH_REPORT_KEY = 'RC_CRASH_REPORT_KEY';
export const ANALYTICS_EVENTS_KEY = 'RC_ANALYTICS_EVENTS_KEY';
+export const MAP_PROVIDER_PREFERENCE_KEY = 'RC_MAP_PROVIDER_PREFERENCE_KEY';
+export const GOOGLE_MAPS_API_KEY_PREFERENCE_KEY = 'RC_GOOGLE_MAPS_API_KEY_PREFERENCE_KEY';
+export const OSM_API_KEY_PREFERENCE_KEY = 'RC_OSM_API_KEY_PREFERENCE_KEY';
+export const MAP_PROVIDER_DEFAULT = 'osm';
export const TOKEN_KEY = 'reactnativemeteor_usertoken';
export const CURRENT_SERVER = 'currentServer';
export const CERTIFICATE_KEY = 'RC_CERTIFICATE_KEY';
diff --git a/app/lib/methods/sendMessage.ts b/app/lib/methods/sendMessage.ts
index 7a4eaae40dd..0f622564201 100644
--- a/app/lib/methods/sendMessage.ts
+++ b/app/lib/methods/sendMessage.ts
@@ -6,6 +6,7 @@ import log from './helpers/log';
import { random } from './helpers';
import { Encryption } from '../encryption';
import { type E2EType, type IMessage, type IUser, type TMessageModel } from '../../definitions';
+import type { IAttachment } from '../../definitions/IAttachment';
import sdk from '../services/sdk';
import { E2E_MESSAGE_TYPE, E2E_STATUS } from '../constants/keys';
import { messagesStatus } from '../constants/messagesStatus';
@@ -48,7 +49,7 @@ const changeMessageStatus = async (id: string, status: number, tmid?: string, me
}
};
-async function sendMessageCall(message: any) {
+async function sendMessageCall(message: IMessage) {
const { _id, tmid } = message;
try {
// RC 0.60.0
@@ -90,7 +91,8 @@ export async function sendMessage(
msg: string,
tmid: string | undefined,
user: Partial>,
- tshow?: boolean
+ tshow?: boolean,
+ attachments?: IAttachment[]
): Promise {
try {
const db = database.active;
@@ -106,7 +108,8 @@ export async function sendMessage(
rid,
msg,
tmid,
- tshow
+ tshow,
+ attachments
} as IMessage);
const messageDate = new Date();
@@ -164,6 +167,7 @@ export async function sendMessage(
tm.ts = messageDate;
tm._updatedAt = messageDate;
tm.status = messagesStatus.TEMP;
+ tm.attachments = attachments as IAttachment[] | undefined;
tm.u = {
_id: user.id || '1',
username: user.username,
@@ -196,6 +200,7 @@ export async function sendMessage(
username: user.username,
name: user.name
};
+ m.attachments = attachments as IAttachment[] | undefined;
if (tmid && tMessageRecord) {
m.tmid = tmid;
// m.tlm = messageDate; // I don't think this is necessary... leaving it commented just in case...
diff --git a/app/lib/services/restApi.ts b/app/lib/services/restApi.ts
index e1f089c304a..9ebf4de03cb 100644
--- a/app/lib/services/restApi.ts
+++ b/app/lib/services/restApi.ts
@@ -1105,3 +1105,22 @@ export const getUsersRoles = async (): Promise => {
export const getSupportedVersionsCloud = (uniqueId?: string, domain?: string) =>
fetch(`https://releases.rocket.chat/v2/server/supportedVersions?uniqueId=${uniqueId}&domain=${domain}&source=mobile`);
+
+// Live Location API methods
+export const liveLocationStart = (rid: string, durationSec?: number, initial?: { lat: number; lon: number; acc?: number }) => {
+ const body: { rid: string; durationSec?: number; initial?: { lat: number; lon: number; acc?: number } } = { rid };
+ if (durationSec !== undefined) body.durationSec = durationSec;
+ if (initial !== undefined) body.initial = initial;
+ return sdk.post('liveLocation.start', body);
+};
+
+export const liveLocationUpdate = (rid: string, msgId: string, coords: { lat: number; lon: number; acc?: number }) =>
+ sdk.post('liveLocation.update', { rid, msgId, coords });
+
+export const liveLocationStop = (rid: string, msgId: string, finalCoords?: { lat: number; lon: number; acc?: number }) => {
+ const body: { rid: string; msgId: string; finalCoords?: { lat: number; lon: number; acc?: number } } = { rid, msgId };
+ if (finalCoords !== undefined) body.finalCoords = finalCoords;
+ return sdk.post('liveLocation.stop', body);
+};
+
+export const liveLocationGet = (rid: string, msgId: string) => sdk.get('liveLocation.get', { rid, msgId });
diff --git a/app/stacks/InsideStack.tsx b/app/stacks/InsideStack.tsx
index 5e19e649a15..b69336cee6a 100644
--- a/app/stacks/InsideStack.tsx
+++ b/app/stacks/InsideStack.tsx
@@ -3,6 +3,7 @@ import { I18nManager } from 'react-native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { createDrawerNavigator } from '@react-navigation/drawer';
+import I18n from '../i18n';
import { ThemeContext } from '../theme';
import { defaultHeader, themedHeader } from '../lib/methods/helpers/navigation';
import Sidebar from '../views/SidebarView';
@@ -75,6 +76,10 @@ import SelectListView from '../views/SelectListView';
import DiscussionsView from '../views/DiscussionsView';
import ChangeAvatarView from '../views/ChangeAvatarView';
import LegalView from '../views/LegalView';
+import LocationPreferencesView from '../views/LocationPreferencesView';
+import LocationPreviewModal from '../views/LocationShare/LocationPreviewModal';
+import LiveLocationPreviewModal from '../views/LocationShare/LiveLocationPreviewModal';
+import LiveLocationViewerModal from '../views/LocationShare/LiveLocationViewerModal';
import {
type AdminPanelStackParamList,
type ChatsStackParamList,
@@ -169,6 +174,7 @@ const ProfileStackNavigator = () => {
+
@@ -338,6 +344,21 @@ const InsideStackNavigator = () => {
{/* @ts-ignore */}
+
+
+
);
};
diff --git a/app/stacks/MasterDetailStack/index.tsx b/app/stacks/MasterDetailStack/index.tsx
index 88512884d15..daa7c3ead10 100644
--- a/app/stacks/MasterDetailStack/index.tsx
+++ b/app/stacks/MasterDetailStack/index.tsx
@@ -45,6 +45,7 @@ import AdminPanelView from '../../views/AdminPanelView';
import NewMessageView from '../../views/NewMessageView';
import CreateChannelView from '../../views/CreateChannelView';
import UserPreferencesView from '../../views/UserPreferencesView';
+import LocationPreferencesView from '../../views/LocationPreferencesView';
import UserNotificationPrefView from '../../views/UserNotificationPreferencesView';
import LegalView from '../../views/LegalView';
import SecurityPrivacyView from '../../views/SecurityPrivacyView';
@@ -195,6 +196,7 @@ const ModalStackNavigator = React.memo(({ navigation }: INavigation) => {
+
diff --git a/app/stacks/MasterDetailStack/types.ts b/app/stacks/MasterDetailStack/types.ts
index 602d7b5e3b6..3a10c03d3dd 100644
--- a/app/stacks/MasterDetailStack/types.ts
+++ b/app/stacks/MasterDetailStack/types.ts
@@ -193,6 +193,7 @@ export type ModalStackParamList = {
E2EEnterYourPasswordView: undefined;
UserPreferencesView: undefined;
UserNotificationPrefView: undefined;
+ LocationPreferencesView: undefined;
SecurityPrivacyView: undefined;
MediaAutoDownloadView: undefined;
E2EEncryptionSecurityView: undefined;
diff --git a/app/stacks/types.ts b/app/stacks/types.ts
index dfedeb49340..1aa665796d1 100644
--- a/app/stacks/types.ts
+++ b/app/stacks/types.ts
@@ -191,6 +191,7 @@ export type ProfileStackParamList = {
ProfileView: undefined;
UserPreferencesView: undefined;
UserNotificationPrefView: undefined;
+ LocationPreferencesView: undefined;
PushTroubleshootView: undefined;
ChangeAvatarView: {
context: TChangeAvatarViewContext;
@@ -294,6 +295,30 @@ export type InsideStackParamList = {
ModalBlockView: {
data: any; // TODO: Change;
};
+ LocationPreviewModal: {
+ rid: string;
+ tmid?: string;
+ provider: 'osm' | 'google';
+ coords: { latitude: number; longitude: number; accuracy?: number; timestamp?: number };
+ googleKey?: string;
+ };
+ LiveLocationPreviewModal: {
+ rid: string;
+ tmid?: string;
+ provider: 'osm' | 'google';
+ googleKey?: string;
+ osmKey?: string;
+ liveLocationId?: string;
+ ownerName?: string;
+ isTracking?: boolean;
+ };
+ LiveLocationViewerModal: {
+ rid: string;
+ msgId: string;
+ provider?: 'osm' | 'google';
+ googleKey?: string;
+ osmKey?: string;
+ };
};
export type OutsideParamList = {
diff --git a/app/views/LocationPreferencesView/ListPicker.tsx b/app/views/LocationPreferencesView/ListPicker.tsx
new file mode 100644
index 00000000000..b14323b8e2e
--- /dev/null
+++ b/app/views/LocationPreferencesView/ListPicker.tsx
@@ -0,0 +1,63 @@
+import React, { useMemo } from 'react';
+import { StyleSheet, Text } from 'react-native';
+
+import { useActionSheet } from '../../containers/ActionSheet';
+import type { TActionSheetOptionsItem } from '../../containers/ActionSheet';
+import { CustomIcon } from '../../containers/CustomIcon';
+import * as List from '../../containers/List';
+import I18n from '../../i18n';
+import { useTheme } from '../../theme';
+import sharedStyles from '../Styles';
+import type { MapProviderName } from '../LocationShare/services/mapProviders';
+
+const styles = StyleSheet.create({
+ title: { ...sharedStyles.textRegular, fontSize: 16 }
+});
+
+const OPTIONS: { label: string; value: MapProviderName }[] = [
+ {
+ label: 'OpenStreetMap',
+ value: 'osm'
+ },
+ {
+ label: 'Google_Maps',
+ value: 'google'
+ }
+];
+
+interface IListPickerProps {
+ title: string;
+ value: MapProviderName;
+ onChangeValue: (value: MapProviderName) => void;
+ testID?: string;
+}
+
+const ListPicker = ({ title, value, onChangeValue, testID }: IListPickerProps) => {
+ const { showActionSheet, hideActionSheet } = useActionSheet();
+ const { colors } = useTheme();
+
+ const option = useMemo(() => OPTIONS.find(item => item.value === value) || OPTIONS[0], [value]);
+
+ const getOptions = (): TActionSheetOptionsItem[] =>
+ OPTIONS.map(i => ({
+ title: I18n.t(i.label, { defaultValue: i.label }),
+ onPress: () => {
+ hideActionSheet();
+ onChangeValue(i.value);
+ },
+ right: option?.value === i.value ? () => : undefined
+ }));
+
+ const label = option?.label ? I18n.t(option?.label, { defaultValue: option?.label }) : option?.label;
+
+ return (
+ showActionSheet({ options: getOptions() })}
+ right={() => {label}}
+ />
+ );
+};
+
+export default ListPicker;
diff --git a/app/views/LocationPreferencesView/index.tsx b/app/views/LocationPreferencesView/index.tsx
new file mode 100644
index 00000000000..64731ee4b2f
--- /dev/null
+++ b/app/views/LocationPreferencesView/index.tsx
@@ -0,0 +1,55 @@
+import React, { useLayoutEffect } from 'react';
+import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
+import type { CompositeNavigationProp } from '@react-navigation/native';
+import { useNavigation } from '@react-navigation/native';
+
+import * as List from '../../containers/List';
+import I18n from '../../i18n';
+import SafeAreaView from '../../containers/SafeAreaView';
+import type { ProfileStackParamList } from '../../stacks/types';
+import type { MasterDetailInsideStackParamList } from '../../stacks/MasterDetailStack/types';
+import { useUserPreferences } from '../../lib/methods/userPreferences';
+import { MAP_PROVIDER_PREFERENCE_KEY, MAP_PROVIDER_DEFAULT } from '../../lib/constants/keys';
+import type { MapProviderName } from '../LocationShare/services/mapProviders';
+import ListPicker from './ListPicker';
+import { useAppSelector } from '../../lib/hooks/useAppSelector';
+
+type TNavigation = CompositeNavigationProp<
+ NativeStackNavigationProp,
+ NativeStackNavigationProp
+>;
+
+const LocationPreferencesView = () => {
+ const navigation = useNavigation();
+ const userId = useAppSelector(state => state.login.user.id);
+
+ const [mapProvider, setMapProvider] = useUserPreferences(
+ `${MAP_PROVIDER_PREFERENCE_KEY}_${userId}`,
+ MAP_PROVIDER_DEFAULT
+ );
+
+ useLayoutEffect(() => {
+ navigation.setOptions({
+ title: I18n.t('Location_Preferences')
+ });
+ }, [navigation]);
+
+ const onProviderChange = (provider: MapProviderName) => {
+ setMapProvider(provider);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default LocationPreferencesView;
diff --git a/app/views/LocationShare/LiveLocationPreviewModal.tsx b/app/views/LocationShare/LiveLocationPreviewModal.tsx
new file mode 100644
index 00000000000..1f0916cf552
--- /dev/null
+++ b/app/views/LocationShare/LiveLocationPreviewModal.tsx
@@ -0,0 +1,791 @@
+import React, { useState, useEffect, useRef, useMemo } from 'react';
+import { View, Text, TouchableOpacity, StyleSheet, ActivityIndicator, Alert, Linking, InteractionManager } from 'react-native';
+import { Image as ExpoImage, type ImageErrorEventData } from 'expo-image';
+import * as Location from 'expo-location';
+import { shallowEqual } from 'react-redux';
+import { useNavigation } from '@react-navigation/native';
+
+import I18n from '../../i18n';
+import Navigation from '../../lib/navigation/appNavigation';
+import { useAppSelector } from '../../lib/hooks/useAppSelector';
+import { getUserSelector } from '../../selectors/login';
+import { useTheme } from '../../theme';
+import { staticMapUrl, providerLabel, mapsDeepLink, providerAttribution } from './services/mapProviders';
+import type { MapProviderName } from './services/mapProviders';
+import { LiveLocationTracker } from './services/liveLocation';
+import type { LiveLocationState } from './services/liveLocation';
+import { LiveLocationApi, serverToMobileCoords } from './services/liveLocationApi';
+import {
+ markLiveLocationAsEnded,
+ isLiveLocationEnded,
+ addLiveLocationEndedListener,
+ removeLiveLocationEndedListener
+} from './services/handleLiveLocationUrl';
+
+type RouteParams = {
+ rid: string;
+ tmid?: string;
+ provider: MapProviderName;
+ liveLocationId?: string;
+ ownerName?: string;
+ isTracking?: boolean;
+};
+
+let globalTracker: LiveLocationTracker | null = null;
+let globalTrackerParams: {
+ rid?: string;
+ tmid?: string;
+ provider: MapProviderName;
+ liveLocationId?: string;
+ ownerName?: string;
+ isTracking?: boolean;
+ userId?: string;
+ username?: string;
+} | null = null;
+
+const globalLocationUpdateCallbacks = new Set<(state: LiveLocationState) => void>();
+const statusChangeListeners = new Set<(isActive: boolean) => void>();
+
+let isModalMinimized = false;
+const minimizedStatusListeners = new Set<(isMinimized: boolean) => void>();
+
+const OSM_HEADERS = {
+ 'User-Agent': 'RocketChatMobile/1.0 (+https://rocket.chat) contact: mobile@rocket.chat',
+ Referer: 'https://rocket.chat'
+};
+
+export function getCurrentLiveParams() {
+ return globalTrackerParams;
+}
+export function isLiveLocationMinimized(): boolean {
+ return isModalMinimized && isLiveLocationActive();
+}
+
+export function addMinimizedStatusListener(listener: (isMinimized: boolean) => void) {
+ minimizedStatusListeners.add(listener);
+}
+
+export function removeMinimizedStatusListener(listener: (isMinimized: boolean) => void) {
+ minimizedStatusListeners.delete(listener);
+}
+
+function emitMinimizedStatusChange(minimized?: boolean) {
+ const value = typeof minimized === 'boolean' ? minimized : isLiveLocationMinimized();
+ minimizedStatusListeners.forEach(fn => {
+ try {
+ fn(value);
+ } catch (e) {
+ // Error in minimized status listener
+ }
+ });
+}
+export function addStatusChangeListener(listener: (isActive: boolean) => void) {
+ statusChangeListeners.add(listener);
+}
+export function removeStatusChangeListener(listener: (isActive: boolean) => void) {
+ statusChangeListeners.delete(listener);
+}
+function emitStatusChange(active?: boolean) {
+ const value = typeof active === 'boolean' ? active : isLiveLocationActive();
+ statusChangeListeners.forEach(fn => {
+ try {
+ fn(value);
+ } catch (e) {
+ // Ignore listener errors
+ }
+ });
+}
+
+const styles = StyleSheet.create({
+ container: { flex: 1, padding: 20, justifyContent: 'center' },
+ content: {
+ borderRadius: 16,
+ padding: 20,
+ shadowOpacity: 0.15,
+ shadowRadius: 12,
+ shadowOffset: { width: 0, height: 6 },
+ elevation: 8,
+ borderWidth: 1
+ },
+ header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16, paddingHorizontal: 4 },
+ titleContainer: { flex: 1, alignItems: 'center' },
+ title: { fontSize: 20, fontWeight: '700', textAlign: 'center' },
+ ownerName: { fontSize: 14, marginTop: 4, fontWeight: '500' },
+ minimizeButton: {
+ width: 40,
+ height: 40,
+ borderRadius: 20,
+ justifyContent: 'center',
+ alignItems: 'center',
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.1,
+ shadowRadius: 4,
+ elevation: 3,
+ borderWidth: 1
+ },
+ minimizeIcon: { width: 20, height: 20, justifyContent: 'center', alignItems: 'center' },
+ minimizeLine: { width: 14, height: 2, borderRadius: 1 },
+ statusContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginBottom: 16,
+ paddingVertical: 8,
+ paddingHorizontal: 16,
+ borderRadius: 20,
+ borderWidth: 1
+ },
+ statusDot: { width: 8, height: 8, borderRadius: 4, marginRight: 8 },
+ statusText: { fontSize: 14, fontWeight: '600' },
+ infoContainer: {
+ marginBottom: 20,
+ alignItems: 'center',
+ padding: 16,
+ borderRadius: 12,
+ borderWidth: 1
+ },
+ coordsLine: { fontSize: 15, fontWeight: '600', textAlign: 'center', marginBottom: 6 },
+ timestamp: { fontSize: 12, textAlign: 'center', fontWeight: '500' },
+ mapLinkText: {
+ fontSize: 16,
+ fontWeight: '700',
+ textAlign: 'center',
+ marginBottom: 16,
+ paddingVertical: 8
+ },
+ mapContainer: {
+ borderRadius: 12,
+ overflow: 'hidden',
+ marginBottom: 16,
+ shadowOffset: { width: 0, height: 4 },
+ shadowOpacity: 0.1,
+ shadowRadius: 8,
+ elevation: 4,
+ borderWidth: 1
+ },
+ mapImage: { width: '100%', height: 220 },
+ mapPlaceholder: { width: '100%', height: 220, justifyContent: 'center', alignItems: 'center' },
+ loadingText: { marginTop: 12, fontSize: 14, fontWeight: '500' },
+ attribution: { fontSize: 10, textAlign: 'center', marginBottom: 12 },
+ pinOverlay: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, alignItems: 'center', justifyContent: 'center' },
+ pinText: { fontSize: 24 },
+ liveIndicator: {
+ fontSize: 13,
+ textAlign: 'center',
+ marginBottom: 20,
+ fontStyle: 'italic',
+ fontWeight: '600'
+ },
+ buttons: { flexDirection: 'row', gap: 16, marginTop: 8 },
+ btn: {
+ flex: 1,
+ paddingVertical: 16,
+ borderRadius: 12,
+ alignItems: 'center',
+ borderWidth: 2,
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.05,
+ shadowRadius: 4,
+ elevation: 2
+ },
+ btnPrimary: {},
+ btnDanger: {},
+ btnText: { fontWeight: '700', fontSize: 16 },
+ btnTextPrimary: {},
+ btnTextDanger: {}
+});
+
+export default function LiveLocationPreviewModal({ route }: { route: { params: RouteParams } }) {
+ const { rid, tmid, provider = 'google', liveLocationId, ownerName, isTracking = false } = route.params;
+ const navigation = useNavigation();
+ const { colors } = useTheme();
+ const [submitting, setSubmitting] = useState(false);
+ const [locationState, setLocationState] = useState(null);
+ const [mapImageUrl, setMapImageUrl] = useState('');
+ const [isShared, setIsShared] = useState(isTracking);
+ const [currentOwnerName, setCurrentOwnerName] = useState(ownerName);
+ const trackerRef = useRef(null);
+ const viewerUpdateIntervalRef = useRef | null>(null);
+ const statusEmitCallback = useRef<(state: LiveLocationState) => void>(() => {
+ emitStatusChange();
+ });
+ const isMinimizingRef = useRef(false);
+
+ useEffect(() => {
+ isModalMinimized = false;
+ emitMinimizedStatusChange(false);
+ }, []);
+
+ useEffect(() => {
+ const unsubscribe = navigation.addListener('beforeRemove', e => {
+ if (isMinimizingRef.current) {
+ return;
+ }
+
+ const globalTrackerActive = globalTracker !== null && globalTracker.getCurrentState()?.isActive === true;
+
+ const shouldMinimize = globalTrackerActive || (!isTracking && liveLocationId && locationState?.isActive);
+
+ if (shouldMinimize) {
+ e.preventDefault();
+ isMinimizingRef.current = true;
+ onMinimize();
+ }
+ });
+
+ return () => {
+ unsubscribe();
+ };
+ }, [navigation, isShared, isTracking, liveLocationId, locationState?.isActive]);
+
+ const mounted = useRef(true);
+ useEffect(() => {
+ isMinimizingRef.current = false;
+
+ return () => {
+ mounted.current = false;
+ isMinimizingRef.current = false;
+ globalLocationUpdateCallbacks.delete(handleLocationUpdate);
+ globalLocationUpdateCallbacks.delete(statusEmitCallback.current);
+ if (viewerUpdateIntervalRef.current) {
+ clearInterval(viewerUpdateIntervalRef.current);
+ viewerUpdateIntervalRef.current = null;
+ }
+ };
+ }, []);
+ const safeSet = React.useCallback((fn: () => void) => {
+ if (mounted.current) fn();
+ }, []);
+
+ useEffect(() => {
+ if (mapImageUrl) {
+ ExpoImage.prefetch(mapImageUrl).catch(() => {});
+ }
+ }, [mapImageUrl]);
+
+ const cacheKey = useMemo(() => {
+ const lat = locationState?.coords?.latitude;
+ const lon = locationState?.coords?.longitude;
+ return lat && lon ? `osm-${lat.toFixed(5)}-${lon.toFixed(5)}-z15-v2` : undefined;
+ }, [locationState?.coords?.latitude, locationState?.coords?.longitude]);
+
+ const { id, username } = useAppSelector(getUserSelector, shallowEqual);
+
+ const handleLocationUpdate = React.useCallback(
+ (state: LiveLocationState) => {
+ safeSet(() => setLocationState(state));
+
+ if (state.coords) {
+ const { url } = staticMapUrl('osm', state.coords, { zoom: 15 });
+ safeSet(() => setMapImageUrl(url));
+ }
+
+ emitStatusChange();
+ },
+ [safeSet]
+ );
+
+ useEffect(() => {
+ if (!isTracking && liveLocationId) {
+ isLiveLocationEnded(liveLocationId).then(ended => {
+ if (ended) {
+ Navigation.back();
+ return;
+ }
+
+ const poll = async () => {
+ try {
+ const data = await LiveLocationApi.get(rid, liveLocationId);
+ if (!data?.isActive || data?.stoppedAt) {
+ try {
+ await markLiveLocationAsEnded(liveLocationId);
+ } catch {
+ // Ignore cleanup errors
+ }
+ Navigation.back();
+ return;
+ }
+ safeSet(() => setCurrentOwnerName(prev => prev || data.ownerName || data.ownerUsername));
+
+ const mobile = serverToMobileCoords(data.coords);
+ const ts = data.lastUpdateAt ? new Date(data.lastUpdateAt).getTime() : Date.now();
+ const next: LiveLocationState = {
+ coords: {
+ latitude: mobile.latitude,
+ longitude: mobile.longitude,
+ accuracy: mobile.accuracy
+ },
+ timestamp: ts,
+ isActive: true
+ };
+ handleLocationUpdate(next);
+ } catch (_e) {
+ // Ignore transient failures; keep last good state
+ }
+ };
+ poll();
+ viewerUpdateIntervalRef.current = setInterval(poll, 10_000);
+ });
+
+ const handleLiveLocationEnded = (endedLocationId: string) => {
+ if (endedLocationId === liveLocationId) {
+ Navigation.back();
+ }
+ };
+ addLiveLocationEndedListener(handleLiveLocationEnded);
+
+ return () => {
+ removeLiveLocationEndedListener(handleLiveLocationEnded);
+ if (viewerUpdateIntervalRef.current) {
+ clearInterval(viewerUpdateIntervalRef.current);
+ viewerUpdateIntervalRef.current = null;
+ }
+ };
+ }
+
+ if (globalTracker && isTracking) {
+ trackerRef.current = globalTracker;
+ globalLocationUpdateCallbacks.add(handleLocationUpdate);
+
+ const currentState = globalTracker.getCurrentState();
+ if (currentState) handleLocationUpdate(currentState);
+ } else if (isTracking) {
+ const tracker = new LiveLocationTracker(
+ rid,
+ tmid,
+ { id, username },
+ (state: LiveLocationState) => {
+ globalLocationUpdateCallbacks.forEach(callback => {
+ if (callback) callback(state);
+ });
+ },
+ undefined,
+ provider
+ );
+
+ trackerRef.current = tracker;
+ globalLocationUpdateCallbacks.add(handleLocationUpdate);
+
+ tracker.startTracking().catch(error => {
+ Alert.alert(I18n.t('Error'), error.message || I18n.t('Could_not_get_location'));
+ });
+ } else {
+ Location.getCurrentPositionAsync({
+ accuracy: Location.Accuracy.High
+ })
+ .then((location: Location.LocationObject) => {
+ const previewState: LiveLocationState = {
+ coords: {
+ latitude: location.coords.latitude,
+ longitude: location.coords.longitude,
+ accuracy: location.coords.accuracy ?? undefined
+ },
+ timestamp: Date.now(),
+ isActive: false
+ };
+ handleLocationUpdate(previewState);
+ })
+ .catch((_error: unknown) => {
+ Alert.alert(I18n.t('Error'), I18n.t('Could_not_get_location'));
+ });
+ }
+ }, [isTracking, liveLocationId, handleLocationUpdate, id, rid, tmid, username]);
+
+ const openInMaps = async () => {
+ if (!locationState?.coords) return;
+ try {
+ const deep = await mapsDeepLink(provider, locationState.coords);
+ await Linking.openURL(deep);
+ } catch (error) {
+ Alert.alert(I18n.t('error-open-maps-application'));
+ }
+ };
+
+ const onCancel = () => {
+ if (trackerRef.current && (isShared || isTracking)) {
+ onMinimize();
+ return;
+ }
+
+ if (trackerRef.current) {
+ trackerRef.current.stopTracking();
+ globalTracker = null;
+ globalTrackerParams = null;
+ globalLocationUpdateCallbacks.clear();
+ emitStatusChange(false);
+ }
+ Navigation.back();
+ };
+
+ const onShare = async () => {
+ if (!locationState?.coords) {
+ Alert.alert(I18n.t('Error'), I18n.t('Location_not_available'));
+ return;
+ }
+
+ if (globalTracker && globalTracker.getCurrentState()?.isActive) {
+ Alert.alert(I18n.t('Live_Location_Active'), I18n.t('Live_Location_Active_Block_Message'), [
+ { text: I18n.t('View_Current_Session'), onPress: () => reopenLiveLocationModal() },
+ { text: I18n.t('Cancel'), style: 'cancel' }
+ ]);
+ return;
+ }
+
+ try {
+ safeSet(() => setSubmitting(true));
+
+ if (!trackerRef.current) {
+ const tracker = new LiveLocationTracker(
+ rid,
+ tmid,
+ { id, username },
+ (state: LiveLocationState) => {
+ globalLocationUpdateCallbacks.forEach(callback => {
+ if (callback) callback(state);
+ });
+ },
+ undefined,
+ provider
+ );
+
+ trackerRef.current = tracker;
+ globalLocationUpdateCallbacks.add(handleLocationUpdate);
+
+ await tracker.startTracking();
+ }
+
+ if (trackerRef.current) {
+ const msgId = trackerRef.current.getMsgId();
+ if (!msgId) {
+ Alert.alert(I18n.t('Error'), I18n.t('Live_Location_Start_Error'));
+ return;
+ }
+
+ globalTracker = trackerRef.current;
+ globalTrackerParams = {
+ rid,
+ tmid,
+ provider,
+ liveLocationId: msgId,
+ ownerName: username || 'You',
+ isTracking: true,
+ userId: id,
+ username
+ };
+ emitStatusChange(true);
+ }
+
+ safeSet(() => {
+ setIsShared(true);
+ setCurrentOwnerName(username || 'You');
+ });
+ } catch (e) {
+ Alert.alert(I18n.t('Oops'), (e as Error)?.message || I18n.t('Could_not_send_message'));
+ } finally {
+ safeSet(() => setSubmitting(false));
+ }
+ };
+
+ const onMinimize = () => {
+ globalLocationUpdateCallbacks.delete(handleLocationUpdate);
+ globalLocationUpdateCallbacks.delete(statusEmitCallback.current);
+ globalLocationUpdateCallbacks.add(statusEmitCallback.current);
+
+ isModalMinimized = true;
+ emitMinimizedStatusChange(true);
+
+ Navigation.back();
+ };
+
+ const onStopSharing = async () => {
+ if (!isOwner()) {
+ Navigation.back();
+ return;
+ }
+
+ if (trackerRef.current) {
+ const msgId = trackerRef.current.getMsgId();
+
+ try {
+ await trackerRef.current.stopTracking();
+ } catch (error) {
+ // Ignore stop errors
+ }
+
+ if (msgId) {
+ try {
+ await markLiveLocationAsEnded(msgId);
+ } catch (e) {
+ // Ignore cleanup errors
+ }
+ }
+
+ emitStatusChange(false);
+
+ globalTracker = null;
+ globalTrackerParams = null;
+ globalLocationUpdateCallbacks.clear();
+
+ isModalMinimized = false;
+ emitMinimizedStatusChange(false);
+ }
+
+ safeSet(() => setIsShared(false));
+ Navigation.back();
+ };
+
+ const formatTimestamp = (timestamp: number) => {
+ if (!timestamp || isNaN(timestamp)) {
+ return 'Invalid Date';
+ }
+ return new Date(timestamp).toLocaleTimeString();
+ };
+ const isOwner = () => isTracking || (currentOwnerName && username ? currentOwnerName === username : Boolean(isShared));
+
+ const renderActionButtons = () => {
+ const shouldShowActiveButtons = isShared || isTracking || (!isTracking && liveLocationId);
+
+ if (!shouldShowActiveButtons) {
+ return (
+ <>
+
+ {I18n.t('Cancel')}
+
+
+ {submitting ? (
+
+ ) : (
+ {I18n.t('Start')}
+ )}
+
+ >
+ );
+ }
+
+ if (isOwner() && !(!isTracking && liveLocationId)) {
+ return (
+
+ {I18n.t('Stop_Sharing')}
+
+ );
+ }
+
+ return (
+
+ {I18n.t('Close')}
+
+ );
+ };
+
+ return (
+
+
+
+
+ π {I18n.t('Live_Location')}
+ {currentOwnerName && (
+
+ {I18n.t('Shared_By')} {currentOwnerName}
+
+ )}
+
+ {(isShared || isTracking) && isOwner() && (
+
+
+
+
+
+ )}
+
+
+
+
+
+ {(isShared || isTracking || (!isTracking && liveLocationId)) && locationState?.isActive
+ ? I18n.t('Live_Location_Active')
+ : I18n.t('Live_Location_Inactive')}
+
+
+
+ {locationState?.coords && (
+
+
+ {locationState.coords.latitude.toFixed(5)}, {locationState.coords.longitude.toFixed(5)}
+ {locationState.coords.accuracy ? ` (Β±${Math.round(locationState.coords.accuracy)}m)` : ''}
+
+
+ {I18n.t('Last_updated_at')} {formatTimestamp(locationState.timestamp)}
+
+
+ )}
+
+
+
+ πΊοΈ Open in {providerLabel(provider)}
+
+
+
+
+ {mapImageUrl ? (
+ <>
+ {
+ safeSet(() => setMapImageUrl(''));
+ }}
+ />
+
+ π
+
+ >
+ ) : (
+
+
+ π {I18n.t('Map_Preview')}
+
+ {locationState?.coords && (
+
+ {locationState.coords.latitude.toFixed(5)}, {locationState.coords.longitude.toFixed(5)}
+
+ )}
+
+ {I18n.t('Map_preview_unavailable_open_below')}
+
+
+ )}
+
+
+ {providerAttribution('osm')}
+
+ {(isShared || isTracking || (!isTracking && liveLocationId)) && (
+
+ π΄ {I18n.t('Updates_every_10_seconds')}
+
+ )}
+
+ {renderActionButtons()}
+
+
+ );
+}
+
+export function isLiveLocationActive(): boolean {
+ return globalTracker !== null && globalTracker.getCurrentState()?.isActive === true;
+}
+
+export function reopenLiveLocationModal() {
+ if (!globalTracker || !globalTrackerParams) return;
+
+ isModalMinimized = false;
+ emitMinimizedStatusChange(false);
+
+ InteractionManager.runAfterInteractions(() => {
+ Navigation.navigate('LiveLocationPreviewModal', {
+ ...globalTrackerParams,
+ isTracking: true
+ });
+ });
+}
+
+export async function stopGlobalLiveLocation() {
+ if (!globalTracker) {
+ emitStatusChange(false);
+ return;
+ }
+
+ const params = globalTrackerParams;
+ try {
+ await globalTracker.stopTracking();
+
+ if (params?.liveLocationId) {
+ try {
+ await markLiveLocationAsEnded(params.liveLocationId);
+ } catch (e) {
+ // Ignore cleanup errors
+ }
+ }
+ } finally {
+ globalTracker = null;
+ globalTrackerParams = null;
+ globalLocationUpdateCallbacks.clear();
+
+ isModalMinimized = false;
+ emitMinimizedStatusChange(false);
+ emitStatusChange(false);
+ }
+}
+
+const BLURHASH_PLACEHOLDER = 'LKO2?U%2Tw=w]~RBVZRi};RPxuwH';
diff --git a/app/views/LocationShare/LiveLocationStatusBar.tsx b/app/views/LocationShare/LiveLocationStatusBar.tsx
new file mode 100644
index 00000000000..9ee73f9f371
--- /dev/null
+++ b/app/views/LocationShare/LiveLocationStatusBar.tsx
@@ -0,0 +1,165 @@
+import React, { useState, useEffect, useRef, useMemo } from 'react';
+import { View, Text, TouchableOpacity, StyleSheet, InteractionManager, Linking } from 'react-native';
+import Animated, { useSharedValue, useAnimatedStyle, withRepeat, withTiming, Easing } from 'react-native-reanimated';
+
+import {
+ reopenLiveLocationModal,
+ stopGlobalLiveLocation,
+ isLiveLocationMinimized,
+ addMinimizedStatusListener,
+ removeMinimizedStatusListener,
+ getCurrentLiveParams
+} from './LiveLocationPreviewModal';
+import { handleLiveLocationUrl, isLiveMessageLink } from './services/handleLiveLocationUrl';
+import { useAppSelector } from '../../lib/hooks/useAppSelector';
+import { getUserSelector } from '../../selectors/login';
+import I18n from '../../i18n';
+import { useTheme, type TColors } from '../../theme';
+
+type Props = { onPress?: () => void };
+
+export default function LiveLocationStatusBar({ onPress }: Props) {
+ const [isActive, setIsActive] = useState(false);
+ const pulseAnim = useSharedValue(1);
+ const username = useAppSelector(state => getUserSelector(state).username);
+ const { colors } = useTheme();
+
+ const styles = useMemo(() => createStyles(colors), [colors]);
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ transform: [{ scale: pulseAnim.value }]
+ }));
+
+ // mounted guard
+ const mounted = useRef(true);
+ useEffect(
+ () => () => {
+ mounted.current = false;
+ },
+ []
+ );
+ const safeSet = (fn: () => void) => {
+ if (mounted.current) fn();
+ };
+
+ // subscribe to global minimized status
+ useEffect(() => {
+ safeSet(() => setIsActive(isLiveLocationMinimized()));
+ const handleStatusChange = (minimized: boolean) => safeSet(() => setIsActive(minimized));
+ addMinimizedStatusListener(handleStatusChange);
+ return () => removeMinimizedStatusListener(handleStatusChange);
+ }, []);
+
+ useEffect(() => {
+ const sub = Linking.addEventListener('url', ({ url }) => {
+ if (isLiveMessageLink(url)) {
+ handleLiveLocationUrl(url);
+ }
+ });
+ // also handle cold-start by link
+ Linking.getInitialURL().then(url => {
+ if (url && isLiveMessageLink(url)) {
+ handleLiveLocationUrl(url);
+ }
+ });
+ return () => sub.remove();
+ }, []);
+
+ // pulse animation
+ useEffect(() => {
+ if (isActive) {
+ pulseAnim.value = withRepeat(withTiming(1.3, { duration: 1000, easing: Easing.inOut(Easing.ease) }), -1, true);
+ } else {
+ pulseAnim.value = withTiming(1, { duration: 300 });
+ }
+ }, [isActive, pulseAnim]);
+
+ const handlePress = () => {
+ if (onPress) onPress();
+ else InteractionManager.runAfterInteractions(reopenLiveLocationModal);
+ };
+
+ const stoppingRef = useRef(false);
+ const handleStop = async () => {
+ if (stoppingRef.current) return;
+ stoppingRef.current = true;
+
+ const params = getCurrentLiveParams();
+ const currentUserIsOwner = params?.ownerName && username ? params.ownerName === username : !!params?.isTracking;
+
+ if (currentUserIsOwner) {
+ try {
+ await stopGlobalLiveLocation(); // sends βEndedβ and clears globals
+ } finally {
+ safeSet(() => setIsActive(false));
+ stoppingRef.current = false;
+ }
+ } else {
+ safeSet(() => setIsActive(false));
+ stoppingRef.current = false;
+ }
+ };
+
+ if (!isActive) return null;
+
+ return (
+
+
+
+ π
+
+
+ {I18n.t('Live_Location_Active')}
+ {I18n.t('Tap_to_view_Updates_every_10s')}
+
+
+
+ β
+
+
+ );
+}
+
+/* eslint-disable react-native/no-unused-styles */
+const createStyles = (colors: TColors) =>
+ StyleSheet.create({
+ container: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: colors.buttonBackgroundDangerDefault,
+ paddingHorizontal: 16,
+ paddingVertical: 12,
+ shadowColor: colors.fontDefault,
+ shadowOpacity: 0.15,
+ shadowRadius: 4,
+ shadowOffset: { width: 0, height: 2 },
+ elevation: 5,
+ zIndex: 1000
+ },
+ statusBar: { flex: 1, flexDirection: 'row', alignItems: 'center' },
+ iconContainer: { marginRight: 12 },
+ icon: { fontSize: 18 },
+ textContainer: { flex: 1 },
+ title: { color: colors.fontWhite, fontSize: 15, fontWeight: '600', marginBottom: 2 },
+ subtitle: { color: colors.fontWhite, fontSize: 12, opacity: 0.8 },
+ stopButton: {
+ width: 28,
+ height: 28,
+ borderRadius: 14,
+ backgroundColor: colors.fontWhite, // theme token
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginLeft: 12,
+ borderWidth: 1,
+ borderColor: colors.strokeLight || colors.surfaceTint
+ },
+ stopText: {
+ color: colors.buttonBackgroundDangerDefault,
+ fontSize: 16,
+ fontWeight: '700'
+ }
+ });
diff --git a/app/views/LocationShare/LiveLocationViewerModal.tsx b/app/views/LocationShare/LiveLocationViewerModal.tsx
new file mode 100644
index 00000000000..a4fd20ac6a6
--- /dev/null
+++ b/app/views/LocationShare/LiveLocationViewerModal.tsx
@@ -0,0 +1,548 @@
+import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
+import { View, StyleSheet, Alert, ActivityIndicator, Text, TouchableOpacity, Linking } from 'react-native';
+import { Image as ExpoImage, type ImageErrorEventData } from 'expo-image';
+
+import I18n from '../../i18n';
+import SafeAreaView from '../../containers/SafeAreaView';
+import StatusBar from '../../containers/StatusBar';
+import Navigation from '../../lib/navigation/appNavigation';
+import { useTheme } from '../../theme';
+import { LiveLocationApi, serverToMobileCoords } from './services/liveLocationApi';
+import { addLiveLocationEndedListener, removeLiveLocationEndedListener } from './services/handleLiveLocationUrl';
+import { staticMapUrl, providerLabel, mapsDeepLink, providerAttribution } from './services/mapProviders';
+import type { MapProviderName } from './services/mapProviders';
+import { useAppSelector } from '../../lib/hooks/useAppSelector';
+import { useUserPreferences } from '../../lib/methods/userPreferences';
+import { MAP_PROVIDER_PREFERENCE_KEY, MAP_PROVIDER_DEFAULT } from '../../lib/constants/keys';
+
+export interface LiveLocationViewerModalProps {
+ route: {
+ params: {
+ rid: string;
+ msgId: string;
+ provider?: 'osm' | 'google';
+ };
+ };
+}
+
+interface LiveLocationData {
+ messageId: string;
+ ownerId: string;
+ ownerUsername: string;
+ ownerName: string;
+ isActive: boolean;
+ startedAt?: number;
+ lastUpdateAt?: number;
+ stoppedAt?: number;
+ coords: {
+ latitude: number;
+ longitude: number;
+ accuracy?: number;
+ };
+ expiresAt?: number;
+ version: number;
+}
+
+const BLURHASH_PLACEHOLDER = 'LKO2?U%2Tw=w]~RBVZRi};RPxuwH';
+
+const OSM_HEADERS = {
+ 'User-Agent': 'RocketChatMobile/1.0 (+https://rocket.chat) contact: mobile@rocket.chat',
+ Referer: 'https://rocket.chat'
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ paddingHorizontal: 20
+ },
+ content: {
+ width: '100%',
+ maxWidth: 400,
+ borderRadius: 20,
+ padding: 24,
+ shadowOpacity: 0.15,
+ shadowRadius: 12,
+ shadowOffset: { width: 0, height: 6 },
+ elevation: 8,
+ borderWidth: 1
+ },
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ marginBottom: 16,
+ paddingHorizontal: 4
+ },
+ titleContainer: {
+ flex: 1,
+ alignItems: 'center'
+ },
+ title: {
+ fontSize: 20,
+ fontWeight: '700',
+ textAlign: 'center'
+ },
+ ownerName: {
+ fontSize: 14,
+ marginTop: 4,
+ fontWeight: '500'
+ },
+ statusContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginBottom: 16,
+ paddingVertical: 8,
+ paddingHorizontal: 16,
+ borderRadius: 20,
+ borderWidth: 1
+ },
+ statusDot: {
+ width: 8,
+ height: 8,
+ borderRadius: 4,
+ marginRight: 8
+ },
+ statusText: {
+ fontSize: 14,
+ fontWeight: '600'
+ },
+ infoContainer: {
+ marginBottom: 20,
+ alignItems: 'center',
+ padding: 16,
+ borderRadius: 12,
+ borderWidth: 1
+ },
+ coordsLine: {
+ fontSize: 15,
+ fontWeight: '600',
+ textAlign: 'center',
+ marginBottom: 6
+ },
+ timestamp: {
+ fontSize: 12,
+ textAlign: 'center',
+ fontWeight: '500'
+ },
+ mapLinkText: {
+ fontSize: 16,
+ fontWeight: '700',
+ textAlign: 'center',
+ marginBottom: 16,
+ paddingVertical: 8
+ },
+
+ mapContainer: {
+ borderRadius: 12,
+ overflow: 'hidden',
+ marginBottom: 16,
+ shadowOffset: { width: 0, height: 4 },
+ shadowOpacity: 0.1,
+ shadowRadius: 8,
+ elevation: 4,
+ borderWidth: 1
+ },
+ mapImage: {
+ width: '100%',
+ height: 220
+ },
+ mapPlaceholder: {
+ width: '100%',
+ height: 220,
+ justifyContent: 'center',
+ alignItems: 'center'
+ },
+ loadingText: {
+ marginTop: 12,
+ fontSize: 14,
+ fontWeight: '500'
+ },
+ attribution: {
+ fontSize: 10,
+ textAlign: 'center',
+ marginBottom: 12
+ },
+ pinOverlay: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ pinText: { fontSize: 24 },
+ liveIndicator: {
+ fontSize: 13,
+ textAlign: 'center',
+ marginBottom: 20,
+ fontStyle: 'italic',
+ fontWeight: '600'
+ },
+ buttons: {
+ flexDirection: 'row',
+ gap: 16,
+ marginTop: 8
+ },
+ btn: {
+ flex: 1,
+ paddingVertical: 16,
+ borderRadius: 12,
+ alignItems: 'center',
+ borderWidth: 2,
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.05,
+ shadowRadius: 4,
+ elevation: 2
+ },
+ btnText: {
+ fontWeight: '700',
+ fontSize: 16
+ },
+ errorText: {
+ fontSize: 16,
+ textAlign: 'center',
+ marginBottom: 16
+ }
+});
+
+const LiveLocationViewerModal = ({ route }: LiveLocationViewerModalProps): React.ReactElement => {
+ const { rid, msgId, provider: routeProvider } = route.params;
+ const { colors } = useTheme();
+ const [loading, setLoading] = useState(true);
+ const [liveLocationData, setLiveLocationData] = useState(null);
+ const [error, setError] = useState(null);
+ const [mapImageUrl, setMapImageUrl] = useState('');
+ const updateIntervalRef = useRef | null>(null);
+ const previousActiveState = useRef(null);
+
+ const currentUserId = useAppSelector(state => state.login.user.id);
+ const [viewerMapProvider] = useUserPreferences(
+ `${MAP_PROVIDER_PREFERENCE_KEY}_${currentUserId}`,
+ MAP_PROVIDER_DEFAULT
+ );
+
+ const provider = viewerMapProvider || routeProvider || 'osm';
+
+ const convertTimestamp = (value: unknown): number | undefined => {
+ if (value && typeof value === 'object') {
+ const objValue = value as Record;
+ if (typeof objValue.date === 'number') return objValue.date;
+ if (typeof objValue.$date === 'number') return objValue.$date;
+ if (typeof objValue.seconds === 'number') return objValue.seconds * 1000;
+ }
+ if (value instanceof Date) {
+ const t = value.getTime();
+ return Number.isFinite(t) ? t : undefined;
+ }
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
+ return value;
+ }
+ if (typeof value === 'string') {
+ const iso = new Date(value);
+ if (!isNaN(iso.getTime())) return iso.getTime();
+ const parsed = parseInt(value, 10);
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
+ }
+ return undefined;
+ };
+
+ const fetchLiveLocation = useCallback(async () => {
+ try {
+ const response = await LiveLocationApi.get(rid, msgId);
+
+ const mobileCoords = serverToMobileCoords(response.coords);
+
+ const responseData = response as Record;
+ const lastUpdateAt =
+ convertTimestamp(response.lastUpdateAt) ??
+ convertTimestamp(responseData.ownerLastUpdateAt) ??
+ convertTimestamp(responseData.updatedAt);
+
+ const startedAt = convertTimestamp(response.startedAt) ?? convertTimestamp(responseData.ownerStartedAt);
+
+ const stoppedAt = response.stoppedAt ? convertTimestamp(response.stoppedAt) : undefined;
+ const expiresAt = response.expiresAt ? convertTimestamp(response.expiresAt) : undefined;
+
+ const liveData: LiveLocationData = {
+ ...response,
+ ownerUsername: response.ownerUsername ?? '',
+ ownerName: response.ownerName ?? '',
+ version: response.version ?? 0,
+ coords: mobileCoords,
+ startedAt,
+ lastUpdateAt,
+ stoppedAt,
+ expiresAt
+ };
+
+ setLiveLocationData(liveData);
+ setError(null);
+
+ if (mobileCoords) {
+ const mapResult = staticMapUrl('osm', mobileCoords, { zoom: 15 });
+ setMapImageUrl(mapResult.url);
+ }
+ } catch (err) {
+ setError((err as Error).message || 'Failed to load live location');
+ } finally {
+ setLoading(false);
+ }
+ }, [rid, msgId]);
+
+ const startPeriodicUpdates = useCallback(() => {
+ if (liveLocationData?.isActive) {
+ updateIntervalRef.current = setInterval(() => {
+ fetchLiveLocation();
+ }, 10000);
+ }
+ }, [liveLocationData?.isActive, fetchLiveLocation]);
+
+ const stopPeriodicUpdates = useCallback(() => {
+ if (updateIntervalRef.current) {
+ clearInterval(updateIntervalRef.current);
+ updateIntervalRef.current = null;
+ }
+ }, []);
+
+ const cacheKey = useMemo(() => {
+ const lat = liveLocationData?.coords?.latitude;
+ const lon = liveLocationData?.coords?.longitude;
+ return lat && lon ? `osm-${lat.toFixed(5)}-${lon.toFixed(5)}-z15-v2` : undefined;
+ }, [liveLocationData?.coords?.latitude, liveLocationData?.coords?.longitude]);
+
+ const openInMaps = async () => {
+ if (!liveLocationData?.coords) return;
+
+ try {
+ const mapUrl = await mapsDeepLink(provider as MapProviderName, liveLocationData.coords);
+ if (mapUrl) {
+ await Linking.openURL(mapUrl);
+ }
+ } catch (err) {
+ Alert.alert(I18n.t('error-open-maps-application'));
+ }
+ };
+
+ useEffect(() => {
+ const onEnded = (endedId: string) => {
+ if (endedId === msgId) {
+ stopPeriodicUpdates();
+ Navigation.back();
+ }
+ };
+ addLiveLocationEndedListener(onEnded);
+ return () => removeLiveLocationEndedListener(onEnded);
+ }, [msgId, stopPeriodicUpdates]);
+
+ useEffect(() => {
+ setLiveLocationData(null);
+ setError(null);
+ setMapImageUrl('');
+ setLoading(true);
+
+ fetchLiveLocation();
+ return () => {
+ stopPeriodicUpdates();
+ };
+ }, [fetchLiveLocation, stopPeriodicUpdates]);
+
+ useEffect(() => {
+ if (liveLocationData) {
+ if (liveLocationData.isActive) {
+ startPeriodicUpdates();
+ previousActiveState.current = true;
+ } else {
+ stopPeriodicUpdates();
+ if (previousActiveState.current === true) {
+ Navigation.back();
+ }
+ previousActiveState.current = false;
+ }
+ }
+ return () => {
+ stopPeriodicUpdates();
+ };
+ }, [liveLocationData, startPeriodicUpdates, stopPeriodicUpdates]);
+
+ const handleClose = () => {
+ stopPeriodicUpdates();
+ Navigation.back();
+ };
+
+ const formatTimestamp = (timestamp?: number) => {
+ if (!timestamp || !Number.isFinite(timestamp) || timestamp <= 0) {
+ return 'β';
+ }
+ try {
+ const date = new Date(timestamp);
+ if (isNaN(date.getTime())) {
+ return 'β';
+ }
+ return date.toLocaleTimeString();
+ } catch (error) {
+ return 'β';
+ }
+ };
+
+ if (loading) {
+ return (
+
+
+
+
+ {I18n.t('Loading_live_location')}
+
+
+ );
+ }
+
+ if (error || !liveLocationData) {
+ return (
+
+
+
+
+ {error || I18n.t('Live_location_not_found')}
+
+
+ {I18n.t('Close')}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ π {I18n.t('Live_Location')}
+
+ {I18n.t('Shared_By')} {liveLocationData.ownerUsername}
+
+
+
+
+
+
+
+ {liveLocationData.isActive ? I18n.t('Live_Location_Active') : I18n.t('Live_Location_Inactive')}
+
+
+
+ {liveLocationData.coords && (
+
+
+ {liveLocationData.coords.latitude.toFixed(5)}, {liveLocationData.coords.longitude.toFixed(5)}
+ {liveLocationData.coords.accuracy ? ` (Β±${Math.round(liveLocationData.coords.accuracy)}m)` : ''}
+
+
+ {I18n.t('Last_updated_at')} {formatTimestamp(liveLocationData.lastUpdateAt)}
+
+
+ )}
+
+
+
+ πΊοΈ {I18n.t('Open_in_provider', { provider: providerLabel(provider as MapProviderName) })}
+
+
+
+
+ {mapImageUrl ? (
+ <>
+ {
+ setMapImageUrl('');
+ }}
+ />
+
+ π
+
+ >
+ ) : (
+
+
+ π {I18n.t('Map_Preview')}
+
+ {liveLocationData.coords && (
+
+ {liveLocationData.coords.latitude.toFixed(5)}, {liveLocationData.coords.longitude.toFixed(5)}
+
+ )}
+
+ {I18n.t('Map_preview_unavailable_open_below')}
+
+
+ )}
+
+ {providerAttribution('osm')}
+
+ {liveLocationData.isActive && (
+
+ π΄ {I18n.t('Updates_every_10_seconds')}
+
+ )}
+
+
+
+ {I18n.t('Close')}
+
+
+
+
+ );
+};
+
+export default LiveLocationViewerModal;
diff --git a/app/views/LocationShare/LocationPreviewModal.tsx b/app/views/LocationShare/LocationPreviewModal.tsx
new file mode 100644
index 00000000000..4d1163948b3
--- /dev/null
+++ b/app/views/LocationShare/LocationPreviewModal.tsx
@@ -0,0 +1,204 @@
+import React, { useState, useMemo, useEffect, useRef } from 'react';
+import { View, Text, TouchableOpacity, StyleSheet, Alert, Linking } from 'react-native';
+import { Image as ExpoImage } from 'expo-image';
+import { shallowEqual } from 'react-redux';
+
+import I18n from '../../i18n';
+import Navigation from '../../lib/navigation/appNavigation';
+import { sendMessage } from '../../lib/methods/sendMessage';
+import { useAppSelector } from '../../lib/hooks/useAppSelector';
+import { getUserSelector } from '../../selectors/login';
+import { staticMapUrl, providerLabel, mapsDeepLink, providerAttribution } from './services/mapProviders';
+import type { MapProviderName } from './services/mapProviders';
+import { useTheme } from '../../theme';
+
+const OSM_HEADERS = {
+ 'User-Agent': 'RocketChatMobile/1.0 (+https://rocket.chat) contact: mobile@rocket.chat',
+ Referer: 'https://rocket.chat'
+};
+
+type Coords = { latitude: number; longitude: number; accuracy?: number; timestamp?: number };
+
+type RouteParams = {
+ rid: string;
+ tmid?: string;
+ provider: MapProviderName;
+ coords: Coords;
+};
+
+export default function LocationPreviewModal({ route }: { route: { params: RouteParams } }) {
+ const { rid, tmid, provider, coords } = route.params;
+ const [submitting, setSubmitting] = useState(false);
+ const { colors } = useTheme();
+
+ const mounted = useRef(true);
+ useEffect(
+ () => () => {
+ mounted.current = false;
+ },
+ []
+ );
+ const safeSet = (fn: () => void) => {
+ if (mounted.current) fn();
+ };
+
+ const { id, username } = useAppSelector(
+ state => ({
+ id: getUserSelector(state).id,
+ username: getUserSelector(state).username
+ }),
+ shallowEqual
+ );
+
+ const mapInfo = useMemo(() => {
+ const opts = { zoom: 15 };
+ return staticMapUrl('osm', { latitude: coords.latitude, longitude: coords.longitude }, opts);
+ }, [coords.latitude, coords.longitude]);
+
+ const cacheKey = useMemo(
+ () => `osm-${coords.latitude.toFixed(5)}-${coords.longitude.toFixed(5)}-z15-v2`,
+ [coords.latitude, coords.longitude]
+ );
+
+ useEffect(() => {
+ if (mapInfo?.url) {
+ ExpoImage.prefetch(mapInfo.url).catch(() => {
+ // Ignore prefetch errors
+ });
+ }
+ }, [mapInfo?.url]);
+
+ const openInMaps = async () => {
+ try {
+ const deep = await mapsDeepLink(provider, coords);
+ await Linking.openURL(deep);
+ } catch (error) {
+ Alert.alert(I18n.t('error-open-maps-application'));
+ }
+ };
+
+ const onCancel = () => Navigation.back();
+
+ const onShare = async () => {
+ try {
+ safeSet(() => setSubmitting(true));
+
+ const deep = await mapsDeepLink(provider, coords);
+ const providerName = providerLabel(provider);
+
+ const message = I18n.t('Share_Location_Message', {
+ location: I18n.t('Location'),
+ openText: I18n.t('Open_in_provider', { provider: providerName }),
+ link: deep
+ });
+
+ await sendMessage(rid, message, tmid, { id, username }, false);
+ Navigation.back();
+ } catch (e) {
+ const error = e as Error;
+ Alert.alert(I18n.t('Oops'), error?.message || I18n.t('Could_not_send_message'));
+ } finally {
+ safeSet(() => setSubmitting(false));
+ }
+ };
+
+ return (
+
+
+ π {I18n.t('Share_Location')}
+
+
+ {coords.latitude.toFixed(5)}, {coords.longitude.toFixed(5)}
+
+ {coords.accuracy ? (
+
+ {I18n.t('Accuracy', { meters: Math.round(coords.accuracy) })}
+
+ ) : null}
+
+
+
+ πΊοΈ {I18n.t('Open_in_provider', { provider: providerLabel(provider) })}
+
+ {' '}
+
+ {
+ // Image failed to load
+ }}
+ />
+
+ π
+
+
+ {providerAttribution('osm')}
+
+
+ {I18n.t('Cancel')}
+
+
+
+
+ {submitting ? I18n.t('Sharing_Loading') : I18n.t('Share_Location')}
+
+
+
+
+
+ );
+}
+
+const BLURHASH_PLACEHOLDER = 'LKO2?U%2Tw=w]~RBVZRi};RPxuwH';
+
+const styles = StyleSheet.create({
+ container: { flex: 1, padding: 16, justifyContent: 'center' },
+ content: {
+ borderRadius: 12,
+ padding: 16,
+ shadowOpacity: 0.1,
+ shadowRadius: 10,
+ shadowOffset: { width: 0, height: 4 },
+ elevation: 3
+ },
+ title: { fontSize: 18, fontWeight: '600', textAlign: 'center', marginBottom: 12 },
+ infoContainer: { marginBottom: 16, alignItems: 'center' },
+ coordsLine: { fontSize: 14, fontWeight: '500', textAlign: 'center', marginBottom: 4 },
+ accuracyText: { fontSize: 12, textAlign: 'center' },
+ mapLinkText: {
+ fontSize: 16,
+ fontWeight: '600',
+ textAlign: 'center',
+ marginBottom: 16
+ },
+ mapContainer: { borderRadius: 8, overflow: 'hidden', marginBottom: 12 },
+ mapImage: { width: '100%', height: 200 },
+ pinOverlay: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, alignItems: 'center', justifyContent: 'center' },
+ pinText: { fontSize: 24 },
+ attribution: { fontSize: 10, textAlign: 'center', marginBottom: 12 },
+ buttons: { flexDirection: 'row', gap: 12 },
+ btn: {
+ flex: 1,
+ paddingVertical: 12,
+ borderRadius: 10,
+ alignItems: 'center',
+ borderWidth: 1
+ },
+ btnPrimary: {},
+ btnText: { fontWeight: '600' },
+ btnTextPrimary: {}
+});
diff --git a/app/views/LocationShare/services/handleLiveLocationUrl.ts b/app/views/LocationShare/services/handleLiveLocationUrl.ts
new file mode 100644
index 00000000000..2c8775913a7
--- /dev/null
+++ b/app/views/LocationShare/services/handleLiveLocationUrl.ts
@@ -0,0 +1,106 @@
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import { Alert } from 'react-native';
+
+import Navigation from '../../../lib/navigation/appNavigation';
+import I18n from '../../../i18n';
+import { isLiveLocationActive, reopenLiveLocationModal, getCurrentLiveParams } from '../LiveLocationPreviewModal';
+
+const ENDED_KEY = 'live_location_ended_ids_v1';
+let endedIds: Set | null = null;
+
+const endedListeners = new Set<(liveLocationId: string) => void>();
+
+export function addLiveLocationEndedListener(listener: (liveLocationId: string) => void) {
+ endedListeners.add(listener);
+}
+
+export function removeLiveLocationEndedListener(listener: (liveLocationId: string) => void) {
+ endedListeners.delete(listener);
+}
+
+function notifyLiveLocationEnded(liveLocationId: string) {
+ endedListeners.forEach(listener => {
+ try {
+ listener(liveLocationId);
+ } catch (e) {
+ // Ignore listener errors
+ }
+ });
+}
+
+async function loadEndedSet(): Promise> {
+ if (!endedIds) {
+ try {
+ const raw = await AsyncStorage.getItem(ENDED_KEY);
+ endedIds = new Set(raw ? JSON.parse(raw) : []);
+ } catch {
+ endedIds = new Set();
+ }
+ }
+ return endedIds!;
+}
+async function saveEndedSet() {
+ if (!endedIds) return;
+ try {
+ await AsyncStorage.setItem(ENDED_KEY, JSON.stringify(Array.from(endedIds)));
+ } catch (_e) {
+ // Ignore storage errors
+ }
+}
+
+export async function markLiveLocationAsEnded(id: string) {
+ const set = await loadEndedSet();
+ if (!set.has(id)) {
+ set.add(id);
+ await saveEndedSet();
+ notifyLiveLocationEnded(id);
+ }
+}
+
+export async function isLiveLocationEnded(id: string) {
+ const set = await loadEndedSet();
+ return set.has(id);
+}
+
+export function isLiveMessageLink(url: string) {
+ return /^rocketchat:\/\/live-location/i.test(url);
+}
+
+export async function handleLiveLocationUrl(url: string) {
+ try {
+ if (!isLiveMessageLink(url)) return;
+
+ const u = new URL(url);
+ if (u.protocol !== 'rocketchat:' || u.host !== 'live-location') return;
+
+ const msgId = u.searchParams.get('msgId') || u.searchParams.get('liveLocationId') || undefined;
+ const provider = (u.searchParams.get('provider') || 'osm') as 'google' | 'osm';
+ const rid = u.searchParams.get('rid') || undefined;
+ const tmid = u.searchParams.get('tmid') || undefined;
+
+ if (msgId && (await isLiveLocationEnded(msgId))) {
+ Alert.alert(I18n.t('Live_Location_Ended_Title'), I18n.t('Live_Location_Ended_Message'), [{ text: I18n.t('OK') }]);
+ return;
+ }
+
+ if (!isLiveLocationActive()) {
+ Navigation.navigate('LiveLocationPreviewModal', {
+ provider,
+ ...(rid ? { rid } : {}),
+ ...(tmid ? { tmid } : {}),
+ liveLocationId: msgId,
+ isTracking: false
+ });
+ return true;
+ }
+
+ const params = getCurrentLiveParams();
+ if (params?.liveLocationId && msgId && params.liveLocationId !== msgId) {
+ return;
+ }
+
+ reopenLiveLocationModal();
+ } catch (e) {
+ // Invalid URL format
+ }
+}
diff --git a/app/views/LocationShare/services/liveLocation.ts b/app/views/LocationShare/services/liveLocation.ts
new file mode 100644
index 00000000000..a8e121d24a6
--- /dev/null
+++ b/app/views/LocationShare/services/liveLocation.ts
@@ -0,0 +1,311 @@
+import * as Location from 'expo-location';
+
+import { sendMessage } from '../../../lib/methods/sendMessage';
+import { LiveLocationApi } from './liveLocationApi';
+import type { MapProviderName } from './mapProviders';
+import I18n from '../../../i18n';
+
+export type LiveLocationState = {
+ coords: {
+ latitude: number;
+ longitude: number;
+ accuracy?: number;
+ };
+ timestamp: number;
+ isActive: boolean;
+ msgId?: string;
+};
+
+export class LiveLocationTracker {
+ private watchSub: Location.LocationSubscription | null = null;
+ private tickInterval: ReturnType | null = null;
+ private onLocationUpdate: ((state: LiveLocationState) => void) | null = null;
+ private currentState: LiveLocationState | null = null;
+ private rid: string;
+ private tmid?: string;
+ private user: { id: string; username: string };
+ private msgId: string | null = null;
+ private durationSec?: number;
+ private useServerApi = false;
+ private liveLocationId: string;
+ private provider: MapProviderName;
+
+ constructor(
+ rid: string,
+ tmid: string | undefined,
+ user: { id: string; username: string },
+ onUpdate: (state: LiveLocationState) => void,
+ durationSec?: number,
+ provider: MapProviderName = 'google'
+ ) {
+ this.rid = rid;
+ this.tmid = tmid;
+ this.user = user;
+ this.onLocationUpdate = onUpdate;
+ this.durationSec = durationSec;
+ this.provider = provider;
+ this.liveLocationId = `live_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
+ }
+
+ private emit(state: LiveLocationState) {
+ this.currentState = state;
+ this.onLocationUpdate?.(state);
+ }
+
+ async startTracking(): Promise {
+ // Permissions
+ let { status } = await Location.getForegroundPermissionsAsync();
+ if (status !== 'granted') {
+ const r = await Location.requestForegroundPermissionsAsync();
+ status = r.status;
+ }
+ if (status !== 'granted') {
+ throw new Error('Location permission not granted');
+ }
+
+ // Ensure services are on
+ const servicesOn = await Location.hasServicesEnabledAsync();
+ if (!servicesOn) {
+ throw new Error('Location services are turned off');
+ }
+
+ // Initial position
+ const first = await Location.getCurrentPositionAsync({
+ accuracy: Location.Accuracy.High,
+ mayShowUserSettingsDialog: true
+ });
+
+ const initialCoords = {
+ latitude: first.coords.latitude,
+ longitude: first.coords.longitude,
+ accuracy: first.coords.accuracy ?? undefined
+ };
+
+ // Start on server
+ try {
+ const response = await LiveLocationApi.start(this.rid, {
+ durationSec: this.durationSec,
+ initial: {
+ lat: initialCoords.latitude,
+ lon: initialCoords.longitude,
+ acc: initialCoords.accuracy
+ }
+ });
+ this.msgId = response.msgId;
+ this.useServerApi = true;
+ } catch (error) {
+ this.useServerApi = false;
+ this.msgId = null;
+
+ this.emit?.({
+ coords: initialCoords,
+ timestamp: Date.now(),
+ isActive: false,
+ msgId: undefined
+ });
+ throw error;
+ }
+
+ this.emit({
+ coords: initialCoords,
+ timestamp: Date.now(),
+ isActive: true,
+ msgId: this.msgId ?? undefined
+ });
+
+ // Watch position
+ try {
+ this.watchSub = await Location.watchPositionAsync(
+ {
+ accuracy: Location.Accuracy.High,
+ timeInterval: 10_000,
+ distanceInterval: 5
+ },
+ pos => {
+ this.currentState = {
+ coords: {
+ latitude: pos.coords.latitude,
+ longitude: pos.coords.longitude,
+ accuracy: pos.coords.accuracy ?? undefined
+ },
+ timestamp: Date.now(),
+ isActive: true,
+ msgId: this.msgId ?? undefined
+ };
+ }
+ );
+ } catch (error) {
+ if (this.msgId && this.useServerApi) {
+ try {
+ await LiveLocationApi.stop(this.rid, this.msgId);
+ } catch {
+ // best-effort cleanup
+ }
+ }
+ this.useServerApi = false;
+ this.msgId = null;
+ this.emit({
+ coords: initialCoords,
+ timestamp: Date.now(),
+ isActive: false,
+ msgId: undefined
+ });
+ throw error;
+ }
+
+ // Sync every 10s
+ this.tickInterval = setInterval(async () => {
+ if (!this.tickInterval || !this.currentState || !this.msgId) {
+ if (this.tickInterval) {
+ clearInterval(this.tickInterval);
+ this.tickInterval = null;
+ }
+ return;
+ }
+
+ if (this.currentState && this.msgId) {
+ const now = Date.now();
+
+ if (this.useServerApi) {
+ try {
+ await LiveLocationApi.update(this.rid, this.msgId, {
+ lat: this.currentState.coords.latitude,
+ lon: this.currentState.coords.longitude,
+ acc: this.currentState.coords.accuracy
+ });
+ } catch (error) {
+ if (
+ error instanceof Error &&
+ (error.message === 'error-live-location-not-found' || error.message.includes('live-location-not-found'))
+ ) {
+ this.useServerApi = false;
+ this.stopTracking().catch(_e => {});
+ return;
+ }
+ }
+ }
+
+ const emittedState = {
+ ...this.currentState,
+ timestamp: now,
+ isActive: true,
+ msgId: this.msgId ?? undefined
+ };
+ this.emit(emittedState);
+ }
+ }, 10_000);
+ }
+
+ async stopTracking(): Promise {
+ // Stop watch
+ if (this.watchSub) {
+ this.watchSub.remove();
+ this.watchSub = null;
+ }
+
+ if (this.tickInterval) {
+ clearInterval(this.tickInterval);
+ this.tickInterval = null;
+ }
+
+ // Stop on server
+ if (this.msgId && this.currentState) {
+ if (this.useServerApi) {
+ try {
+ await LiveLocationApi.stop(this.rid, this.msgId, {
+ lat: this.currentState.coords.latitude,
+ lon: this.currentState.coords.longitude,
+ acc: this.currentState.coords.accuracy
+ });
+ } catch (error) {
+ // ignore
+ }
+ } else {
+ // Fallback: send stop message
+ const stopMessage = this.createLiveLocationMessage(this.currentState.coords, 'stop');
+ try {
+ await sendMessage(this.rid, stopMessage, this.tmid, this.user, false);
+ } catch (error) {
+ // ignore
+ }
+ }
+ }
+
+ // Reset
+ this.useServerApi = false;
+ this.msgId = null;
+
+ if (this.currentState) {
+ this.emit({
+ ...this.currentState,
+ isActive: false,
+ msgId: undefined
+ });
+ }
+ }
+
+ getCurrentState(): LiveLocationState | null {
+ return this.currentState;
+ }
+
+ getMsgId(): string | null {
+ return this.msgId;
+ }
+
+ private createLiveLocationMessage(
+ coords: { latitude: number; longitude: number; accuracy?: number },
+ type: 'start' | 'stop'
+ ): string {
+ const params = new URLSearchParams({
+ liveLocationId: this.liveLocationId,
+ rid: this.rid,
+ tmid: this.tmid || '',
+ provider: this.provider,
+ action: 'reopen'
+ });
+ const appDeepLink = `rocketchat://live-location?${params.toString()}`;
+
+ if (type === 'start') {
+ return `π **${I18n.t('Live_Location_Start_Title')}**
+
+[π΄ ${I18n.t('View_Live_Location')}](${appDeepLink})
+
+${I18n.t('Coordinates', { lat: coords.latitude.toFixed(6), lon: coords.longitude.toFixed(6) })}`;
+ }
+ return `π **${I18n.t('Live_Location_Ended_Title')}** (ID: ${this.liveLocationId})`;
+ }
+}
+
+export function generateLiveLocationId(): string {
+ return `live_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
+}
+
+export function createLiveLocationMessage(
+ liveLocationId: string,
+ provider: MapProviderName,
+ coords: { latitude: number; longitude: number },
+ _serverUrl: string,
+ rid?: string,
+ tmid?: string
+): string {
+ const params = new URLSearchParams({
+ liveLocationId,
+ rid: rid || '',
+ tmid: tmid || '',
+ provider,
+ action: 'reopen'
+ });
+ const appDeepLink = `rocketchat://live-location?${params.toString()}`;
+
+ return `π **${I18n.t('Live_Location_Start_Title')}**
+
+[π΄ ${I18n.t('View_Live_Location')}](${appDeepLink})`;
+}
+
+export function createLiveLocationStopMessage(
+ liveLocationId: string,
+ _provider: MapProviderName,
+ _lastCoords: { latitude: number; longitude: number }
+): string {
+ return `π **${I18n.t('Live_Location_Ended_Title')}** (ID: ${liveLocationId})`;
+}
diff --git a/app/views/LocationShare/services/liveLocationApi.ts b/app/views/LocationShare/services/liveLocationApi.ts
new file mode 100644
index 00000000000..a7b7ac6a121
--- /dev/null
+++ b/app/views/LocationShare/services/liveLocationApi.ts
@@ -0,0 +1,125 @@
+import { liveLocationStart, liveLocationUpdate, liveLocationStop, liveLocationGet } from '../../../lib/services/restApi';
+import I18n from '../../../i18n';
+
+export type Coordinates = {
+ lat: number;
+ lon: number;
+ acc?: number;
+};
+
+export type LiveLocationStartOptions = {
+ durationSec?: number;
+ initial?: Coordinates;
+};
+
+export type LiveLocationStartResponse = {
+ msgId: string;
+};
+
+export type LiveLocationUpdateResponse = {
+ updated?: boolean;
+ ignored?: boolean;
+ reason?: string;
+};
+
+export type LiveLocationStopResponse = {
+ stopped: boolean;
+};
+
+export type LiveLocationGetResponse = {
+ messageId: string;
+ ownerId: string;
+ ownerUsername?: string;
+ ownerName?: string;
+ isActive: boolean;
+ startedAt: string;
+ lastUpdateAt: string;
+ stoppedAt?: string;
+ coords: { lat: number; lon: number };
+ expiresAt?: string;
+ version?: number;
+};
+
+export class LiveLocationApi {
+ static async start(rid: string, options: LiveLocationStartOptions = {}): Promise {
+ const initial = options.initial
+ ? { lat: options.initial.lat, lon: options.initial.lon, acc: options.initial.acc }
+ : undefined;
+ const res = await liveLocationStart(rid, options.durationSec, initial);
+ if ('success' in res && !res.success) {
+ throw new Error(typeof res.error === 'string' ? res.error : I18n.t('Live_Location_Start_Failed'));
+ }
+ return { msgId: res.msgId };
+ }
+
+ static async update(rid: string, msgId: string, coords: Coordinates): Promise {
+ const serverCoords = { lat: coords.lat, lon: coords.lon, acc: coords.acc };
+ const res = await liveLocationUpdate(rid, msgId, serverCoords);
+ if ('success' in res && !res.success) {
+ throw new Error(typeof res.error === 'string' ? res.error : I18n.t('Live_Location_Update_Error'));
+ }
+ return { updated: res.updated, ignored: res.ignored, reason: res.reason };
+ }
+
+ static async stop(rid: string, msgId: string, finalCoords?: Coordinates): Promise {
+ const serverCoords = finalCoords ? { lat: finalCoords.lat, lon: finalCoords.lon, acc: finalCoords.acc } : undefined;
+ const res = await liveLocationStop(rid, msgId, serverCoords);
+ if ('success' in res && !res.success) {
+ throw new Error(typeof res.error === 'string' ? res.error : I18n.t('Live_Location_Stop_Error'));
+ }
+ return { stopped: !!res.stopped };
+ }
+
+ static async get(rid: string, msgId: string): Promise {
+ const res = await liveLocationGet(rid, msgId);
+ if ('success' in res && !res.success) {
+ throw new Error(typeof res.error === 'string' ? res.error : I18n.t('Live_Location_Get_Error'));
+ }
+ if (!res.startedAt || !res.lastUpdateAt || !res.coords) {
+ throw new Error(I18n.t('Live_Location_Invalid_Response'));
+ }
+ if (!res.messageId || !res.ownerId || typeof res.isActive !== 'boolean' || typeof res.version !== 'number') {
+ throw new Error(I18n.t('Live_Location_Invalid_Response'));
+ }
+ return {
+ messageId: res.messageId,
+ ownerId: res.ownerId,
+ ownerUsername: res.ownerUsername ?? '',
+ ownerName: res.ownerName ?? '',
+ isActive: res.isActive,
+ startedAt: res.startedAt,
+ lastUpdateAt: res.lastUpdateAt,
+ stoppedAt: res.stoppedAt,
+ coords: res.coords,
+ expiresAt: res.expiresAt,
+ version: res.version
+ };
+ }
+}
+
+export function mobileToServerCoords(coords: { latitude: number; longitude: number; accuracy?: number }): {
+ lat: number;
+ lon: number;
+ acc?: number;
+} {
+ return {
+ lat: coords.latitude,
+ lon: coords.longitude,
+ acc: coords.accuracy
+ };
+}
+
+export function serverToMobileCoords(coords: Coordinates | { lat: number; lon: number; acc?: number }): {
+ latitude: number;
+ longitude: number;
+ accuracy?: number;
+} {
+ if (coords.lon == null) {
+ throw new Error(I18n.t('Live_Location_Invalid_Coordinates'));
+ }
+ return {
+ latitude: coords.lat,
+ longitude: coords.lon,
+ accuracy: coords.acc
+ };
+}
diff --git a/app/views/LocationShare/services/mapProviders.ts b/app/views/LocationShare/services/mapProviders.ts
new file mode 100644
index 00000000000..75f548aadc1
--- /dev/null
+++ b/app/views/LocationShare/services/mapProviders.ts
@@ -0,0 +1,154 @@
+import { Linking, Platform } from 'react-native';
+
+import I18n from '../../../i18n';
+
+export type MapProviderName = 'google' | 'osm';
+type AppleProvider = 'apple';
+type AnyProvider = MapProviderName | AppleProvider;
+type GoogleMapType = 'roadmap' | 'satellite' | 'hybrid' | 'terrain';
+
+export type Coords = { latitude: number; longitude: number; accuracy?: number; timestamp?: number };
+
+export type StaticOpts = {
+ zoom?: number;
+ size?: `${number}x${number}`;
+ scale?: 1 | 2 | 3;
+ mapType?: GoogleMapType;
+ googleApiKey?: string;
+ markerColor?: string;
+};
+
+const DEFAULT_ZOOM = 15;
+const DEFAULT_SIZE = '640x320';
+const DEFAULT_GOOGLE_SCALE: 1 | 2 | 3 = 2;
+const DEFAULT_GOOGLE_MAPTYPE: GoogleMapType = 'roadmap';
+const DEFAULT_MARKER_COLOR = 'red';
+
+const GOOGLE_STATIC_BASE = 'https://maps.googleapis.com/maps/api/staticmap';
+const OSM_TILE_BASE = 'https://tile.openstreetmap.org';
+
+function parseSize(size: StaticOpts['size'] | undefined) {
+ const [wStr, hStr] = (size ?? DEFAULT_SIZE).split('x');
+ const width = Number(wStr) || 640;
+ const height = Number(hStr) || 320;
+ return { width, height };
+}
+
+function enc(s: string | number) {
+ return encodeURIComponent(String(s));
+}
+
+function lonLatToTile(lon: number, lat: number, zoom: number) {
+ const latRad = (lat * Math.PI) / 180;
+ const n = Math.pow(2, zoom);
+ const x = Math.floor(((lon + 180) / 360) * n);
+ const y = Math.floor(((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * n);
+ return { x, y };
+}
+
+function buildGoogleStaticUrl(
+ { latitude, longitude }: Coords,
+ { zoom, size, scale, mapType, googleApiKey, markerColor }: StaticOpts
+): { url: string; width: number; height: number } {
+ const z = zoom ?? DEFAULT_ZOOM;
+ const { width, height } = parseSize(size);
+ const sc = scale ?? DEFAULT_GOOGLE_SCALE;
+ const type: GoogleMapType = mapType ?? DEFAULT_GOOGLE_MAPTYPE;
+ const color = markerColor ?? DEFAULT_MARKER_COLOR;
+
+ const qp =
+ `center=${latitude},${longitude}&zoom=${z}&size=${width}x${height}&scale=${sc}&maptype=${enc(type)}` +
+ `&markers=color:${enc(color)}|${latitude},${longitude}${googleApiKey ? `&key=${enc(googleApiKey)}` : ''}`;
+
+ return { url: `${GOOGLE_STATIC_BASE}?${qp}`, width, height };
+}
+
+function buildOsmStaticUrl(
+ { latitude, longitude }: Coords,
+ { zoom }: StaticOpts
+): { url: string; width: number; height: number } {
+ const z = zoom ?? DEFAULT_ZOOM;
+ const { x, y } = lonLatToTile(longitude, latitude, z);
+ const url = `${OSM_TILE_BASE}/${z}/${x}/${y}.png`;
+ return { url, width: 256, height: 256 };
+}
+
+export function staticMapUrl(
+ provider: MapProviderName,
+ coords: Coords,
+ opts: StaticOpts = {}
+): { url: string; width: number; height: number } {
+ switch (provider) {
+ case 'google':
+ return buildGoogleStaticUrl(coords, opts);
+ case 'osm':
+ return buildOsmStaticUrl(coords, opts);
+ default: {
+ const _never: never = provider;
+ throw new Error(`Unsupported provider: ${_never}`);
+ }
+ }
+}
+
+// iOS
+async function iosGoogleLink({ latitude, longitude }: Coords): Promise {
+ const query = `${latitude},${longitude}`;
+ const appScheme = `comgooglemaps://?q=${enc(query)}`;
+
+ try {
+ if (await Linking.canOpenURL(appScheme)) return appScheme;
+ } catch {
+ // fall back to web
+ }
+ return `https://www.google.com/maps/search/?api=1&query=${enc(query)}`;
+}
+
+function iosOsmLink({ latitude, longitude }: Coords): string {
+ return `https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=${DEFAULT_ZOOM}/${latitude}/${longitude}`;
+}
+
+function iosAppleLink({ latitude, longitude }: Coords): string {
+ const query = `${latitude},${longitude}`;
+ return `https://maps.apple.com/?ll=${query}&q=${enc(query)}`;
+}
+
+// Android
+function androidGoogleLikeLink({ latitude, longitude }: Coords): string {
+ const query = `${latitude},${longitude}`;
+ return `geo:${query}?q=${enc(query)}`;
+}
+
+function androidOsmLink({ latitude, longitude }: Coords): string {
+ return `https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=${DEFAULT_ZOOM}/${latitude}/${longitude}`;
+}
+
+export async function mapsDeepLink(provider: AnyProvider, coords: Coords): Promise {
+ if (Platform.OS === 'ios') {
+ switch (provider) {
+ case 'google': {
+ const url = await iosGoogleLink(coords);
+ return url;
+ }
+ case 'osm':
+ return iosOsmLink(coords);
+ case 'apple':
+ return iosAppleLink(coords);
+ default:
+ return iosAppleLink(coords);
+ }
+ }
+
+ // Android
+ switch (provider) {
+ case 'google':
+ case 'apple':
+ return androidGoogleLikeLink(coords);
+ case 'osm':
+ return androidOsmLink(coords);
+ default:
+ return androidGoogleLikeLink(coords);
+ }
+}
+
+export const providerLabel = (p: MapProviderName) => (p === 'google' ? I18n.t('Google_Maps') : I18n.t('OpenStreetMap'));
+export const providerAttribution = (p: MapProviderName) => (p === 'google' ? undefined : I18n.t('OSM_Attribution'));
diff --git a/app/views/LocationShare/services/staticLocation.ts b/app/views/LocationShare/services/staticLocation.ts
new file mode 100644
index 00000000000..c6a67240655
--- /dev/null
+++ b/app/views/LocationShare/services/staticLocation.ts
@@ -0,0 +1,59 @@
+import * as Location from 'expo-location';
+
+import type { Coords } from './mapProviders';
+
+const LOCATION_TIMEOUT_MS = 15_000;
+const LAST_KNOWN_MAX_AGE_MS = 15_000;
+
+function withTimeout(p: Promise, ms: number): Promise {
+ return new Promise((resolve, reject) => {
+ const t = setTimeout(() => {
+ reject(new Error('Location request timed out'));
+ }, ms);
+
+ p.then(v => {
+ clearTimeout(t);
+ resolve(v);
+ }).catch(e => {
+ clearTimeout(t);
+ reject(e);
+ });
+ });
+}
+
+export async function getCurrentPositionOnce(): Promise {
+ try {
+ try {
+ const last = await Location.getLastKnownPositionAsync({ maxAge: LAST_KNOWN_MAX_AGE_MS });
+
+ if (last?.coords) {
+ const { latitude, longitude, accuracy } = last.coords;
+ return {
+ latitude,
+ longitude,
+ accuracy: accuracy ?? undefined,
+ timestamp: last.timestamp
+ };
+ }
+ } catch (_e) {
+ // Failed to get last known position
+ }
+
+ const loc = await withTimeout(
+ Location.getCurrentPositionAsync({
+ accuracy: Location.Accuracy.High
+ }),
+ LOCATION_TIMEOUT_MS
+ );
+
+ const { latitude, longitude, accuracy } = loc.coords;
+ return {
+ latitude,
+ longitude,
+ accuracy: accuracy ?? undefined,
+ timestamp: loc.timestamp
+ };
+ } catch (error) {
+ throw error;
+ }
+}
diff --git a/app/views/RoomView/index.tsx b/app/views/RoomView/index.tsx
index 4b76f4b55d0..333a8ab0631 100644
--- a/app/views/RoomView/index.tsx
+++ b/app/views/RoomView/index.tsx
@@ -54,6 +54,7 @@ import JoinCode, { type IJoinCode } from './JoinCode';
import UploadProgress from './UploadProgress';
import ReactionPicker from './ReactionPicker';
import List from './List';
+import LiveLocationStatusBar from '../LocationShare/LiveLocationStatusBar';
import {
type IApplicationState,
type IAttachment,
@@ -1534,6 +1535,7 @@ class RoomView extends React.Component {
getText: this.getText
}}>
+
{!this.tmid ? (
+ navigateToScreen('LocationPreferencesView')}
+ showActionIndicator
+ testID='preferences-view-location'
+ />
+
{compareServerVersion(serverVersion, 'lowerThan', '5.0.0') ? (
diff --git a/ios/.ruby-version b/ios/.ruby-version
new file mode 100644
index 00000000000..be94e6f53db
--- /dev/null
+++ b/ios/.ruby-version
@@ -0,0 +1 @@
+3.2.2
diff --git a/ios/NotificationService/Info.plist b/ios/NotificationService/Info.plist
index 9d959502934..0b649883e6e 100644
--- a/ios/NotificationService/Info.plist
+++ b/ios/NotificationService/Info.plist
@@ -4,8 +4,6 @@
AppGroup
group.ios.chat.rocket
- IS_OFFICIAL
-
CFBundleDevelopmentRegion
$(DEVELOPMENT_LANGUAGE)
CFBundleDisplayName
@@ -24,6 +22,8 @@
$(MARKETING_VERSION)
CFBundleVersion
1
+ IS_OFFICIAL
+
KeychainGroup
$(AppIdentifierPrefix)chat.rocket.reactnative
NSExtension
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index 789fecd480e..e0fc3bef435 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -86,6 +86,8 @@ PODS:
- ExpoModulesCore
- ExpoLocalAuthentication (16.0.3):
- ExpoModulesCore
+ - ExpoLocation (19.0.7):
+ - ExpoModulesCore
- ExpoModulesCore (2.3.12):
- DoubleConversion
- glog
@@ -2658,6 +2660,7 @@ DEPENDENCIES:
- ExpoImage (from `../node_modules/expo-image/ios`)
- ExpoKeepAwake (from `../node_modules/expo-keep-awake/ios`)
- ExpoLocalAuthentication (from `../node_modules/expo-local-authentication/ios`)
+ - ExpoLocation (from `../node_modules/expo-location/ios`)
- ExpoModulesCore (from `../node_modules/expo-modules-core`)
- ExpoSystemUI (from `../node_modules/expo-system-ui/ios`)
- ExpoVideoThumbnails (from `../node_modules/expo-video-thumbnails/ios`)
@@ -2834,6 +2837,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/expo-keep-awake/ios"
ExpoLocalAuthentication:
:path: "../node_modules/expo-local-authentication/ios"
+ ExpoLocation:
+ :path: "../node_modules/expo-location/ios"
ExpoModulesCore:
:path: "../node_modules/expo-modules-core"
ExpoSystemUI:
@@ -3070,6 +3075,7 @@ SPEC CHECKSUMS:
ExpoImage: 785f7673cfecd97045cf333fd6486b57ccab4b71
ExpoKeepAwake: 213acedecafb6fda8c0ffedad22ee9e2903400c5
ExpoLocalAuthentication: f69863a1822e42db67a311ce839ecbac70e7fa65
+ ExpoLocation: 93d7faa0c2adbd5a04686af0c1a61bc6ed3ee2f7
ExpoModulesCore: b4fdeaceca6a4360d4a75fbae335907427c1df6b
ExpoSystemUI: 82c970cf8495449698e7343b4f78a0d04bcec9ee
ExpoVideoThumbnails: 2a448a23eb4eeb860d92ded372fec6e6a7a0cdcb
@@ -3203,9 +3209,9 @@ SPEC CHECKSUMS:
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
TOCropViewController: 80b8985ad794298fb69d3341de183f33d1853654
WatermelonDB: 4c846c8cb94eef3cba90fa034d15310163226703
- Yoga: dfabf1234ccd5ac41d1b1d43179f024366ae9831
+ Yoga: 2a3a4c38a8441b6359d5e5914d35db7b2b67aebd
ZXingObjC: 8898711ab495761b2dbbdec76d90164a6d7e14c5
PODFILE CHECKSUM: 4c73563b34520b90c036817cdb9ccf65fea5f5c5
-COCOAPODS: 1.15.2
+COCOAPODS: 1.16.2
diff --git a/ios/RocketChatRN.xcodeproj/project.pbxproj b/ios/RocketChatRN.xcodeproj/project.pbxproj
index 7f65bcc902d..5c70c4bfcd2 100644
--- a/ios/RocketChatRN.xcodeproj/project.pbxproj
+++ b/ios/RocketChatRN.xcodeproj/project.pbxproj
@@ -1882,7 +1882,7 @@
inputFileListPaths = (
);
inputPaths = (
- "$TARGET_BUILD_DIR/$INFOPLIST_PATH",
+ $TARGET_BUILD_DIR/$INFOPLIST_PATH,
);
name = "Upload source maps to Bugsnag";
outputFileListPaths = (
@@ -1968,7 +1968,7 @@
inputFileListPaths = (
);
inputPaths = (
- "$TARGET_BUILD_DIR/$INFOPLIST_PATH",
+ $TARGET_BUILD_DIR/$INFOPLIST_PATH,
);
name = "Upload source maps to Bugsnag";
outputFileListPaths = (
@@ -2698,7 +2698,7 @@
"$(inherited)",
"$(SRCROOT)/../node_modules/rn-extensions-share/ios/**",
"$(SRCROOT)/../node_modules/react-native-firebase/ios/RNFirebase/**",
- "$PODS_CONFIGURATION_BUILD_DIR/Firebase",
+ $PODS_CONFIGURATION_BUILD_DIR/Firebase,
"$(SRCROOT)/../node_modules/react-native-mmkv-storage/ios/**",
);
INFOPLIST_FILE = ShareRocketChatRN/Info.plist;
@@ -2775,7 +2775,7 @@
"$(inherited)",
"$(SRCROOT)/../node_modules/rn-extensions-share/ios/**",
"$(SRCROOT)/../node_modules/react-native-firebase/ios/RNFirebase/**",
- "$PODS_CONFIGURATION_BUILD_DIR/Firebase",
+ $PODS_CONFIGURATION_BUILD_DIR/Firebase,
"$(SRCROOT)/../node_modules/react-native-mmkv-storage/ios/**",
);
INFOPLIST_FILE = ShareRocketChatRN/Info.plist;
diff --git a/ios/RocketChatRN/Info.plist b/ios/RocketChatRN/Info.plist
index 2b2b1868232..4c670bd7f97 100644
--- a/ios/RocketChatRN/Info.plist
+++ b/ios/RocketChatRN/Info.plist
@@ -57,6 +57,7 @@
firefox
brave
tel
+ comgooglemaps
LSRequiresIPhoneOS
@@ -66,7 +67,7 @@
NSAllowsLocalNetworking
-
+
NSCameraUsageDescription
Take photos to share with other users
NSFaceIDUsageDescription
@@ -77,6 +78,10 @@
Give $(PRODUCT_NAME) permission to save photos
NSPhotoLibraryUsageDescription
Upload photos to share with other users or to change your avatar
+ NSLocationWhenInUseUsageDescription
+ Rocket.Chat uses your location to share your current position in conversations. Location data is only sent to the server when you explicitly choose to share it and is not stored locally on your device.
+ NSLocationAlwaysAndWhenInUseUsageDescription
+ Rocket.Chat needs background location access to continue sharing live location updates while the app is minimized. You control when live sharing starts and stops, and can end it anytime from the status bar.
UIAppFonts
custom.ttf
@@ -92,6 +97,7 @@
audio
fetch
voip
+ location
UILaunchStoryboardName
LaunchScreen
diff --git a/package.json b/package.json
index abae3c2e00a..4d9e78a085e 100644
--- a/package.json
+++ b/package.json
@@ -75,6 +75,7 @@
"expo-image": "^2.3.2",
"expo-keep-awake": "14.1.3",
"expo-local-authentication": "16.0.3",
+ "expo-location": "^19.0.7",
"expo-navigation-bar": "^4.2.4",
"expo-status-bar": "^2.2.3",
"expo-system-ui": "^5.0.7",
diff --git a/yarn.lock b/yarn.lock
index 3319bd32e7e..d403e81aa22 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -8618,6 +8618,11 @@ expo-local-authentication@16.0.3:
dependencies:
invariant "^2.2.4"
+expo-location@^19.0.7:
+ version "19.0.7"
+ resolved "https://registry.yarnpkg.com/expo-location/-/expo-location-19.0.7.tgz#58ab5b9b59db3a26d0495c19e719d5f559948b1c"
+ integrity sha512-YNkh4r9E6ECbPkBCAMG5A5yHDgS0pw+Rzyd0l2ZQlCtjkhlODB55nMCKr5CZnUI0mXTkaSm8CwfoCO8n2MpYfg==
+
expo-modules-autolinking@2.1.9:
version "2.1.9"
resolved "https://registry.yarnpkg.com/expo-modules-autolinking/-/expo-modules-autolinking-2.1.9.tgz#7bf8338d4b7a1b6e8eccab51634de9b339e90c04"