|
@@ -6,10 +6,13 @@ import {
|
|
InputModeOptions,
|
|
InputModeOptions,
|
|
NativeSyntheticEvent,
|
|
NativeSyntheticEvent,
|
|
TextInputFocusEventData,
|
|
TextInputFocusEventData,
|
|
- TouchableOpacity
|
|
|
|
|
|
+ TouchableOpacity,
|
|
|
|
+ Linking,
|
|
|
|
+ Platform
|
|
} from 'react-native';
|
|
} from 'react-native';
|
|
import { styling } from './style';
|
|
import { styling } from './style';
|
|
import { Colors } from 'src/theme';
|
|
import { Colors } from 'src/theme';
|
|
|
|
+import { GOOGLE_MAP_PLACES_APIKEY } from 'src/constants';
|
|
|
|
|
|
type Props = {
|
|
type Props = {
|
|
placeholder?: string;
|
|
placeholder?: string;
|
|
@@ -31,6 +34,149 @@ type Props = {
|
|
setValue?: (value: string) => void;
|
|
setValue?: (value: string) => void;
|
|
};
|
|
};
|
|
|
|
|
|
|
|
+const parseTextWithLinks = (text?: string): React.ReactNode => {
|
|
|
|
+ if (!text) return null;
|
|
|
|
+
|
|
|
|
+ const urlRegex = /((https?:\/\/[^\s]+)|(?<!\w)(www\.[^\s]+))/g;
|
|
|
|
+ const eventPageRegex = /Event page\s+(https?:\/\/[^\s]+)\s+([^\n]+)/i;
|
|
|
|
+
|
|
|
|
+ const result: React.ReactNode[] = [];
|
|
|
|
+ let lastIndex = 0;
|
|
|
|
+
|
|
|
|
+ const eventMatch = text.match(eventPageRegex);
|
|
|
|
+ let handledEvent = false;
|
|
|
|
+
|
|
|
|
+ if (eventMatch) {
|
|
|
|
+ const [fullMatch, eventUrl, locationCandidate] = eventMatch;
|
|
|
|
+ const eventStart = eventMatch.index ?? 0;
|
|
|
|
+ const eventEnd = eventStart + fullMatch.length;
|
|
|
|
+
|
|
|
|
+ if (eventStart > 0) {
|
|
|
|
+ result.push(<Text key="text-before-event">{text.slice(0, eventStart)}</Text>);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ result.push(
|
|
|
|
+ <Text
|
|
|
|
+ key="event-url"
|
|
|
|
+ style={{ color: Colors.ORANGE, textDecorationLine: 'underline' }}
|
|
|
|
+ onPress={() => Linking.openURL(eventUrl)}
|
|
|
|
+ >
|
|
|
|
+ {eventUrl}
|
|
|
|
+ </Text>
|
|
|
|
+ );
|
|
|
|
+
|
|
|
|
+ result.push(
|
|
|
|
+ <Text
|
|
|
|
+ key="event-location"
|
|
|
|
+ style={{ color: Colors.ORANGE, textDecorationLine: 'none' }}
|
|
|
|
+ onPress={() => openLocation(locationCandidate)}
|
|
|
|
+ >
|
|
|
|
+ {' ' + locationCandidate.trim()}
|
|
|
|
+ </Text>
|
|
|
|
+ );
|
|
|
|
+
|
|
|
|
+ lastIndex = eventEnd;
|
|
|
|
+ handledEvent = true;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ Array.from(text.matchAll(urlRegex)).forEach((match, index) => {
|
|
|
|
+ const matchText = match[0];
|
|
|
|
+ const matchIndex = match.index ?? 0;
|
|
|
|
+
|
|
|
|
+ if (handledEvent && matchIndex < lastIndex) return;
|
|
|
|
+
|
|
|
|
+ if (lastIndex < matchIndex) {
|
|
|
|
+ result.push(<Text key={`text-${index}`}>{text.slice(lastIndex, matchIndex)}</Text>);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ let url = matchText.startsWith('http') ? matchText : `https://${matchText}`;
|
|
|
|
+ const isGoogleMaps = /google\.com\/maps|maps\.google\.com/.test(url);
|
|
|
|
+
|
|
|
|
+ const handlePress = async () => {
|
|
|
|
+ if (isGoogleMaps) {
|
|
|
|
+ const latLngMatch = url.match(/@(-?\d+\.\d+),(-?\d+\.\d+)/);
|
|
|
|
+ const lat = latLngMatch?.[1];
|
|
|
|
+ const lng = latLngMatch?.[2];
|
|
|
|
+
|
|
|
|
+ let mapsUrl = url;
|
|
|
|
+
|
|
|
|
+ if (Platform.OS === 'ios') {
|
|
|
|
+ mapsUrl = lat && lng ? `comgooglemaps://?q=${lat},${lng}` : url;
|
|
|
|
+ } else {
|
|
|
|
+ mapsUrl = lat && lng ? `geo:${lat},${lng}?q=${lat},${lng}` : url;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ const supported = await Linking.canOpenURL(mapsUrl);
|
|
|
|
+ if (supported) {
|
|
|
|
+ await Linking.openURL(mapsUrl);
|
|
|
|
+ } else {
|
|
|
|
+ await Linking.openURL(url);
|
|
|
|
+ }
|
|
|
|
+ } else {
|
|
|
|
+ await Linking.openURL(url);
|
|
|
|
+ }
|
|
|
|
+ };
|
|
|
|
+
|
|
|
|
+ result.push(
|
|
|
|
+ <Text
|
|
|
|
+ key={`link-${index}`}
|
|
|
|
+ style={{ color: Colors.ORANGE, textDecorationLine: 'underline' }}
|
|
|
|
+ onPress={handlePress}
|
|
|
|
+ >
|
|
|
|
+ {matchText}
|
|
|
|
+ </Text>
|
|
|
|
+ );
|
|
|
|
+
|
|
|
|
+ lastIndex = matchIndex + matchText.length;
|
|
|
|
+ });
|
|
|
|
+
|
|
|
|
+ if (lastIndex < text.length) {
|
|
|
|
+ result.push(<Text key="text-end">{text.slice(lastIndex)}</Text>);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ return result;
|
|
|
|
+};
|
|
|
|
+
|
|
|
|
+const openLocation = async (address: string) => {
|
|
|
|
+ const endpoint =
|
|
|
|
+ `https://maps.googleapis.com/maps/api/place/findplacefromtext/json` +
|
|
|
|
+ `?input=${encodeURIComponent(address)}` +
|
|
|
|
+ `&inputtype=textquery` +
|
|
|
|
+ `&fields=geometry,formatted_address,name` +
|
|
|
|
+ `&key=${GOOGLE_MAP_PLACES_APIKEY}`;
|
|
|
|
+
|
|
|
|
+ try {
|
|
|
|
+ const response = await fetch(endpoint);
|
|
|
|
+ const data = await response.json();
|
|
|
|
+
|
|
|
|
+ if (data.status === 'OK' && data.candidates?.length > 0) {
|
|
|
|
+ const place = data.candidates[0];
|
|
|
|
+ const { lat, lng } = place.geometry.location;
|
|
|
|
+ const label = place.name || place.formatted_address || address;
|
|
|
|
+ const encodedLabel = encodeURIComponent(label);
|
|
|
|
+ const fallbackUrl = `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`;
|
|
|
|
+
|
|
|
|
+ let mapsUrl: string;
|
|
|
|
+
|
|
|
|
+ if (Platform.OS === 'ios') {
|
|
|
|
+ const canOpenGoogleMaps = await Linking.canOpenURL('comgooglemaps://');
|
|
|
|
+ mapsUrl = canOpenGoogleMaps
|
|
|
|
+ ? `comgooglemaps://?center=${lat},${lng}&q=${encodedLabel}@${lat},${lng}`
|
|
|
|
+ : `http://maps.apple.com/?ll=${lat},${lng}&q=${encodedLabel}`;
|
|
|
|
+ } else {
|
|
|
|
+ mapsUrl = `geo:${lat},${lng}?q=${encodedLabel}`;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ const supported = await Linking.canOpenURL(mapsUrl);
|
|
|
|
+ await Linking.openURL(supported ? mapsUrl : fallbackUrl);
|
|
|
|
+ } else {
|
|
|
|
+ console.warn('Places API did not return valid results:', data.status);
|
|
|
|
+ }
|
|
|
|
+ } catch (error) {
|
|
|
|
+ console.error('Places API error:', error);
|
|
|
|
+ }
|
|
|
|
+};
|
|
|
|
+
|
|
export const Input: FC<Props> = ({
|
|
export const Input: FC<Props> = ({
|
|
onChange,
|
|
onChange,
|
|
placeholder,
|
|
placeholder,
|
|
@@ -76,7 +222,53 @@ export const Input: FC<Props> = ({
|
|
{icon}
|
|
{icon}
|
|
</View>
|
|
</View>
|
|
) : null}
|
|
) : null}
|
|
- <TextInput
|
|
|
|
|
|
+ {editable === false ? (
|
|
|
|
+ <Text
|
|
|
|
+ style={[
|
|
|
|
+ {
|
|
|
|
+ width: '100%',
|
|
|
|
+ flex: 1
|
|
|
|
+ },
|
|
|
|
+ !icon ? { padding: 10 } : null
|
|
|
|
+ ]}
|
|
|
|
+ >
|
|
|
|
+ {parseTextWithLinks(value)}
|
|
|
|
+ </Text>
|
|
|
|
+ ) : (
|
|
|
|
+ <TextInput
|
|
|
|
+ editable={editable}
|
|
|
|
+ value={value}
|
|
|
|
+ inputMode={inputMode ?? 'text'}
|
|
|
|
+ secureTextEntry={isPrivate ?? false}
|
|
|
|
+ placeholder={placeholder}
|
|
|
|
+ onChangeText={onChange}
|
|
|
|
+ multiline={multiline}
|
|
|
|
+ contextMenuHidden={inputMode === 'none'}
|
|
|
|
+ caretHidden={inputMode === 'none'}
|
|
|
|
+ onPress={() => {
|
|
|
|
+ setFocused(true);
|
|
|
|
+ if (isFocused) {
|
|
|
|
+ isFocused(true);
|
|
|
|
+ }
|
|
|
|
+ }}
|
|
|
|
+ onFocus={() => {
|
|
|
|
+ if (inputMode !== 'none') {
|
|
|
|
+ setFocused(true);
|
|
|
|
+ if (isFocused) {
|
|
|
|
+ isFocused(true);
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ }}
|
|
|
|
+ onBlur={(e) => {
|
|
|
|
+ setFocused(false);
|
|
|
|
+ if (onBlur) {
|
|
|
|
+ onBlur(e);
|
|
|
|
+ }
|
|
|
|
+ }}
|
|
|
|
+ style={[{ height: '100%', width: '100%', flex: 1 }, !icon ? { padding: 10 } : null]}
|
|
|
|
+ />
|
|
|
|
+ )}
|
|
|
|
+ {/* <TextInput
|
|
editable={editable}
|
|
editable={editable}
|
|
value={value}
|
|
value={value}
|
|
inputMode={inputMode ?? 'text'}
|
|
inputMode={inputMode ?? 'text'}
|
|
@@ -107,7 +299,7 @@ export const Input: FC<Props> = ({
|
|
}
|
|
}
|
|
}}
|
|
}}
|
|
style={[{ height: '100%', width: '100%', flex: 1 }, !icon ? { padding: 10 } : null]}
|
|
style={[{ height: '100%', width: '100%', flex: 1 }, !icon ? { padding: 10 } : null]}
|
|
- />
|
|
|
|
|
|
+ /> */}
|
|
{clearIcon && value && value?.length > 0 ? (
|
|
{clearIcon && value && value?.length > 0 ? (
|
|
<TouchableOpacity
|
|
<TouchableOpacity
|
|
style={styles.clearIcon}
|
|
style={styles.clearIcon}
|