From da2f38e0729613dc6499c1eb806df3d406ddd344 Mon Sep 17 00:00:00 2001 From: Rajat Dabade Date: Fri, 12 Sep 2025 14:06:58 +0530 Subject: [PATCH] Mention, embedded link and handling of post update on offline device (#9104) * typescript and view component for permalink with user and message * Old post edited handling in permalink * Added test and update flag value to EnablePermalinkPreview * Added test for permalink_preview component * Added test for content/index.tsx for permalink * Addressed review comments * Unit test for missing file and review comments * Added test to check handlePostEdited permalink sync only calls one time only * Change TouchableOpacity to Pressable * When user not found fetch the user from the server * Removed the redundant test in the test for permalink_preview/index? * ts to tsx * Removed the circular dependency * Address review comments * displayname fallback * remove permalink when permalink post is deleted * UX review comments * Linter fixes * Test fixes * File attachment in permalink preview component * Fix the width and height of the image in permalink * Added gredient when exceeds height of permalink container * Minor * Updated tests * Minor * Review comments * Minor review comments * type fixes * Review comments * Minor * Mention ability in permalink preview * Support for external link in permalink * Handle device not connected update permalink post * test fixes * Address review comments * Minor * Merge fixes * Addressed review comments * Fix content.test.tsx after permalink branch merge - Updated test expectations to use embedData prop instead of post prop - Fixed assertions to match the new PermalinkPreview API - All content tests now pass * review comments * Review comments * Some more review comments * Minor * Fixed the undefined issue for opengraph component metadata * More fixes * type chech for site name * linter fixes * UX review and remove show more height not require * Fix tests * Review nitpick and fixes * Minor UX changes --------- Co-authored-by: yasserfaraazkhan --- .../post/body/content/content.test.tsx | 6 + .../message_attachments/attachment_author.tsx | 18 +- .../message_attachments/attachment_title.tsx | 18 +- .../post/body/content/opengraph/opengraph.tsx | 60 ++--- .../external_link_preview.test.tsx | 212 ++++++++++++++++++ .../external_link_preview.tsx | 125 +++++++++++ .../content/permalink_preview/index.test.tsx | 55 +++++ .../permalink_preview.test.tsx | 85 ++++++- .../permalink_preview/permalink_preview.tsx | 16 +- app/hooks/use_external_link_handler.ts | 23 ++ assets/base/i18n/en.json | 1 + 11 files changed, 557 insertions(+), 62 deletions(-) create mode 100644 app/components/post_list/post/body/content/permalink_preview/external_link_preview.test.tsx create mode 100644 app/components/post_list/post/body/content/permalink_preview/external_link_preview.tsx create mode 100644 app/hooks/use_external_link_handler.ts diff --git a/app/components/post_list/post/body/content/content.test.tsx b/app/components/post_list/post/body/content/content.test.tsx index 6930946c7..15d8b580d 100644 --- a/app/components/post_list/post/body/content/content.test.tsx +++ b/app/components/post_list/post/body/content/content.test.tsx @@ -95,6 +95,7 @@ describe('components/post_list/post/body/content/Content - PermalinkPreview', () ); const permalinkPreview = getByTestId('permalink-preview'); + expect(permalinkPreview.props.embedData).toBeDefined(); expect(permalinkPreview.props.embedData).toEqual(permalinkEmbedData); }); @@ -134,6 +135,7 @@ describe('components/post_list/post/body/content/Content - PermalinkPreview', () ); const permalinkPreview = getByTestId('permalink-preview'); + expect(permalinkPreview.props.embedData).toBeDefined(); expect(permalinkPreview.props.embedData).toEqual(customEmbedData); }); @@ -202,6 +204,7 @@ describe('components/post_list/post/body/content/Content - PermalinkPreview', () ); const permalinkPreview = getByTestId('permalink-preview'); + expect(permalinkPreview.props.embedData).toBeDefined(); expect(permalinkPreview.props.embedData).toEqual(firstEmbedData); }); @@ -234,6 +237,7 @@ describe('components/post_list/post/body/content/Content - PermalinkPreview', () ); const permalinkPreview = getByTestId('permalink-preview'); + expect(permalinkPreview.props.embedData).toBeDefined(); expect(permalinkPreview.props.embedData).toEqual(permalinkEmbedData); }); @@ -271,6 +275,7 @@ describe('components/post_list/post/body/content/Content - PermalinkPreview', () ); const permalinkPreview = getByTestId('permalink-preview'); + expect(permalinkPreview.props.embedData).toBeDefined(); expect(permalinkPreview.props.embedData).toEqual(dmEmbedData); expect(permalinkPreview.props.embedData.channel_type).toBe('D'); }); @@ -309,6 +314,7 @@ describe('components/post_list/post/body/content/Content - PermalinkPreview', () ); const permalinkPreview = getByTestId('permalink-preview'); + expect(permalinkPreview.props.embedData).toBeDefined(); expect(permalinkPreview.props.embedData).toEqual(gmEmbedData); expect(permalinkPreview.props.embedData.channel_type).toBe('G'); }); diff --git a/app/components/post_list/post/body/content/message_attachments/attachment_author.tsx b/app/components/post_list/post/body/content/message_attachments/attachment_author.tsx index 34a25c55d..7d9d5634d 100644 --- a/app/components/post_list/post/body/content/message_attachments/attachment_author.tsx +++ b/app/components/post_list/post/body/content/message_attachments/attachment_author.tsx @@ -2,13 +2,11 @@ // See LICENSE.txt for license information. import {Image} from 'expo-image'; -import React, {useCallback} from 'react'; -import {useIntl} from 'react-intl'; +import React from 'react'; import {Text, View} from 'react-native'; +import {useExternalLinkHandler} from '@hooks/use_external_link_handler'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; -import {tryOpenURL} from '@utils/url'; -import {onOpenLinkError} from '@utils/url/links'; type Props = { icon?: string; @@ -37,18 +35,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }); const AttachmentAuthor = ({icon, link, name, theme}: Props) => { - const intl = useIntl(); const style = getStyleSheet(theme); - - const openLink = useCallback(() => { - if (link) { - const onError = () => { - onOpenLinkError(intl); - }; - - tryOpenURL(link, onError); - } - }, [intl, link]); + const openLink = useExternalLinkHandler(link); return ( diff --git a/app/components/post_list/post/body/content/message_attachments/attachment_title.tsx b/app/components/post_list/post/body/content/message_attachments/attachment_title.tsx index c082abd05..d332b711c 100644 --- a/app/components/post_list/post/body/content/message_attachments/attachment_title.tsx +++ b/app/components/post_list/post/body/content/message_attachments/attachment_title.tsx @@ -1,14 +1,12 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback} from 'react'; -import {useIntl} from 'react-intl'; +import React from 'react'; import {Text, View} from 'react-native'; import Markdown from '@components/markdown'; +import {useExternalLinkHandler} from '@hooks/use_external_link_handler'; import {makeStyleSheetFromTheme} from '@utils/theme'; -import {tryOpenURL} from '@utils/url'; -import {onOpenLinkError} from '@utils/url/links'; import type {AvailableScreens} from '@typings/screens/navigation'; @@ -39,18 +37,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }); const AttachmentTitle = ({channelId, link, location, theme, value}: Props) => { - const intl = useIntl(); const style = getStyleSheet(theme); - - const openLink = useCallback(() => { - if (link) { - const onError = () => { - onOpenLinkError(intl); - }; - - tryOpenURL(link, onError); - } - }, [intl, link]); + const openLink = useExternalLinkHandler(link); let title; if (link) { diff --git a/app/components/post_list/post/body/content/opengraph/opengraph.tsx b/app/components/post_list/post/body/content/opengraph/opengraph.tsx index 1f621c63d..b3ede7533 100644 --- a/app/components/post_list/post/body/content/opengraph/opengraph.tsx +++ b/app/components/post_list/post/body/content/opengraph/opengraph.tsx @@ -1,13 +1,13 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback} from 'react'; -import {useIntl} from 'react-intl'; +import React from 'react'; import {Text, TouchableOpacity, View} from 'react-native'; +import {useExternalLinkHandler} from '@hooks/use_external_link_handler'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; -import {tryOpenURL} from '@utils/url'; -import {onOpenLinkError} from '@utils/url/links'; + +import ExternalLinkPreview from '../permalink_preview/external_link_preview'; import OpengraphImage from './opengraph_image'; @@ -19,6 +19,7 @@ type OpengraphProps = { postId: string; showLinkPreviews: boolean; theme: Theme; + isEmbedded?: boolean; } const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { @@ -60,9 +61,8 @@ const selectOpenGraphData = (url: string, metadata: PostMetadata | undefined | n })?.data; }; -const Opengraph = ({isReplyPost, layoutWidth, location, metadata, postId, showLinkPreviews, theme}: OpengraphProps) => { - const intl = useIntl(); - const link = metadata?.embeds![0]!.url || ''; +const Opengraph = ({isReplyPost, layoutWidth, location, metadata, postId, showLinkPreviews, theme, isEmbedded}: OpengraphProps) => { + const link = metadata?.embeds?.[0]?.url || ''; const openGraphData = selectOpenGraphData(link, metadata); if (!showLinkPreviews || !openGraphData) { @@ -76,13 +76,7 @@ const Opengraph = ({isReplyPost, layoutWidth, location, metadata, postId, showLi openGraphData.images.length && metadata?.images); - const goToLink = useCallback(() => { - const onError = () => { - onOpenLinkError(intl); - }; - - tryOpenURL(link, onError); - }, [intl, link]); + const goToLink = useExternalLinkHandler(link); let siteName; if (openGraphData.site_name) { @@ -136,22 +130,28 @@ const Opengraph = ({isReplyPost, layoutWidth, location, metadata, postId, showLi } return ( - - {siteName} - {siteTitle} - {siteDescription} - {hasImage && - - } - + <> + {isEmbedded ? ( + + ) : ( + + {siteName} + {siteTitle} + {siteDescription} + {hasImage && + + } + + )} + ); }; diff --git a/app/components/post_list/post/body/content/permalink_preview/external_link_preview.test.tsx b/app/components/post_list/post/body/content/permalink_preview/external_link_preview.test.tsx new file mode 100644 index 000000000..e9ce7b8a4 --- /dev/null +++ b/app/components/post_list/post/body/content/permalink_preview/external_link_preview.test.tsx @@ -0,0 +1,212 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {fireEvent} from '@testing-library/react-native'; +import React from 'react'; + +import DatabaseManager from '@database/manager'; +import {renderWithEverything} from '@test/intl-test-helper'; +import TestHelper from '@test/test_helper'; +import * as UrlLinks from '@utils/url/links'; + +import ExternalLinkPreview from './external_link_preview'; + +import type ServerDataOperator from '@database/operator/server_data_operator'; +import type {Database} from '@nozbe/watermelondb'; + +describe('components/post_list/post/body/content/permalink_preview/ExternalLinkPreview', () => { + const serverUrl = 'http://localhost:8065'; + let database: Database; + let operator: ServerDataOperator; + const mockOpenLink = jest.spyOn(UrlLinks, 'openLink').mockImplementation(() => Promise.resolve()); + + beforeEach(async () => { + await DatabaseManager.init([serverUrl]); + const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + database = serverDatabaseAndOperator.database; + operator = serverDatabaseAndOperator.operator; + + // Add a current user + const currentUser = TestHelper.fakeUser({id: 'current-user', locale: 'en'}); + await operator.handleUsers({users: [currentUser], prepareRecordsOnly: false}); + await operator.handleSystem({ + systems: [{id: 'currentUserId', value: currentUser.id}], + prepareRecordsOnly: false, + }); + mockOpenLink.mockClear(); + }); + + afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); + }); + + it('should not render when no embeds are provided', () => { + const {queryByTestId} = renderWithEverything( + , + {database, serverUrl}, + ); + + expect(queryByTestId('external-link-preview')).toBeNull(); + }); + + it('should not render when embeds array is empty', () => { + const {queryByTestId} = renderWithEverything( + , + {database, serverUrl}, + ); + + expect(queryByTestId('external-link-preview')).toBeNull(); + }); + + it('should not render when no opengraph embed is present', () => { + const embeds = [{ + type: 'image' as PostEmbedType, + url: 'https://example.com/image.jpg', + data: {}, + }]; + + const {queryByTestId} = renderWithEverything( + , + {database, serverUrl}, + ); + + expect(queryByTestId('external-link-preview')).toBeNull(); + }); + + it('should render external link when opengraph embed is present', () => { + const embeds = [{ + type: 'opengraph' as PostEmbedType, + url: 'https://example.com', + data: { + title: 'Example Website', + description: 'This is an example website', + site_name: 'Example', + }, + }]; + + const {getByTestId, getByText} = renderWithEverything( + , + {database, serverUrl}, + ); + + expect(getByTestId('external-link-preview')).toBeTruthy(); + expect(getByText('Example Website')).toBeTruthy(); + expect(getByText('This is an example website')).toBeTruthy(); + }); + + it('should show fallback text when link data is incomplete', () => { + const embeds = [{ + type: 'opengraph' as PostEmbedType, + url: 'https://example.com', + data: {}, + }]; + + const {getByText} = renderWithEverything( + , + {database, serverUrl}, + ); + + expect(getByText('External Link')).toBeTruthy(); + expect(getByText('https://example.com')).toBeTruthy(); + }); + + it('should prioritize title over site_name', () => { + const embeds = [{ + type: 'opengraph' as PostEmbedType, + url: 'https://example.com', + data: { + title: 'Page Title', + site_name: 'Site Name', + description: 'Description text', + }, + }]; + + const {getByText, queryByText} = renderWithEverything( + , + {database, serverUrl}, + ); + + expect(getByText('Page Title')).toBeTruthy(); + expect(queryByText('Site Name')).toBeNull(); + expect(getByText('Description text')).toBeTruthy(); + }); + + it('should use site_name when title is not available', () => { + const embeds = [{ + type: 'opengraph' as PostEmbedType, + url: 'https://example.com', + data: { + site_name: 'Site Name', + description: 'Description text', + }, + }]; + + const {getByText} = renderWithEverything( + , + {database, serverUrl}, + ); + + expect(getByText('Site Name')).toBeTruthy(); + expect(getByText('Description text')).toBeTruthy(); + }); + + it('should show URL when description is not available', () => { + const embeds = [{ + type: 'opengraph' as PostEmbedType, + url: 'https://example.com', + data: { + title: 'Page Title', + }, + }]; + + const {getByText} = renderWithEverything( + , + {database, serverUrl}, + ); + + expect(getByText('Page Title')).toBeTruthy(); + expect(getByText('https://example.com')).toBeTruthy(); + }); + + it('should handle press event and open URL', () => { + const embeds = [{ + type: 'opengraph' as PostEmbedType, + url: 'https://example.com', + data: { + title: 'Example Website', + description: 'Example description', + }, + }]; + + const {getByTestId} = renderWithEverything( + , + {database, serverUrl}, + ); + + const linkContainer = getByTestId('external-link-preview'); + fireEvent.press(linkContainer); + + expect(mockOpenLink).toHaveBeenCalledWith('https://example.com', 'http://localhost:8065', '', expect.any(Object)); + }); + + it('should use custom testID when provided', () => { + const embeds = [{ + type: 'opengraph' as PostEmbedType, + url: 'https://example.com', + data: { + title: 'Example Website', + }, + }]; + + const {getByTestId, queryByTestId} = renderWithEverything( + , + {database, serverUrl}, + ); + + expect(getByTestId('custom-test-id')).toBeTruthy(); + expect(queryByTestId('external-link-preview')).toBeNull(); + }); +}); diff --git a/app/components/post_list/post/body/content/permalink_preview/external_link_preview.tsx b/app/components/post_list/post/body/content/permalink_preview/external_link_preview.tsx new file mode 100644 index 000000000..786dd7f51 --- /dev/null +++ b/app/components/post_list/post/body/content/permalink_preview/external_link_preview.tsx @@ -0,0 +1,125 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useMemo} from 'react'; +import {Text, TouchableOpacity} from 'react-native'; + +import FormattedText from '@components/formatted_text'; +import {useTheme} from '@context/theme'; +import {useExternalLinkHandler} from '@hooks/use_external_link_handler'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +type ExternalLinkData = { + url: string; + data: Record; +}; + +type ExternalLinkPreviewProps = { + embeds?: PostEmbed[]; + testID?: string; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + externalLinkContainer: { + backgroundColor: theme.centerChannelBg, + borderColor: changeOpacity(theme.centerChannelColor, 0.16), + borderWidth: 1, + borderRadius: 4, + padding: 12, + marginBottom: 12, + }, + externalLinkTitle: { + color: theme.linkColor, + ...typography('Body', 100, 'SemiBold'), + marginBottom: 4, + }, + externalLinkUrl: { + color: changeOpacity(theme.centerChannelColor, 0.64), + ...typography('Body', 75), + }, + }; +}); + +const ExternalLinkPreview = ({embeds, testID = 'external-link-preview'}: ExternalLinkPreviewProps) => { + const theme = useTheme(); + const styles = getStyleSheet(theme); + + const externalLinkData = useMemo((): ExternalLinkData | null => { + if (!embeds || embeds.length === 0) { + return null; + } + + const linkEmbed = embeds.find((embed) => embed.type === 'opengraph'); + if (!linkEmbed) { + return null; + } + + return { + url: linkEmbed.url, + data: linkEmbed.data, + }; + }, [embeds]); + + const displayTitle = useMemo(() => { + if (!externalLinkData?.data) { + return null; + } + + const title = (typeof externalLinkData.data.title === 'string' && externalLinkData.data.title.trim()) ? externalLinkData.data.title : null; + const siteName = (typeof externalLinkData.data.site_name === 'string' && externalLinkData.data.site_name.trim()) ? externalLinkData.data.site_name : null; + + return title || siteName; + }, [externalLinkData?.data]); + + const displayDescription = useMemo(() => { + if (!externalLinkData?.data) { + return externalLinkData?.url || ''; + } + + const description = (typeof externalLinkData.data.description === 'string' && externalLinkData.data.description.trim()) ? externalLinkData.data.description : null; + return description || externalLinkData.url; + }, [externalLinkData?.data, externalLinkData?.url]); + + const handleExternalLinkPress = useExternalLinkHandler(externalLinkData?.url); + + if (!externalLinkData) { + return null; + } + + return ( + + {displayTitle ? ( + + {displayTitle} + + ) : ( + + )} + + {displayDescription} + + + ); +}; + +export default ExternalLinkPreview; diff --git a/app/components/post_list/post/body/content/permalink_preview/index.test.tsx b/app/components/post_list/post/body/content/permalink_preview/index.test.tsx index 4b0188ae1..ec55c5681 100644 --- a/app/components/post_list/post/body/content/permalink_preview/index.test.tsx +++ b/app/components/post_list/post/body/content/permalink_preview/index.test.tsx @@ -14,6 +14,34 @@ import EnhancedPermalinkPreview from './index'; import type ServerDataOperator from '@database/operator/server_data_operator'; import type {Database} from '@nozbe/watermelondb'; +import type PostModel from '@typings/database/models/servers/post'; + +const createPostWithPermalinkEmbed = async ( + operator: ServerDataOperator, + database: Database, + embedData: PermalinkEmbedData, +): Promise => { + const postWithPermalink = TestHelper.fakePost({ + id: `referencing-post-${Date.now()}`, + metadata: { + embeds: [{ + type: 'permalink' as PostEmbedType, + url: '', + data: embedData, + }], + }, + }); + + const models = await operator.handlePosts({ + actionType: 'POSTS.RECEIVED_NEW' as 'POSTS.RECEIVED_NEW', + order: [postWithPermalink.id], + posts: [postWithPermalink], + prepareRecordsOnly: true, + }); + await operator.batchRecords(models, 'test'); + + return await database.get('Post').find(postWithPermalink.id) as PostModel; +}; jest.mock('./permalink_preview', () => ({ __esModule: true, @@ -75,6 +103,25 @@ describe('PermalinkPreview Enhanced Component', () => { channel_id: 'channel-123', }; + const postWithPermalink = TestHelper.fakePost({ + id: 'referencing-post', + metadata: { + embeds: [{ + type: 'permalink' as PostEmbedType, + url: '', + data: embedData, + }], + }, + }); + + const models = await operator.handlePosts({ + actionType: 'POSTS.RECEIVED_NEW' as 'POSTS.RECEIVED_NEW', + order: [postWithPermalink.id], + posts: [postWithPermalink], + prepareRecordsOnly: true, + }); + await operator.batchRecords(models, 'test'); + const {getByTestId} = renderWithEverything( { channel_id: 'channel-123', }; + await createPostWithPermalinkEmbed(operator, database, embedData); + const {getByTestId} = renderWithEverything( { channel_id: 'channel-123', }; + await createPostWithPermalinkEmbed(operator, database, embedData); + const {getByTestId} = renderWithEverything( { channel_id: 'channel-123', }; + await createPostWithPermalinkEmbed(operator, database, embedData); + const {getByTestId} = renderWithEverything( { channel_id: 'channel-123', }; + await createPostWithPermalinkEmbed(operator, database, embedData); + const {getByTestId} = renderWithEverything( {isEdited ? ( + + {hasFiles && post && ( { + const intl = useIntl(); + const serverUrl = useServerUrl(); + + return useCallback(() => { + if (!url || !serverUrl) { + return; + } + + openLink(url, serverUrl, siteURL || '', intl); + }, [intl, url, serverUrl, siteURL]); +}; + +export default useExternalLinkHandler; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 23c93bd24..5fa1fb4f2 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -772,6 +772,7 @@ "mobile.pdf_viewer.password_limit_reached": "You’ve entered the wrong password too many times.", "mobile.pdf_viewer.password_required": "This document is password protected.", "mobile.pdf_viewer.unlock": "Unlock", + "mobile.permalink_preview.external_link": "External Link", "mobile.permalink_preview.originally_posted": "Originally posted in ", "mobile.permission_denied_dismiss": "Don't Allow", "mobile.permission_denied_retry": "Settings",