diff --git a/src/chat/ChatScreen.tsx b/src/chat/ChatScreen.tsx index 8cf30af..af16193 100644 --- a/src/chat/ChatScreen.tsx +++ b/src/chat/ChatScreen.tsx @@ -18,6 +18,7 @@ import { GiftedChat, type GiftedChatProps, Bubble, + BubbleProps, } from 'react-native-gifted-chat'; import TypingIndicator from 'react-native-gifted-chat/lib/TypingIndicator'; import { FirestoreServices } from '../services/firebase'; @@ -43,6 +44,7 @@ import InputToolbar, { IInputToolbar } from './components/InputToolbar'; type ChildrenProps = { onSend: (messages: MessageProps) => Promise; }; +import { ICustomBubbleWithLinkPreviewStyles } from './components/bubble/CustomBubbleWithLinkPreview'; interface ChatScreenProps extends GiftedChatProps { style?: StyleProp; @@ -65,6 +67,12 @@ interface ChatScreenProps extends GiftedChatProps { messageStatusEnable?: boolean; customMessageStatus?: (hasUnread: boolean) => JSX.Element; children?: (props: ChildrenProps) => ReactNode | ReactNode; + customLinkPreviewStyles?: ICustomBubbleWithLinkPreviewStyles; + customLinkPreview: ( + urls: string[], + bubbleMessage: BubbleProps + ) => JSX.Element; + enableLinkPreview?: boolean; } export const ChatScreen: React.FC = ({ @@ -83,6 +91,9 @@ export const ChatScreen: React.FC = ({ enableTyping = true, typingTimeoutSeconds = DEFAULT_TYPING_TIMEOUT_SECONDS, messageStatusEnable = true, + customLinkPreviewStyles, + customLinkPreview, + enableLinkPreview = true, ...props }) => { const { userInfo, chatDispatch } = useChatContext(); @@ -307,6 +318,9 @@ export const ChatScreen: React.FC = ({ unReadSeenMessage={props.unReadSeenMessage} customMessageStatus={props.customMessageStatus} messageStatusEnable={messageStatusEnable} + customLinkPreviewStyles={customLinkPreviewStyles} + customLinkPreview={customLinkPreview} + enableLinkPreview={enableLinkPreview} /> ); }; diff --git a/src/chat/components/bubble/CustomBubble.tsx b/src/chat/components/bubble/CustomBubble.tsx index e4720a0..fa2b7ed 100644 --- a/src/chat/components/bubble/CustomBubble.tsx +++ b/src/chat/components/bubble/CustomBubble.tsx @@ -1,12 +1,16 @@ import React from 'react'; import { StyleProp, StyleSheet, Text, View, ViewStyle } from 'react-native'; import { MessageTypes, type MessageProps } from '../../../interfaces'; -import { Bubble } from 'react-native-gifted-chat'; +import { Bubble, BubbleProps } from 'react-native-gifted-chat'; import { CustomImageVideoBubble, CustomImageVideoBubbleProps, } from './CustomImageVideoBubble'; import MessageStatus from '../MessageStatus'; +import { + ICustomBubbleWithLinkPreviewStyles, + CustomBubbleWithLinkPreview, +} from './CustomBubbleWithLinkPreview'; interface CustomBubbleProps { bubbleMessage: Bubble['props']; @@ -20,6 +24,12 @@ interface CustomBubbleProps { unReadSeenMessage?: string; messageStatusEnable: boolean; customMessageStatus?: (hasUnread: boolean) => JSX.Element; + customLinkPreviewStyles?: ICustomBubbleWithLinkPreviewStyles; + customLinkPreview: ( + urls: string[], + bubbleMessage: BubbleProps + ) => JSX.Element; + enableLinkPreview: boolean; } export const CustomBubble: React.FC = ({ @@ -34,6 +44,9 @@ export const CustomBubble: React.FC = ({ unReadSentMessage, messageStatusEnable, customMessageStatus, + customLinkPreviewStyles, + customLinkPreview, + enableLinkPreview, }) => { const styleBuble = { left: { backgroundColor: 'transparent' }, @@ -98,7 +111,12 @@ export const CustomBubble: React.FC = ({ default: { return ( - + {ViewMessageStatus} ); diff --git a/src/chat/components/bubble/CustomBubbleWithLinkPreview.tsx b/src/chat/components/bubble/CustomBubbleWithLinkPreview.tsx new file mode 100644 index 0000000..7b66129 --- /dev/null +++ b/src/chat/components/bubble/CustomBubbleWithLinkPreview.tsx @@ -0,0 +1,172 @@ +import React, { useCallback } from 'react'; +import { + Linking, + StyleProp, + StyleSheet, + Text, + View, + ViewStyle, +} from 'react-native'; +import { Bubble, BubbleProps } from 'react-native-gifted-chat'; +import type { MessageProps } from '../../../interfaces'; +import { LinkPreview } from '../linkPreview'; + +export interface ICustomBubbleWithLinkPreviewStyles { + customContainerStyle?: StyleProp; + customPreviewContainerStyle?: StyleProp; + customLinkTextStyle?: StyleProp; + customMessagePreviewStyle?: StyleProp; +} +interface ICustomBubbleWithLinkPreviewProps { + bubbleMessage: BubbleProps; + customBubbleWithLinkPreviewStyles?: ICustomBubbleWithLinkPreviewStyles; + customBubbleWithLinkPreview: ( + urls: string[], + bubbleMessage: BubbleProps + ) => JSX.Element; + enableLinkPreview: boolean; +} + +const urlRegex = /(https?:\/\/[^\s]+)/g; + +const handleLinkPress = (url: string) => { + Linking.openURL(url).catch((err) => console.error('Error opening URL:', err)); +}; + +export const CustomBubbleWithLinkPreview: React.FC< + ICustomBubbleWithLinkPreviewProps +> = (props) => { + const { + bubbleMessage, + customBubbleWithLinkPreviewStyles, + customBubbleWithLinkPreview, + enableLinkPreview, + } = props; + const { currentMessage } = bubbleMessage; + const urls = currentMessage?.text.match(urlRegex); + + const { + customContainerStyle, + customPreviewContainerStyle, + customLinkTextStyle, + customMessagePreviewStyle, + } = customBubbleWithLinkPreviewStyles || {}; + + const renderTextWithLinks = useCallback( + (text: string) => { + const parts = text.split(urlRegex); + return parts.map((part, index) => { + if (urlRegex.test(part)) { + return ( + handleLinkPress(part)} + style={[styles.linkText, customLinkTextStyle]} + > + {part} + + ); + } + return {part}; + }); + }, + [customLinkTextStyle] + ); + + const renderPreview = useCallback( + (urlsLink: string[]) => { + const firstUrl = urlsLink[0]; + + return ( + + + + {!!currentMessage?.text && + renderTextWithLinks(currentMessage.text)} + + {!!firstUrl && enableLinkPreview && ( + + )} + + + ); + }, + [ + bubbleMessage.position, + currentMessage?.text, + customContainerStyle, + customMessagePreviewStyle, + customPreviewContainerStyle, + enableLinkPreview, + renderTextWithLinks, + ] + ); + + if (!urls) { + return ; + } + + return customBubbleWithLinkPreview + ? customBubbleWithLinkPreview(urls, bubbleMessage) + : renderPreview(urls); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + bubble: { + padding: 15, + maxWidth: '70%', + backgroundColor: '#e1ffc7', + borderRadius: 20, + overflow: 'hidden', + borderColor: '#d3d3d3', + borderWidth: 1, + }, + bubbleContainer: { + flexDirection: 'row', + marginVertical: 5, + }, + flexEnd: { + justifyContent: 'flex-end', + }, + flexStart: { + justifyContent: 'flex-start', + }, + containerPreview: { + padding: 10, + }, + previewContainer: { + backgroundColor: 'white', + borderRadius: 20, + marginTop: 16, + }, + messagePreview: { + color: 'black', + fontSize: 16, + }, + textPreview: { + color: 'blue', + textDecorationLine: 'underline', + }, + linkText: { + color: 'blue', + textDecorationLine: 'underline', + }, +}); diff --git a/src/chat/components/bubble/index.ts b/src/chat/components/bubble/index.ts index 1f52a7e..88e2a65 100644 --- a/src/chat/components/bubble/index.ts +++ b/src/chat/components/bubble/index.ts @@ -1,2 +1,3 @@ export * from './CustomBubble'; export * from './CustomImageVideoBubble'; +export * from './CustomBubbleWithLinkPreview'; diff --git a/src/chat/components/linkPreview/index.tsx b/src/chat/components/linkPreview/index.tsx new file mode 100644 index 0000000..3d177ac --- /dev/null +++ b/src/chat/components/linkPreview/index.tsx @@ -0,0 +1,221 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { + Image, + LayoutAnimation, + LayoutChangeEvent, + Linking, + StyleProp, + StyleSheet, + Text, + TouchableWithoutFeedback, + TouchableWithoutFeedbackProps, + View, + ViewStyle, +} from 'react-native'; +import { PreviewData, PreviewDataImage } from './type'; +import { getPreviewData } from './utils'; + +export interface LinkPreviewProps { + containerStyle?: StyleProp; + enableAnimation?: boolean; + metadataContainerStyle?: StyleProp; + metadataTextContainerStyle?: StyleProp; + onPreviewDataFetched?: (previewData: PreviewData) => void; + previewData?: PreviewData; + requestTimeout?: number; + text: string; + textContainerStyle?: StyleProp; + touchableWithoutFeedbackProps?: TouchableWithoutFeedbackProps; +} + +export const LinkPreview = ({ + containerStyle, + enableAnimation, + metadataContainerStyle, + metadataTextContainerStyle, + onPreviewDataFetched, + previewData, + requestTimeout = 5000, + text, + textContainerStyle, + touchableWithoutFeedbackProps, +}: LinkPreviewProps) => { + const [containerWidth, setContainerWidth] = useState(0); + const [data, setData] = useState(previewData); + const aspectRatio = data?.image + ? data.image.width / data.image.height + : undefined; + + useEffect(() => { + let isCancelled = false; + if (previewData) { + setData(previewData); + return; + } + + const fetchData = async () => { + setData(undefined); + const newData = await getPreviewData(text, requestTimeout); + if (!isCancelled) { + if (enableAnimation) { + LayoutAnimation.easeInEaseOut(); + } + setData(newData); + onPreviewDataFetched?.(newData); + } + }; + + fetchData(); + return () => { + isCancelled = true; + }; + }, [ + enableAnimation, + onPreviewDataFetched, + previewData, + requestTimeout, + text, + ]); + + const handleContainerLayout = useCallback((event: LayoutChangeEvent) => { + setContainerWidth(event.nativeEvent.layout.width); + }, []); + + const handlePress = useCallback(() => { + if (data?.link) { + Linking.openURL(data.link); + } + }, [data?.link]); + + const renderDescriptionNode = useCallback( + (description: string) => ( + + {description} + + ), + [] + ); + + const renderImageNode = useCallback( + (image: PreviewDataImage) => { + const ar = aspectRatio ?? 1; + return ( + + + + ); + }, + [aspectRatio, containerWidth] + ); + + const renderTitleNode = useCallback( + (title: string) => ( + + {title} + + ), + [] + ); + + const renderLinkPreviewNode = useCallback( + () => ( + + + {(data?.description || + (data?.image && + aspectRatio === 1 && + (data?.description || data?.title)) || + data?.title) && ( + + + {data?.title && renderTitleNode(data.title)} + {data?.description && renderDescriptionNode(data.description)} + + + )} + + {data?.image && + (aspectRatio !== 1 || (!data?.description && !data.title)) && + renderImageNode(data.image)} + + ), + [ + textContainerStyle, + data?.description, + data?.image, + data?.title, + aspectRatio, + metadataContainerStyle, + metadataTextContainerStyle, + renderTitleNode, + renderDescriptionNode, + renderImageNode, + ] + ); + + return ( + + + {renderLinkPreviewNode()} + + + ); +}; + +const styles = StyleSheet.create({ + description: { + marginTop: 4, + }, + image: { + alignSelf: 'center', + backgroundColor: '#f7f7f8', + }, + metadataContainer: { + flexDirection: 'row', + marginTop: 16, + }, + metadataTextContainer: { + flex: 1, + }, + textContainer: { + marginHorizontal: 24, + marginVertical: 16, + }, + title: { + fontWeight: 'bold', + }, +}); diff --git a/src/chat/components/linkPreview/type.ts b/src/chat/components/linkPreview/type.ts new file mode 100644 index 0000000..232db47 --- /dev/null +++ b/src/chat/components/linkPreview/type.ts @@ -0,0 +1,19 @@ +interface PreviewData { + description?: string; + image?: PreviewDataImage; + link?: string; + title?: string; +} + +interface PreviewDataImage { + height: number; + url: string; + width: number; +} + +interface Size { + height: number; + width: number; +} + +export { PreviewData, PreviewDataImage, Size }; diff --git a/src/chat/components/linkPreview/utils.ts b/src/chat/components/linkPreview/utils.ts new file mode 100644 index 0000000..7eaa4a5 --- /dev/null +++ b/src/chat/components/linkPreview/utils.ts @@ -0,0 +1,231 @@ +import { Image } from 'react-native'; +import { PreviewData, PreviewDataImage, Size } from './type'; + +export const getActualImageUrl = (baseUrl: string, imageUrl?: string) => { + let actualImageUrl = imageUrl?.trim(); + if (!actualImageUrl || actualImageUrl.startsWith('data')) return; + + if (actualImageUrl.startsWith('//')) + actualImageUrl = `https:${actualImageUrl}`; + + if (!actualImageUrl.startsWith('http')) { + if (baseUrl.endsWith('/') && actualImageUrl.startsWith('/')) { + actualImageUrl = `${baseUrl.slice(0, -1)}${actualImageUrl}`; + } else if (!baseUrl.endsWith('/') && !actualImageUrl.startsWith('/')) { + actualImageUrl = `${baseUrl}/${actualImageUrl}`; + } else { + actualImageUrl = `${baseUrl}${actualImageUrl}`; + } + } + + return actualImageUrl; +}; + +export const getHtmlEntitiesDecodedText = (text?: string) => { + const actualText = text?.trim(); + if (!actualText) return; + + // Define a mapping of common HTML entities to their corresponding characters + const htmlEntities: { [key: string]: string } = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + ''': "'", + // Add more entities as needed + }; + + // Replace the HTML entities in the text with their corresponding characters + return actualText.replace( + /&[a-zA-Z0-9#]+;/g, + (entity) => htmlEntities[entity] || entity + ); +}; + +export const getContent = (left: string, right: string, type: string) => { + const contents = { + [left.trim()]: right, + [right.trim()]: left, + }; + + return contents[type]?.trim(); +}; + +export const getImageSize = (url: string) => { + return new Promise((resolve, reject) => { + Image.getSize( + url, + (width, height) => { + resolve({ height, width }); + }, + // type-coverage:ignore-next-line + (error) => reject(error) + ); + }); +}; + +// Functions below use functions from the same file and mocks are not working +/* istanbul ignore next */ +export const getPreviewData = async (text: string, requestTimeout = 5000) => { + const previewData: PreviewData = { + description: undefined, + image: undefined, + link: undefined, + title: undefined, + }; + + try { + const textWithoutEmails = text.replace(REGEX_EMAIL, '').trim(); + + if (!textWithoutEmails) return previewData; + + const link = textWithoutEmails.match(REGEX_LINK)?.[0]; + + if (!link) return previewData; + + let url = link; + + if (!url.toLowerCase().startsWith('http')) { + url = 'https://' + url; + } + + let abortControllerTimeout: NodeJS.Timeout; + const abortController = new AbortController(); + + const request = fetch(url, { + headers: { + 'User-Agent': + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.36', + }, + signal: abortController.signal, + }); + + abortControllerTimeout = setTimeout(() => { + abortController.abort(); + }, requestTimeout); + + const response = await request; + + clearTimeout(abortControllerTimeout); + + previewData.link = url; + + const contentType = response.headers.get('content-type') ?? ''; + + if (REGEX_IMAGE_CONTENT_TYPE.test(contentType)) { + const image = await getPreviewDataImage(url); + previewData.image = image; + return previewData; + } + + const html = await response.text(); + + // Some pages return undefined + if (!html) return previewData; + + const head = html.substring(0, html.indexOf('( + (acc, curr) => { + if (!curr[2] || !curr[3]) return acc; + + const description = + !acc.description && + (getContent(curr[2], curr[3], 'og:description') || + getContent(curr[2], curr[3], 'description')); + const ogImage = + !acc.imageUrl && getContent(curr[2], curr[3], 'og:image'); + const ogTitle = !acc.title && getContent(curr[2], curr[3], 'og:title'); + + return { + description: description + ? getHtmlEntitiesDecodedText(description) + : acc.description, + imageUrl: ogImage ? getActualImageUrl(url, ogImage) : acc.imageUrl, + title: ogTitle ? getHtmlEntitiesDecodedText(ogTitle) : acc.title, + }; + }, + { title: previewData.title } + ); + + previewData.description = metaPreviewData.description; + previewData.image = await getPreviewDataImage(metaPreviewData.imageUrl); + previewData.title = metaPreviewData.title; + + if (!previewData.image) { + let imageMatches: RegExpMatchArray | null; + const tags: RegExpMatchArray[] = []; + while ((imageMatches = REGEX_IMAGE_TAG.exec(html)) !== null) { + tags.push(imageMatches as RegExpMatchArray); + } + + let images: PreviewDataImage[] = []; + + for (const tag of tags + .filter((t) => t[1] && !t[1].startsWith('data')) + .slice(0, 5)) { + const image = await getPreviewDataImage(getActualImageUrl(url, tag[1])); + + if (!image) continue; + + images = [...images, image]; + } + + previewData.image = images.sort( + (a, b) => b.height * b.width - a.height * a.width + )[0]; + } + + return previewData; + } catch { + return previewData; + } +}; + +export const getPreviewDataImage = async ( + url?: string +): Promise => { + if (!url) return; + + try { + const { height, width } = await getImageSize(url); + const aspectRatio = width / (height || 1); + + const isValidImage = + height > 100 && width > 100 && aspectRatio > 0.1 && aspectRatio < 10; + + if (isValidImage) { + return { height, url, width }; + } + } catch (error) { + console.error('Error fetching image size:', error); + } + + return undefined; +}; + +export const REGEX_EMAIL = + /([a-zA-Z0-9+._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+)/g; +export const REGEX_IMAGE_CONTENT_TYPE = /image\/*/g; +// Consider empty line after img tag and take only the src field, space before to not match data-src for example +export const REGEX_IMAGE_TAG = //g; +export const REGEX_TITLE = /(.*?)<\/title>/g;