From 113f9d942cae160c968132d3d68b4ba9a0d65ee1 Mon Sep 17 00:00:00 2001 From: Rajat Dabade Date: Wed, 10 Sep 2025 17:12:16 +0530 Subject: [PATCH] Typescript support and view component for permalink with user and message (#9052) * 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 * Address review comments * Minor * Some more review comments --------- Co-authored-by: yasserfaraazkhan --- app/actions/websocket/posts.test.ts | 139 +++++++- app/actions/websocket/posts.ts | 20 +- .../index.test.tsx | 0 .../index.tsx | 0 app/components/jumbo_emoji/index.tsx | 2 +- app/components/markdown/markdown.tsx | 2 +- .../post/body/content/content.test.tsx | 315 ++++++++++++++++++ .../post_list/post/body/content/content.tsx | 126 +++++++ .../post_list/post/body/content/index.tsx | 116 +------ .../content/permalink_preview/index.test.tsx | 269 +++++++++++++++ .../body/content/permalink_preview/index.tsx | 44 +++ .../permalink_preview.test.tsx | 239 +++++++++++++ .../permalink_preview/permalink_preview.tsx | 235 +++++++++++++ app/queries/servers/post.test.ts | 281 ++++++++++++++++ app/queries/servers/post.ts | 90 +++++ app/utils/permalink_sync.test.ts | 249 ++++++++++++++ app/utils/permalink_sync.ts | 65 ++++ assets/base/i18n/en.json | 1 + types/api/config.d.ts | 1 + types/api/posts.d.ts | 11 +- 20 files changed, 2085 insertions(+), 120 deletions(-) rename app/components/{EditedIndicator => edited_indicator}/index.test.tsx (100%) rename app/components/{EditedIndicator => edited_indicator}/index.tsx (100%) create mode 100644 app/components/post_list/post/body/content/content.test.tsx create mode 100644 app/components/post_list/post/body/content/content.tsx create mode 100644 app/components/post_list/post/body/content/permalink_preview/index.test.tsx create mode 100644 app/components/post_list/post/body/content/permalink_preview/index.tsx create mode 100644 app/components/post_list/post/body/content/permalink_preview/permalink_preview.test.tsx create mode 100644 app/components/post_list/post/body/content/permalink_preview/permalink_preview.tsx create mode 100644 app/queries/servers/post.test.ts create mode 100644 app/utils/permalink_sync.test.ts create mode 100644 app/utils/permalink_sync.ts diff --git a/app/actions/websocket/posts.test.ts b/app/actions/websocket/posts.test.ts index a1175d7b8..78e183016 100644 --- a/app/actions/websocket/posts.test.ts +++ b/app/actions/websocket/posts.test.ts @@ -15,7 +15,7 @@ import {PostTypes} from '@constants/post'; import DatabaseManager from '@database/manager'; import {PostsInChannelModel} from '@database/models/server'; import {getChannelById, getMyChannel} from '@queries/servers/channel'; -import {getPostById} from '@queries/servers/post'; +import {getPostById, syncPermalinkPreviewsForEditedPost} from '@queries/servers/post'; import {getCurrentUserId, getCurrentChannelId} from '@queries/servers/system'; import {getIsCRTEnabled} from '@queries/servers/thread'; import EphemeralStore from '@store/ephemeral_store'; @@ -44,6 +44,7 @@ jest.mock('@utils/post', () => ({ ...jest.requireActual('@utils/post'), shouldIgnorePost: jest.fn(), })); +jest.mock('@utils/permalink_sync'); const serverUrl = 'baseHandler.test.com'; @@ -75,6 +76,7 @@ describe('WebSocket Post Actions', () => { const mockedAddPostAcknowledgement = jest.mocked(addPostAcknowledgement); const mockedFetchMissingProfilesByIds = jest.mocked(fetchMissingProfilesByIds); const mockedRemovePostAcknowledgement = jest.mocked(removePostAcknowledgement); + const mockedSyncPermalinkPreviewsForEditedPost = jest.mocked(syncPermalinkPreviewsForEditedPost); beforeEach(async () => { await DatabaseManager.init([serverUrl]); @@ -277,30 +279,102 @@ describe('WebSocket Post Actions', () => { }); describe('handlePostEdited', () => { + const editedPost = {id: 'post1', channel_id: 'channel1', user_id: 'user1', is_pinned: false, edit_at: 54321, message: 'edited message'}; const msg = { data: { - post: JSON.stringify({id: 'post1', channel_id: 'channel1', user_id: 'user1', is_pinned: false}), + post: JSON.stringify(editedPost), }, } as WebSocketMessage; - it('should handle post edited event', async () => { - const batchRecordsSpy = jest.spyOn(operator, 'batchRecords').mockImplementation(jest.fn()); + const permalinkPostModel = TestHelper.fakePostModel({id: 'permalink_post', channelId: 'channel2'}); - mockedGetPostById.mockResolvedValue(postModels[0]); + beforeEach(() => { + mockedSyncPermalinkPreviewsForEditedPost.mockResolvedValue([]); mockedFetchPostAuthors.mockResolvedValue({authors: []}); mockedGetIsCRTEnabled.mockResolvedValue(false); + }); + + it('should handle post edited event - post exists locally', async () => { + const batchRecordsSpy = jest.spyOn(operator, 'batchRecords'); + + mockedGetPostById.mockResolvedValue(postModels[0]); + mockedSyncPermalinkPreviewsForEditedPost.mockResolvedValue([]); await handlePostEdited(serverUrl, msg); + expect(mockedSyncPermalinkPreviewsForEditedPost).toHaveBeenCalledWith(expect.any(Object), editedPost); + expect(mockedGetPostById).toHaveBeenCalledWith(expect.any(Object), 'post1'); expect(mockedFetchChannelStats).toHaveBeenCalledWith(serverUrl, 'channel1'); expect(batchRecordsSpy).toHaveBeenCalledWith([expect.any(PostsInChannelModel)], 'handlePostEdited'); }); + it('should handle post edited event - post exists with permalink updates', async () => { + const batchRecordsSpy = jest.spyOn(operator, 'batchRecords'); + + mockedGetPostById.mockResolvedValue(postModels[0]); + mockedSyncPermalinkPreviewsForEditedPost.mockResolvedValue([permalinkPostModel]); + + await handlePostEdited(serverUrl, msg); + + expect(mockedSyncPermalinkPreviewsForEditedPost).toHaveBeenCalledWith(expect.any(Object), editedPost); + expect(batchRecordsSpy).toHaveBeenCalledWith( + expect.arrayContaining([ + permalinkPostModel, + expect.any(PostsInChannelModel), + ]), + 'handlePostEdited', + ); + }); + + it('should handle post edited event - post missing but has permalink updates (key fix)', async () => { + const batchRecordsSpy = jest.spyOn(operator, 'batchRecords'); + const addEditingPostSpy = jest.spyOn(EphemeralStore, 'addEditingPost'); + + mockedGetPostById.mockResolvedValue(undefined); + mockedSyncPermalinkPreviewsForEditedPost.mockResolvedValue([permalinkPostModel]); + + await handlePostEdited(serverUrl, msg); + + expect(mockedSyncPermalinkPreviewsForEditedPost).toHaveBeenCalledWith(expect.any(Object), editedPost); + expect(addEditingPostSpy).toHaveBeenCalledWith(serverUrl, editedPost); + + expect(batchRecordsSpy).toHaveBeenCalledWith([permalinkPostModel], 'handlePostEdited - permalink sync only'); + }); + + it('should handle post edited event - post missing and no permalink updates', async () => { + const batchRecordsSpy = jest.spyOn(operator, 'batchRecords'); + const addEditingPostSpy = jest.spyOn(EphemeralStore, 'addEditingPost'); + + mockedGetPostById.mockResolvedValue(undefined); + mockedSyncPermalinkPreviewsForEditedPost.mockResolvedValue([]); + + await handlePostEdited(serverUrl, msg); + + expect(mockedSyncPermalinkPreviewsForEditedPost).toHaveBeenCalledWith(expect.any(Object), editedPost); + expect(addEditingPostSpy).toHaveBeenCalledWith(serverUrl, editedPost); + + expect(batchRecordsSpy).not.toHaveBeenCalled(); + }); + + it('should handle permalink sync errors gracefully', async () => { + const batchRecordsSpy = jest.spyOn(operator, 'batchRecords'); + const logWarningSpy = jest.spyOn(require('@utils/log'), 'logWarning'); + + mockedGetPostById.mockResolvedValue(postModels[0]); + mockedSyncPermalinkPreviewsForEditedPost.mockRejectedValue(new Error('Permalink sync failed')); + + await handlePostEdited(serverUrl, msg); + + expect(logWarningSpy).toHaveBeenCalledWith('Failed to sync permalink previews for edited post:', expect.any(Error)); + expect(batchRecordsSpy).toHaveBeenCalledWith([expect.any(PostsInChannelModel)], 'handlePostEdited'); + }); + it('should handle post edited event - no operator', async () => { const batchRecordsSpy = jest.spyOn(operator, 'batchRecords'); await handlePostEdited('junk', msg); + expect(mockedSyncPermalinkPreviewsForEditedPost).not.toHaveBeenCalled(); expect(mockedGetPostById).not.toHaveBeenCalled(); expect(batchRecordsSpy).not.toHaveBeenCalled(); }); @@ -308,30 +382,71 @@ describe('WebSocket Post Actions', () => { it('should handle post edited event - malformed post data', async () => { const batchRecordsSpy = jest.spyOn(operator, 'batchRecords'); - mockedGetPostById.mockResolvedValueOnce(postModels[0]); - await handlePostEdited(serverUrl, {data: undefined} as WebSocketMessage); + expect(mockedSyncPermalinkPreviewsForEditedPost).not.toHaveBeenCalled(); expect(mockedGetPostById).not.toHaveBeenCalled(); expect(batchRecordsSpy).not.toHaveBeenCalled(); }); it('should not update create_at for ephemeral messages', async () => { const batchRecordsSpy = jest.spyOn(operator, 'batchRecords').mockImplementation(jest.fn()); + const ephemeralPost = TestHelper.fakePost({type: PostTypes.EPHEMERAL, create_at: 0}); const ephemeralMsg = { - data: {post: JSON.stringify( - TestHelper.fakePost({type: PostTypes.EPHEMERAL, create_at: 0}), - )}, + data: {post: JSON.stringify(ephemeralPost)}, } as WebSocketMessage; mockedGetPostById.mockResolvedValueOnce(postModels[0]); - mockedFetchPostAuthors.mockResolvedValue({authors: []}); - mockedGetIsCRTEnabled.mockResolvedValue(false); + mockedSyncPermalinkPreviewsForEditedPost.mockResolvedValue([]); await handlePostEdited(serverUrl, ephemeralMsg); expect(batchRecordsSpy).toHaveBeenCalledWith(expect.arrayContaining([expect.objectContaining({createAt: 12345})]), 'handlePostEdited'); }); + + it('should not double batch permalink models in handlePostEdited', async () => { + const batchRecordsSpy = jest.spyOn(operator, 'batchRecords'); + + mockedGetPostById.mockResolvedValue(postModels[0]); + mockedSyncPermalinkPreviewsForEditedPost.mockResolvedValue([permalinkPostModel]); + + await handlePostEdited(serverUrl, msg); + + expect(batchRecordsSpy).toHaveBeenCalledTimes(1); + + expect(batchRecordsSpy).toHaveBeenCalledWith( + expect.arrayContaining([ + permalinkPostModel, + expect.any(PostsInChannelModel), + ]), + 'handlePostEdited', + ); + + expect(batchRecordsSpy).not.toHaveBeenCalledWith( + [permalinkPostModel], + 'handlePostEdited - permalink sync only', + ); + }); + + it('should batch only permalink models when post does not exist locally', async () => { + const batchRecordsSpy = jest.spyOn(operator, 'batchRecords'); + + mockedGetPostById.mockResolvedValue(undefined); + mockedSyncPermalinkPreviewsForEditedPost.mockResolvedValue([permalinkPostModel]); + + await handlePostEdited(serverUrl, msg); + + expect(batchRecordsSpy).toHaveBeenCalledTimes(1); + expect(batchRecordsSpy).toHaveBeenCalledWith( + [permalinkPostModel], + 'handlePostEdited - permalink sync only', + ); + + expect(batchRecordsSpy).not.toHaveBeenCalledWith( + expect.anything(), + 'handlePostEdited', + ); + }); }); describe('handlePostDeleted', () => { diff --git a/app/actions/websocket/posts.ts b/app/actions/websocket/posts.ts index b3df039c5..4254b7813 100644 --- a/app/actions/websocket/posts.ts +++ b/app/actions/websocket/posts.ts @@ -15,12 +15,13 @@ import {ActionType, Events, Screens} from '@constants'; import {PostTypes} from '@constants/post'; import DatabaseManager from '@database/manager'; import {getChannelById, getMyChannel} from '@queries/servers/channel'; -import {getPostById} from '@queries/servers/post'; +import {getPostById, syncPermalinkPreviewsForEditedPost} from '@queries/servers/post'; import {getCurrentChannelId, getCurrentTeamId, getCurrentUserId} from '@queries/servers/system'; import {getIsCRTEnabled} from '@queries/servers/thread'; import EphemeralStore from '@store/ephemeral_store'; import NavigationStore from '@store/navigation_store'; import {hasArrayChanged, isTablet} from '@utils/helpers'; +import {logWarning} from '@utils/log'; import {isFromWebhook, isSystemMessage, shouldIgnorePost} from '@utils/post'; import type {Model} from '@nozbe/watermelondb'; @@ -208,14 +209,29 @@ export async function handlePostEdited(serverUrl: string, msg: WebSocketMessage) return; } - const models: Model[] = []; + const permalinkModels: Model[] = []; + try { + const updatedPermalinkPosts = await syncPermalinkPreviewsForEditedPost(database, post); + if (updatedPermalinkPosts.length) { + permalinkModels.push(...updatedPermalinkPosts); + } + } catch (error) { + logWarning('Failed to sync permalink previews for edited post:', error); + } const oldPost = await getPostById(database, post.id); if (!oldPost) { EphemeralStore.addEditingPost(serverUrl, post); + + // If we have permalink updates but no post to process, batch just the permalinks + if (permalinkModels.length) { + operator.batchRecords(permalinkModels, 'handlePostEdited - permalink sync only'); + } return; } + const models: Model[] = [...permalinkModels]; + if (post.type === PostTypes.EPHEMERAL && post.create_at === 0) { // Updated ephemeral messages don't have a create_at value // since the server has no persistence for ephemeral messages, diff --git a/app/components/EditedIndicator/index.test.tsx b/app/components/edited_indicator/index.test.tsx similarity index 100% rename from app/components/EditedIndicator/index.test.tsx rename to app/components/edited_indicator/index.test.tsx diff --git a/app/components/EditedIndicator/index.tsx b/app/components/edited_indicator/index.tsx similarity index 100% rename from app/components/EditedIndicator/index.tsx rename to app/components/edited_indicator/index.tsx diff --git a/app/components/jumbo_emoji/index.tsx b/app/components/jumbo_emoji/index.tsx index 7488c7271..c4b93adc1 100644 --- a/app/components/jumbo_emoji/index.tsx +++ b/app/components/jumbo_emoji/index.tsx @@ -6,7 +6,7 @@ import Renderer from 'commonmark-react-renderer'; import React, {type ReactElement, useRef} from 'react'; import {type StyleProp, StyleSheet, Text, type TextStyle, View} from 'react-native'; -import EditedIndicator from '@components/EditedIndicator'; +import EditedIndicator from '@components/edited_indicator'; import Emoji from '@components/emoji'; import {useTheme} from '@context/theme'; diff --git a/app/components/markdown/markdown.tsx b/app/components/markdown/markdown.tsx index f39473a74..614c157a6 100644 --- a/app/components/markdown/markdown.tsx +++ b/app/components/markdown/markdown.tsx @@ -9,7 +9,7 @@ import React, {type ReactElement, useMemo, useRef} from 'react'; import {Dimensions, type GestureResponderEvent, type StyleProp, StyleSheet, Text, type TextStyle, View, type ViewStyle} from 'react-native'; import CompassIcon from '@components/compass_icon'; -import EditedIndicator from '@components/EditedIndicator'; +import EditedIndicator from '@components/edited_indicator'; import Emoji from '@components/emoji'; import FormattedText from '@components/formatted_text'; import {logError} from '@utils/log'; diff --git a/app/components/post_list/post/body/content/content.test.tsx b/app/components/post_list/post/body/content/content.test.tsx new file mode 100644 index 000000000..6930946c7 --- /dev/null +++ b/app/components/post_list/post/body/content/content.test.tsx @@ -0,0 +1,315 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {Preferences} from '@constants'; +import {renderWithIntlAndTheme} from '@test/intl-test-helper'; +import TestHelper from '@test/test_helper'; + +import Content from './content'; +import PermalinkPreview from './permalink_preview'; + +import type {AvailableScreens} from '@typings/screens/navigation'; + +jest.mock('./permalink_preview', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('./opengraph', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('./image_preview', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('./message_attachments', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('./youtube', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('./embedded_bindings', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mocked(PermalinkPreview).mockImplementation((props) => + React.createElement('div', {testID: 'permalink-preview', ...props}), +); + +describe('components/post_list/post/body/content/Content - PermalinkPreview', () => { + const baseProps = { + isReplyPost: false, + layoutWidth: 350, + location: 'Channel' as AvailableScreens, + theme: Preferences.THEMES.denim, + showPermalinkPreviews: true, + }; + + const permalinkEmbedData: PermalinkEmbedData = { + post_id: 'post-123', + post: TestHelper.fakePost({ + id: 'post-123', + user_id: 'user-123', + message: 'Test permalink message', + create_at: 1234567890000, + }), + team_name: 'test-team', + channel_display_name: 'Test Channel', + channel_type: 'O', + channel_id: 'channel-123', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render PermalinkPreview when content type is permalink', () => { + const post = TestHelper.fakePostModel({ + id: 'post-1', + metadata: { + embeds: [ + { + type: 'permalink', + url: '', + data: permalinkEmbedData, + }, + ], + }, + }); + + const {getByTestId} = renderWithIntlAndTheme( + , + ); + + const permalinkPreview = getByTestId('permalink-preview'); + expect(permalinkPreview.props.embedData).toEqual(permalinkEmbedData); + }); + + it('should pass correct embedData to PermalinkPreview', () => { + const customEmbedData: PermalinkEmbedData = { + post_id: 'custom-post', + post: TestHelper.fakePost({ + id: 'custom-post', + user_id: 'custom-user', + message: 'Custom permalink message', + create_at: 9876543210000, + }), + team_name: 'custom-team', + channel_display_name: 'Custom Channel', + channel_type: 'P', + channel_id: 'custom-channel', + }; + + const post = TestHelper.fakePostModel({ + id: 'post-2', + metadata: { + embeds: [ + { + type: 'permalink', + url: '', + data: customEmbedData, + }, + ], + }, + }); + + const {getByTestId} = renderWithIntlAndTheme( + , + ); + + const permalinkPreview = getByTestId('permalink-preview'); + expect(permalinkPreview.props.embedData).toEqual(customEmbedData); + }); + + it('should not render anything when no embeds metadata exists', () => { + const post = TestHelper.fakePostModel({ + id: 'post-3', + metadata: null, + }); + + const result = renderWithIntlAndTheme( + , + ); + + expect(result.toJSON()).toBeNull(); + }); + + it('should handle permalink with multiple embeds (use first embed)', () => { + const firstEmbedData: PermalinkEmbedData = { + post_id: 'first-post', + post: TestHelper.fakePost({ + id: 'first-post', + message: 'First permalink', + }), + team_name: 'team1', + channel_display_name: 'Channel 1', + channel_type: 'O', + channel_id: 'channel1', + }; + + const post = TestHelper.fakePostModel({ + id: 'post-6', + metadata: { + embeds: [ + { + type: 'permalink', + url: '', + data: firstEmbedData, + }, + { + type: 'permalink', + url: '', + data: { + post_id: 'second-post', + post: TestHelper.fakePost({ + id: 'second-post', + message: 'Second permalink', + }), + team_name: 'team2', + channel_display_name: 'Channel 2', + channel_type: 'P', + channel_id: 'channel2', + }, + }, + ], + }, + }); + + const {getByTestId} = renderWithIntlAndTheme( + , + ); + + const permalinkPreview = getByTestId('permalink-preview'); + expect(permalinkPreview.props.embedData).toEqual(firstEmbedData); + }); + + it('should render PermalinkPreview with different post props', () => { + const post = TestHelper.fakePostModel({ + id: 'post-7', + metadata: { + embeds: [ + { + type: 'permalink', + url: '', + data: permalinkEmbedData, + }, + ], + }, + }); + + const customProps = { + ...baseProps, + isReplyPost: true, + layoutWidth: 250, + location: 'Thread' as AvailableScreens, + }; + + const {getByTestId} = renderWithIntlAndTheme( + , + ); + + const permalinkPreview = getByTestId('permalink-preview'); + expect(permalinkPreview.props.embedData).toEqual(permalinkEmbedData); + }); + + it('should handle permalink with direct message channel type', () => { + const dmEmbedData: PermalinkEmbedData = { + post_id: 'dm-post', + post: TestHelper.fakePost({ + id: 'dm-post', + message: 'DM message', + }), + team_name: 'team', + channel_display_name: 'testuser', + channel_type: 'D', + channel_id: 'dm-channel', + }; + + const post = TestHelper.fakePostModel({ + id: 'post-8', + metadata: { + embeds: [ + { + type: 'permalink', + url: '', + data: dmEmbedData, + }, + ], + }, + }); + + const {getByTestId} = renderWithIntlAndTheme( + , + ); + + const permalinkPreview = getByTestId('permalink-preview'); + expect(permalinkPreview.props.embedData).toEqual(dmEmbedData); + expect(permalinkPreview.props.embedData.channel_type).toBe('D'); + }); + + it('should handle permalink with group message channel type', () => { + const gmEmbedData: PermalinkEmbedData = { + post_id: 'gm-post', + post: TestHelper.fakePost({ + id: 'gm-post', + message: 'Group message', + }), + team_name: 'team', + channel_display_name: 'user1, user2, user3', + channel_type: 'G', + channel_id: 'gm-channel', + }; + + const post = TestHelper.fakePostModel({ + id: 'post-9', + metadata: { + embeds: [ + { + type: 'permalink', + url: '', + data: gmEmbedData, + }, + ], + }, + }); + + const {getByTestId} = renderWithIntlAndTheme( + , + ); + + const permalinkPreview = getByTestId('permalink-preview'); + expect(permalinkPreview.props.embedData).toEqual(gmEmbedData); + expect(permalinkPreview.props.embedData.channel_type).toBe('G'); + }); +}); diff --git a/app/components/post_list/post/body/content/content.tsx b/app/components/post_list/post/body/content/content.tsx new file mode 100644 index 000000000..9ace7ad8e --- /dev/null +++ b/app/components/post_list/post/body/content/content.tsx @@ -0,0 +1,126 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {isMessageAttachmentArray} from '@utils/message_attachment'; +import {isYoutubeLink} from '@utils/url'; + +import EmbeddedBindings from './embedded_bindings'; +import ImagePreview from './image_preview'; +import MessageAttachments from './message_attachments'; +import Opengraph from './opengraph'; +import PermalinkPreview from './permalink_preview'; +import YouTube from './youtube'; + +import type PostModel from '@typings/database/models/servers/post'; +import type {AvailableScreens} from '@typings/screens/navigation'; + +type ContentProps = { + isReplyPost: boolean; + layoutWidth?: number; + location: AvailableScreens; + post: PostModel; + theme: Theme; + showPermalinkPreviews: boolean; +} + +const contentType: Record = { + app_bindings: 'app_bindings', + image: 'image', + message_attachment: 'message_attachment', + opengraph: 'opengraph', + permalink: 'permalink', + youtube: 'youtube', +}; + +const Content = ({isReplyPost, layoutWidth, location, post, theme, showPermalinkPreviews}: ContentProps) => { + let type: string | undefined = post.metadata?.embeds?.[0].type; + + const nAppBindings = Array.isArray(post.props?.app_bindings) ? post.props.app_bindings.length : 0; + if (!type && nAppBindings) { + type = contentType.app_bindings; + } + + if (!type) { + return null; + } + + const attachments = isMessageAttachmentArray(post.props?.attachments) ? post.props.attachments : []; + + switch (contentType[type]) { + case contentType.image: + return ( + + ); + case contentType.opengraph: + if (isYoutubeLink(post.metadata!.embeds![0].url)) { + return ( + + ); + } + + return ( + + ); + case contentType.message_attachment: + if (attachments.length) { + return ( + + ); + } + break; + case contentType.app_bindings: + if (nAppBindings) { + return ( + + ); + } + break; + case contentType.permalink: + if (!showPermalinkPreviews) { + return null; + } + return ( + + ); + } + + return null; +}; + +export default Content; diff --git a/app/components/post_list/post/body/content/index.tsx b/app/components/post_list/post/body/content/index.tsx index 670e6ebb6..9540ce6ac 100644 --- a/app/components/post_list/post/body/content/index.tsx +++ b/app/components/post_list/post/body/content/index.tsx @@ -1,113 +1,23 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; +import {of as of$} from 'rxjs'; -import {isMessageAttachmentArray} from '@utils/message_attachment'; -import {isYoutubeLink} from '@utils/url'; +import {observeConfigBooleanValue} from '@queries/servers/system'; -import EmbeddedBindings from './embedded_bindings'; -import ImagePreview from './image_preview'; -import MessageAttachments from './message_attachments'; -import Opengraph from './opengraph'; -import YouTube from './youtube'; +import Content from './content'; +import type {WithDatabaseArgs} from '@typings/database/database'; import type PostModel from '@typings/database/models/servers/post'; -import type {AvailableScreens} from '@typings/screens/navigation'; -type ContentProps = { - isReplyPost: boolean; - layoutWidth?: number; - location: AvailableScreens; - post: PostModel; - theme: Theme; -} +const enhance = withObservables(['post'], ({database, post}: WithDatabaseArgs & {post: PostModel}) => { + const hasPermalinkEmbed = post.metadata?.embeds?.[0]?.type === 'permalink'; + const showPermalinkPreviews = hasPermalinkEmbed ? observeConfigBooleanValue(database, 'EnablePermalinkPreviews', false) : of$(false); -const contentType: Record = { - app_bindings: 'app_bindings', - image: 'image', - message_attachment: 'message_attachment', - opengraph: 'opengraph', - youtube: 'youtube', -}; + return { + showPermalinkPreviews, + }; +}); -const Content = ({isReplyPost, layoutWidth, location, post, theme}: ContentProps) => { - let type: string | undefined = post.metadata?.embeds?.[0].type; - - const nAppBindings = Array.isArray(post.props?.app_bindings) ? post.props.app_bindings.length : 0; - if (!type && nAppBindings) { - type = contentType.app_bindings; - } - - if (!type) { - return null; - } - - const attachments = isMessageAttachmentArray(post.props?.attachments) ? post.props.attachments : []; - - switch (contentType[type]) { - case contentType.image: - return ( - - ); - case contentType.opengraph: - if (isYoutubeLink(post.metadata!.embeds![0].url)) { - return ( - - ); - } - - return ( - - ); - case contentType.message_attachment: - if (attachments.length) { - return ( - - ); - } - break; - case contentType.app_bindings: - if (nAppBindings) { - return ( - - ); - } - break; - } - - return null; -}; - -export default Content; +export default withDatabase(enhance(Content)); 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 new file mode 100644 index 000000000..4b0188ae1 --- /dev/null +++ b/app/components/post_list/post/body/content/permalink_preview/index.test.tsx @@ -0,0 +1,269 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {Screens} from '@constants'; +import DatabaseManager from '@database/manager'; +import {renderWithEverything, waitFor} from '@test/intl-test-helper'; +import TestHelper from '@test/test_helper'; + +import PermalinkPreview from './permalink_preview'; + +import EnhancedPermalinkPreview from './index'; + +import type ServerDataOperator from '@database/operator/server_data_operator'; +import type {Database} from '@nozbe/watermelondb'; + +jest.mock('./permalink_preview', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mocked(PermalinkPreview).mockImplementation((props) => + React.createElement('PermalinkPreview', {...props, testID: 'permalink-preview'}), +); + +describe('PermalinkPreview Enhanced Component', () => { + const serverUrl = 'server-1'; + let database: Database; + let operator: ServerDataOperator; + + beforeEach(async () => { + await DatabaseManager.init([serverUrl]); + const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + database = serverDatabaseAndOperator.database; + operator = serverDatabaseAndOperator.operator; + + // Setup basic data + await operator.handleConfigs({ + configs: [ + {id: 'EnablePermalinkPreviews', value: 'true'}, + {id: 'TeammateNameDisplay', value: 'username'}, + ], + configsToDelete: [], + prepareRecordsOnly: false, + }); + + // 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, + }); + }); + + afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); + }); + + it('should pass props to permalink preview component with post author', async () => { + const authorUser = TestHelper.fakeUser({id: 'author-user', username: 'testauthor'}); + await operator.handleUsers({users: [authorUser], prepareRecordsOnly: false}); + + const embedData: PermalinkEmbedData = { + post_id: 'post-123', + post: TestHelper.fakePost({ + id: 'post-123', + user_id: 'author-user', + message: 'Test message', + }), + team_name: 'test-team', + channel_display_name: 'Test Channel', + channel_type: 'O', + channel_id: 'channel-123', + }; + + const {getByTestId} = renderWithEverything( + , + {database, serverUrl}, + ); + + await waitFor(() => { + const permalinkPreview = getByTestId('permalink-preview'); + expect(permalinkPreview.props.teammateNameDisplay).toBe('username'); + expect(permalinkPreview.props.author).toBeDefined(); + expect(permalinkPreview.props.currentUser).toBeDefined(); + expect(permalinkPreview.props.isMilitaryTime).toBe(false); + }); + }); + + it('should pass props to permalink preview component without post author', async () => { + const embedData: PermalinkEmbedData = { + post_id: 'post-123', + post: TestHelper.fakePost({ + id: 'post-123', + user_id: '', + message: 'Test message', + }), + team_name: 'test-team', + channel_display_name: 'Test Channel', + channel_type: 'O', + channel_id: 'channel-123', + }; + + const {getByTestId} = renderWithEverything( + , + {database, serverUrl}, + ); + + await waitFor(() => { + const permalinkPreview = getByTestId('permalink-preview'); + expect(permalinkPreview.props.teammateNameDisplay).toBe('username'); + expect(permalinkPreview.props.author).toBeUndefined(); + expect(permalinkPreview.props.currentUser).toBeDefined(); + expect(permalinkPreview.props.isMilitaryTime).toBe(false); + }); + }); + + it('should pass props to permalink preview component with disabled link previews', async () => { + await operator.handleConfigs({ + configs: [ + {id: 'EnablePermalinkPreviews', value: 'false'}, + ], + configsToDelete: [], + prepareRecordsOnly: false, + }); + + const embedData: PermalinkEmbedData = { + post_id: 'post-123', + post: TestHelper.fakePost({ + id: 'post-123', + user_id: 'author-user', + message: 'Test message', + }), + team_name: 'test-team', + channel_display_name: 'Test Channel', + channel_type: 'O', + channel_id: 'channel-123', + }; + + const {getByTestId} = renderWithEverything( + , + {database, serverUrl}, + ); + + await waitFor(() => { + const permalinkPreview = getByTestId('permalink-preview'); + expect(permalinkPreview.props.teammateNameDisplay).toBe('username'); + expect(permalinkPreview.props.currentUser).toBeDefined(); + expect(permalinkPreview.props.isMilitaryTime).toBe(false); + }); + }); + + it('should pass props to permalink preview component without post data', async () => { + const embedData: PermalinkEmbedData = { + post_id: 'post-123', + post: TestHelper.fakePost({ + id: 'post-123', + message: 'Test message', + }), + team_name: 'test-team', + channel_display_name: 'Test Channel', + channel_type: 'O', + channel_id: 'channel-123', + }; + + const {getByTestId} = renderWithEverything( + , + {database, serverUrl}, + ); + + await waitFor(() => { + const permalinkPreview = getByTestId('permalink-preview'); + expect(permalinkPreview.props.teammateNameDisplay).toBe('username'); + expect(permalinkPreview.props.author).toBeUndefined(); + expect(permalinkPreview.props.currentUser).toBeDefined(); + expect(permalinkPreview.props.isMilitaryTime).toBe(false); + }); + }); + + it('should pass props to permalink preview component with different teammate name display', async () => { + await operator.handleConfigs({ + configs: [ + {id: 'TeammateNameDisplay', value: 'full_name'}, + ], + configsToDelete: [], + prepareRecordsOnly: false, + }); + + const embedData: PermalinkEmbedData = { + post_id: 'post-123', + post: TestHelper.fakePost({ + id: 'post-123', + user_id: 'author-user', + message: 'Test message', + }), + team_name: 'test-team', + channel_display_name: 'Test Channel', + channel_type: 'O', + channel_id: 'channel-123', + }; + + const {getByTestId} = renderWithEverything( + , + {database, serverUrl}, + ); + + await waitFor(() => { + const permalinkPreview = getByTestId('permalink-preview'); + expect(permalinkPreview.props.teammateNameDisplay).toBe('full_name'); + expect(permalinkPreview.props.currentUser).toBeDefined(); + expect(permalinkPreview.props.isMilitaryTime).toBe(false); + }); + }); + + it('should pass military time preference correctly', async () => { + await operator.handlePreferences({ + preferences: [{ + category: 'display_settings', + name: 'use_military_time', + user_id: 'current-user', + value: 'true', + }], + prepareRecordsOnly: false, + }); + + const embedData: PermalinkEmbedData = { + post_id: 'post-123', + post: TestHelper.fakePost({ + id: 'post-123', + user_id: 'author-user', + message: 'Test message', + }), + team_name: 'test-team', + channel_display_name: 'Test Channel', + channel_type: 'O', + channel_id: 'channel-123', + }; + + const {getByTestId} = renderWithEverything( + , + {database, serverUrl}, + ); + + await waitFor(() => { + const permalinkPreview = getByTestId('permalink-preview'); + expect(permalinkPreview.props.isMilitaryTime).toBe(true); + }); + }); +}); diff --git a/app/components/post_list/post/body/content/permalink_preview/index.tsx b/app/components/post_list/post/body/content/permalink_preview/index.tsx new file mode 100644 index 000000000..e3eff7a66 --- /dev/null +++ b/app/components/post_list/post/body/content/permalink_preview/index.tsx @@ -0,0 +1,44 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; +import {of as of$} from 'rxjs'; +import {map, switchMap, distinctUntilChanged} from 'rxjs/operators'; + +import {getDisplayNamePreferenceAsBool} from '@helpers/api/preference'; +import {observePost} from '@queries/servers/post'; +import {queryDisplayNamePreferences} from '@queries/servers/preference'; +import {observeUser, observeTeammateNameDisplay, observeCurrentUser} from '@queries/servers/user'; + +import PermalinkPreview from './permalink_preview'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +const enhance = withObservables(['embedData'], ({database, embedData}: WithDatabaseArgs & {embedData: PermalinkEmbedData}) => { + const teammateNameDisplay = observeTeammateNameDisplay(database); + const currentUser = observeCurrentUser(database); + + const preferences = queryDisplayNamePreferences(database).observeWithColumns(['value']); + const isMilitaryTime = preferences.pipe(map((prefs) => getDisplayNamePreferenceAsBool(prefs, 'use_military_time'))); + + const userId = embedData?.post?.user_id; + const author = userId ? observeUser(database, userId) : of$(undefined); + + const isOriginPostDeleted = embedData?.post_id ? observePost(database, embedData.post_id).pipe( + switchMap((p) => { + const initialDeleted = Boolean(embedData?.post?.delete_at > 0 || embedData?.post?.state === 'DELETED'); + return of$(p ? p.deleteAt > 0 : initialDeleted); + }), + distinctUntilChanged(), + ) : of$(false); + + return { + teammateNameDisplay, + currentUser, + isMilitaryTime, + author, + isOriginPostDeleted, + }; +}); + +export default withDatabase(enhance(PermalinkPreview)); diff --git a/app/components/post_list/post/body/content/permalink_preview/permalink_preview.test.tsx b/app/components/post_list/post/body/content/permalink_preview/permalink_preview.test.tsx new file mode 100644 index 000000000..083143b3d --- /dev/null +++ b/app/components/post_list/post/body/content/permalink_preview/permalink_preview.test.tsx @@ -0,0 +1,239 @@ +// 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 Markdown from '@components/markdown'; +import {Screens} from '@constants'; +import {renderWithIntlAndTheme} from '@test/intl-test-helper'; +import TestHelper from '@test/test_helper'; + +import PermalinkPreview from './permalink_preview'; + +jest.mock('@components/markdown', () => ({ + __esModule: true, + default: jest.fn(), +})); +jest.mocked(Markdown).mockImplementation((props) => + React.createElement('Text', {testID: 'markdown'}, props.value), +); + +describe('components/post_list/post/body/content/permalink_preview/PermalinkPreview', () => { + const baseProps: Parameters[0] = { + embedData: { + post_id: 'post-123', + post: TestHelper.fakePost({ + id: 'post-123', + user_id: 'user-123', + message: 'This is a test message', + create_at: 1234567890000, + edit_at: 0, + }), + team_name: 'test-team', + channel_display_name: 'Test Channel', + channel_type: 'O', + channel_id: 'channel-123', + }, + author: TestHelper.fakeUserModel({ + id: 'user-123', + username: 'testuser', + firstName: 'Test', + lastName: 'User', + }), + currentUser: TestHelper.fakeUserModel({ + id: 'current-user', + locale: 'en', + }), + isMilitaryTime: false, + teammateNameDisplay: 'username', + location: Screens.CHANNEL, + isOriginPostDeleted: false, + }; + + it('should render permalink preview correctly', () => { + const {getByText} = renderWithIntlAndTheme( + , + ); + + expect(getByText('testuser')).toBeTruthy(); + expect(getByText('This is a test message')).toBeTruthy(); + expect(getByText('Originally posted in ~Test Channel')).toBeTruthy(); + }); + + it('should not render when origin post is deleted', () => { + const props = {...baseProps, isOriginPostDeleted: true}; + const {queryByText} = renderWithIntlAndTheme( + , + ); + + expect(queryByText('testuser')).toBeNull(); + expect(queryByText('This is a test message')).toBeNull(); + }); + + it('should not render when post is missing', () => { + const props = { + ...baseProps, + embedData: { + ...baseProps.embedData, + post: undefined as unknown as Post, + }, + }; + const {queryByText} = renderWithIntlAndTheme( + , + ); + + expect(queryByText('testuser')).toBeNull(); + }); + + it('should handle press event without crashing', () => { + const {getByTestId} = renderWithIntlAndTheme( + , + ); + const permalinkContainer = getByTestId('permalink-preview-container'); + expect(() => { + fireEvent.press(permalinkContainer); + }).not.toThrow(); + expect(getByTestId('permalink-preview-container')).toBeTruthy(); + }); + + it('should display author name from user model', () => { + const {getByText} = renderWithIntlAndTheme( + , + ); + + expect(getByText('testuser')).toBeTruthy(); + }); + + it('should display "Someone" when no author is provided', () => { + const props = { + ...baseProps, + author: undefined, + embedData: { + ...baseProps.embedData, + post: TestHelper.fakePost({ + id: 'post-123', + user_id: '', + message: 'Test message', + }), + }, + }; + const {getByText} = renderWithIntlAndTheme( + , + ); + + expect(getByText('Someone')).toBeTruthy(); + }); + + it('should display channel name with ~ prefix for public channels', () => { + const {getByText} = renderWithIntlAndTheme( + , + ); + + expect(getByText('Originally posted in ~Test Channel')).toBeTruthy(); + }); + + it('should display channel name with ~ prefix for direct messages', () => { + const props = { + ...baseProps, + embedData: { + ...baseProps.embedData, + channel_type: 'D', + channel_display_name: 'testuser', + }, + }; + const {getByText} = renderWithIntlAndTheme( + , + ); + + expect(getByText('Originally posted in ~testuser')).toBeTruthy(); + }); + + it('should truncate long messages', () => { + const longMessage = 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6'; + const props = { + ...baseProps, + embedData: { + ...baseProps.embedData, + post: TestHelper.fakePost({ + id: 'post-123', + user_id: 'user-123', + message: longMessage, + }), + }, + }; + const {getByText} = renderWithIntlAndTheme( + , + ); + + expect(getByText('Line 1\n...Line 2\n...Line 3\n...Line 4')).toBeTruthy(); + }); + + it('should handle empty message', () => { + const props = { + ...baseProps, + embedData: { + ...baseProps.embedData, + post: TestHelper.fakePost({ + id: 'post-123', + user_id: 'user-123', + message: '', + }), + }, + }; + const {queryByText} = renderWithIntlAndTheme( + , + ); + + // Should render but with empty message + expect(queryByText('This is a test message')).toBeNull(); + }); + + it('should show edited indicator when post is edited', () => { + const props = { + ...baseProps, + embedData: { + ...baseProps.embedData, + post: TestHelper.fakePost({ + id: 'post-123', + user_id: 'user-123', + message: 'Edited message', + edit_at: 1234567891000, + create_at: 1234567890000, + }), + }, + }; + const {getByTestId} = renderWithIntlAndTheme( + , + ); + + expect(getByTestId('permalink_preview.edited_indicator_separate')).toBeTruthy(); + }); + + it('should not show edited indicator when post is not edited', () => { + const {queryByTestId} = renderWithIntlAndTheme( + , + ); + + expect(queryByTestId('permalink_preview.edited_indicator_separate')).toBeNull(); + }); + + it('should display formatted time correctly', () => { + const {getByText} = renderWithIntlAndTheme( + , + ); + + // FormattedTime component should render the time text + expect(getByText('11:31 PM')).toBeTruthy(); + }); + + it('should display profile picture', () => { + const {root} = renderWithIntlAndTheme( + , + ); + + // Profile picture should be rendered (check for the image component) + const profilePicture = root.findByType('ViewManagerAdapter_ExpoImage'); + expect(profilePicture).toBeTruthy(); + }); +}); diff --git a/app/components/post_list/post/body/content/permalink_preview/permalink_preview.tsx b/app/components/post_list/post/body/content/permalink_preview/permalink_preview.tsx new file mode 100644 index 000000000..67400e642 --- /dev/null +++ b/app/components/post_list/post/body/content/permalink_preview/permalink_preview.tsx @@ -0,0 +1,235 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useMemo, useCallback, useEffect} from 'react'; +import {Text, View, Pressable} from 'react-native'; + +import {fetchUsersByIds} from '@actions/remote/user'; +import EditedIndicator from '@components/edited_indicator'; +import FormattedText from '@components/formatted_text'; +import FormattedTime from '@components/formatted_time'; +import Markdown from '@components/markdown'; +import ProfilePicture from '@components/profile_picture'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import {useUserLocale} from '@context/user_locale'; +import {usePreventDoubleTap} from '@hooks/utils'; +import {getMarkdownTextStyles, getMarkdownBlockStyles} from '@utils/markdown'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; +import {displayUsername, getUserTimezone} from '@utils/user'; + +import type UserModel from '@typings/database/models/servers/user'; +import type {AvailableScreens} from '@typings/screens/navigation'; + +const MAX_PERMALINK_PREVIEW_LINES = 4; +const EDITED_INDICATOR_CONTEXT = ['paragraph']; + +type PermalinkPreviewProps = { + embedData: PermalinkEmbedData; + author?: UserModel; + currentUser?: UserModel; + isMilitaryTime: boolean; + teammateNameDisplay: string; + isOriginPostDeleted?: boolean; + location: AvailableScreens; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + backgroundColor: theme.centerChannelBg, + borderColor: changeOpacity(theme.centerChannelColor, 0.16), + borderWidth: 1, + borderRadius: 4, + marginTop: 8, + marginBottom: 8, + padding: 12, + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.08, + shadowRadius: 3, + elevation: 2, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 8, + }, + authorInfo: { + flex: 1, + marginLeft: 12, + flexDirection: 'row', + alignItems: 'center', + }, + authorName: { + color: theme.centerChannelColor, + ...typography('Body', 100, 'SemiBold'), + }, + timestamp: { + color: changeOpacity(theme.centerChannelColor, 0.64), + ...typography('Body', 75), + marginLeft: 8, + }, + messageContainer: { + marginBottom: 8, + maxHeight: 20 * MAX_PERMALINK_PREVIEW_LINES, + overflow: 'hidden', + }, + messageText: { + color: theme.centerChannelColor, + ...typography('Body', 100), + lineHeight: 20, + }, + + channelContext: { + color: changeOpacity(theme.centerChannelColor, 0.64), + ...typography('Body', 75), + }, + channelName: { + color: theme.linkColor, + ...typography('Body', 75, 'SemiBold'), + }, + }; +}); + +const PermalinkPreview = ({ + embedData, + author, + currentUser, + isMilitaryTime, + teammateNameDisplay, + isOriginPostDeleted, + location, +}: PermalinkPreviewProps) => { + const theme = useTheme(); + const serverUrl = useServerUrl(); + const locale = useUserLocale(); + const styles = getStyleSheet(theme); + + const textStyles = getMarkdownTextStyles(theme); + const blockStyles = getMarkdownBlockStyles(theme); + + const userId = embedData?.post?.user_id; + + useEffect(() => { + if (userId && !author) { + fetchUsersByIds(serverUrl, [userId], false); + } + }, [userId, author, serverUrl]); + + if (isOriginPostDeleted) { + return null; + } + + const { + post, + channel_display_name, + channel_type, + } = embedData; + + const truncatedMessage = useMemo(() => { + const message = post?.message; + + if (!message || typeof message !== 'string') { + return ''; + } + + const cleanMessage = message.trim(); + const lines = cleanMessage.split('\n'); + if (lines.length > MAX_PERMALINK_PREVIEW_LINES) { + return lines.slice(0, MAX_PERMALINK_PREVIEW_LINES).join('\n...'); + } + + return cleanMessage; + }, [post?.message]); + + const isEdited = useMemo(() => post && post.edit_at > 0, [post]); + + const authorDisplayName = useMemo(() => { + return displayUsername(author, locale, teammateNameDisplay); + }, [author, locale, teammateNameDisplay]); + + const channelContextText = useMemo(() => { + const displayName = channel_type === 'D' ? authorDisplayName : channel_display_name; + return `~${displayName}`; + }, [channel_display_name, channel_type, authorDisplayName]); + + const handlePress = usePreventDoubleTap(useCallback(() => { + // Navigation will be implemented in Task 5 + }, [])); + + if (!post) { + return null; + } + + return ( + [ + styles.container, + {opacity: pressed ? 0.8 : 1}, + ]} + onPress={handlePress} + testID='permalink-preview-container' + > + + + + + {authorDisplayName} + + + + + + + + {isEdited ? ( + + ) : null} + + + + + + {channelContextText} + + + + ); +}; + +export default PermalinkPreview; diff --git a/app/queries/servers/post.test.ts b/app/queries/servers/post.test.ts new file mode 100644 index 000000000..297a76579 --- /dev/null +++ b/app/queries/servers/post.test.ts @@ -0,0 +1,281 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Database} from '@nozbe/watermelondb'; + +import {ActionType} from '@constants'; +import DatabaseManager from '@database/manager'; +import ServerDataOperator from '@database/operator/server_data_operator'; +import TestHelper from '@test/test_helper'; + +import {queryPostsWithPermalinkReferences} from './post'; + +describe('Post Queries', () => { + const serverUrl = 'post.test.com'; + let database: Database; + let operator: ServerDataOperator; + + beforeEach(async () => { + await DatabaseManager.init([serverUrl]); + const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + database = serverDatabaseAndOperator.database; + operator = serverDatabaseAndOperator.operator; + }); + + afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); + }); + + describe('queryPostsWithPermalinkReferences', () => { + it('should return posts that reference the given post ID', async () => { + const referencedPostId = 'referenced_post_123'; + const referencingPost = TestHelper.fakePost({ + id: 'referencing_post_456', + channel_id: 'channel1', + metadata: { + embeds: [ + { + type: 'permalink', + url: '', + data: { + post_id: referencedPostId, + }, + }, + ], + }, + }); + + const nonReferencingPost = TestHelper.fakePost({ + id: 'non_referencing_post_789', + channel_id: 'channel1', + metadata: { + embeds: [ + { + type: 'opengraph', + url: 'https://example.com', + data: {}, + }, + ], + }, + }); + + const models = await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_NEW, + order: [referencingPost.id, nonReferencingPost.id], + posts: [referencingPost, nonReferencingPost], + prepareRecordsOnly: true, + }); + await operator.batchRecords(models, 'test'); + + const result = await queryPostsWithPermalinkReferences(database, referencedPostId); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe(referencingPost.id); + }); + + it('should return posts from multiple channels that reference the same post', async () => { + const referencedPostId = 'referenced_post_123'; + const channel1Post = TestHelper.fakePost({ + id: 'channel1_post', + channel_id: 'channel1', + metadata: { + embeds: [ + { + type: 'permalink', + url: '', + data: { + post_id: referencedPostId, + }, + }, + ], + }, + }); + + const channel2Post = TestHelper.fakePost({ + id: 'channel2_post', + channel_id: 'channel2', + metadata: { + embeds: [ + { + type: 'permalink', + url: '', + data: { + post_id: referencedPostId, + }, + }, + ], + }, + }); + + const models = await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_NEW, + order: [channel1Post.id, channel2Post.id], + posts: [channel1Post, channel2Post], + prepareRecordsOnly: true, + }); + await operator.batchRecords(models, 'test'); + + const result = await queryPostsWithPermalinkReferences(database, referencedPostId); + + expect(result).toHaveLength(2); + expect(result.map((p) => p.id)).toEqual(expect.arrayContaining([channel1Post.id, channel2Post.id])); + }); + + it('should exclude deleted posts', async () => { + const referencedPostId = 'referenced_post_123'; + const deletedPost = TestHelper.fakePost({ + id: 'deleted_post', + channel_id: 'channel1', + delete_at: Date.now(), + metadata: { + embeds: [ + { + type: 'permalink', + url: '', + data: { + post_id: referencedPostId, + }, + }, + ], + }, + }); + + const models = await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_NEW, + order: [deletedPost.id], + posts: [deletedPost], + prepareRecordsOnly: true, + }); + await operator.batchRecords(models, 'test'); + + const result = await queryPostsWithPermalinkReferences(database, referencedPostId); + + expect(result).toHaveLength(0); + }); + + it('should exclude posts without metadata', async () => { + const referencedPostId = 'referenced_post_123'; + const postWithoutMetadata = TestHelper.fakePost({ + id: 'post_without_metadata', + channel_id: 'channel1', + metadata: undefined, + }); + + const models = await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_NEW, + order: [postWithoutMetadata.id], + posts: [postWithoutMetadata], + prepareRecordsOnly: true, + }); + await operator.batchRecords(models, 'test'); + + const result = await queryPostsWithPermalinkReferences(database, referencedPostId); + + expect(result).toHaveLength(0); + }); + + it('should exclude posts with empty embeds', async () => { + const referencedPostId = 'referenced_post_123'; + const postWithEmptyEmbeds = TestHelper.fakePost({ + id: 'post_with_empty_embeds', + channel_id: 'channel1', + metadata: { + embeds: [], + }, + }); + + const models = await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_NEW, + order: [postWithEmptyEmbeds.id], + posts: [postWithEmptyEmbeds], + prepareRecordsOnly: true, + }); + await operator.batchRecords(models, 'test'); + + const result = await queryPostsWithPermalinkReferences(database, referencedPostId); + + expect(result).toHaveLength(0); + }); + + it('should handle multiple embeds and find the correct permalink', async () => { + const referencedPostId = 'referenced_post_123'; + const postWithMultipleEmbeds = TestHelper.fakePost({ + id: 'post_with_multiple_embeds', + channel_id: 'channel1', + metadata: { + embeds: [ + { + type: 'opengraph', + url: 'https://example.com', + data: {}, + }, + { + type: 'permalink', + url: '', + data: { + post_id: referencedPostId, + }, + }, + { + type: 'image', + url: 'https://example.com/image.jpg', + data: {}, + }, + ], + }, + }); + + const models = await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_NEW, + order: [postWithMultipleEmbeds.id], + posts: [postWithMultipleEmbeds], + prepareRecordsOnly: true, + }); + await operator.batchRecords(models, 'test'); + + const result = await queryPostsWithPermalinkReferences(database, referencedPostId); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe(postWithMultipleEmbeds.id); + }); + + it('should return empty array when no posts reference the given post ID', async () => { + const nonExistentPostId = 'non_existent_post_456'; + + const result = await queryPostsWithPermalinkReferences(database, nonExistentPostId); + + expect(result).toHaveLength(0); + }); + + it('should only match exact post_id in permalink data', async () => { + const referencedPostId = 'referenced_post_123'; + const partialMatchPost = TestHelper.fakePost({ + id: 'partial_match_post', + channel_id: 'channel1', + metadata: { + embeds: [ + { + type: 'permalink', + url: '', + data: { + post_id: 'referenced_post_12345', + }, + }, + ], + }, + }); + + const models = await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_NEW, + order: [partialMatchPost.id], + posts: [partialMatchPost], + prepareRecordsOnly: true, + }); + await operator.batchRecords(models, 'test'); + + const result = await queryPostsWithPermalinkReferences(database, referencedPostId); + + expect(result).toHaveLength(0); + }); + }); +}); diff --git a/app/queries/servers/post.ts b/app/queries/servers/post.ts index e618cd567..d3381c2d5 100644 --- a/app/queries/servers/post.ts +++ b/app/queries/servers/post.ts @@ -6,6 +6,8 @@ import {of as of$, combineLatestWith} from 'rxjs'; import {switchMap, distinctUntilChanged} from 'rxjs/operators'; import {MM_TABLES} from '@constants/database'; +import {logDebug, logWarning} from '@utils/log'; +import {updatePermalinkMetadata} from '@utils/permalink_sync'; import {queryGroupsByNames} from './group'; import {querySavedPostsPreferences} from './preference'; @@ -279,3 +281,91 @@ export const countUsersFromMentions = async (database: Database, mentions: strin const [groups, usersCount] = await Promise.all([groupsQuery, usersQuery]); return groups.reduce((acc, v) => acc + v.memberCount, usersCount); }; + +export const queryPostsWithPermalinkReferences = async ( + database: Database, + referencedPostId: string, +): Promise => { + try { + const clauses: Q.Clause[] = [ + Q.where('metadata', Q.notEq(null)), + Q.where('delete_at', Q.eq(0)), + ]; + + const postsWithMetadata = await database.get(POST). + query(...clauses). + fetch(); + + const referencingPosts: PostModel[] = []; + + for (const post of postsWithMetadata) { + const metadata = post.metadata; + if (metadata?.embeds?.length) { + for (const embed of metadata.embeds) { + if (embed.type === 'permalink' && embed.data?.post_id === referencedPostId) { + referencingPosts.push(post); + break; + } + } + } + } + + return referencingPosts; + } catch (error) { + return []; + } +}; + +/** + * Find posts that contain permalink previews referencing the given post ID + */ +export const findPostsWithPermalinkReferences = async ( + database: Database, + referencedPostId: string, +): Promise => { + try { + const referencingPosts = await queryPostsWithPermalinkReferences(database, referencedPostId); + return referencingPosts; + } catch (error) { + logWarning('Error finding posts with permalink references:', error); + return []; + } +}; + +/** + * Synchronize permalink previews when a post is edited + */ +export const syncPermalinkPreviewsForEditedPost = async ( + database: Database, + editedPost: Post, +): Promise => { + try { + const referencingPosts = await findPostsWithPermalinkReferences( + database, + editedPost.id, + ); + + if (!referencingPosts.length) { + return []; + } + + const updatedPosts: PostModel[] = []; + for (const referencingPost of referencingPosts) { + const updatedPost = updatePermalinkMetadata( + referencingPost, + editedPost.id, + editedPost, + ); + + if (updatedPost) { + updatedPosts.push(updatedPost); + } + } + + logDebug(`Updated ${updatedPosts.length} permalink previews for edited post ${editedPost.id}`); + return updatedPosts; + } catch (error) { + logWarning('Error syncing permalink previews for edited post:', error); + return []; + } +}; diff --git a/app/utils/permalink_sync.test.ts b/app/utils/permalink_sync.test.ts new file mode 100644 index 000000000..93747195d --- /dev/null +++ b/app/utils/permalink_sync.test.ts @@ -0,0 +1,249 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Database} from '@nozbe/watermelondb'; + +import {ActionType} from '@constants'; +import DatabaseManager from '@database/manager'; +import ServerDataOperator from '@database/operator/server_data_operator'; +import TestHelper from '@test/test_helper'; +import * as logModule from '@utils/log'; + +import {updatePermalinkMetadata} from './permalink_sync'; + +import type PostModel from '@typings/database/models/servers/post'; + +describe('Permalink Sync Utils', () => { + const serverUrl = 'permalinksync.test.com'; + let database: Database; + let operator: ServerDataOperator; + + beforeEach(async () => { + await DatabaseManager.init([serverUrl]); + const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + database = serverDatabaseAndOperator.database; + operator = serverDatabaseAndOperator.operator; + jest.clearAllMocks(); + }); + + afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); + }); + + describe('updatePermalinkMetadata', () => { + it('should return null when post has no metadata', async () => { + const referencedPostId = 'referenced_post_456'; + const freshPostData = TestHelper.fakePost({id: referencedPostId}); + + const postWithoutMetadata = TestHelper.fakePost({ + id: 'post_without_metadata', + channel_id: 'channel1', + metadata: undefined, + }); + + const models = await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_NEW, + order: [postWithoutMetadata.id], + posts: [postWithoutMetadata], + prepareRecordsOnly: true, + }); + await operator.batchRecords(models, 'test'); + + const postModel = await database.get('Post').find(postWithoutMetadata.id) as PostModel; + + const result = updatePermalinkMetadata(postModel, referencedPostId, freshPostData); + + expect(result).toBeNull(); + }); + + it('should return null when post has no embeds', async () => { + const referencedPostId = 'referenced_post_456'; + const freshPostData = TestHelper.fakePost({id: referencedPostId}); + + const postWithEmptyEmbeds = TestHelper.fakePost({ + id: 'post_with_empty_embeds', + channel_id: 'channel1', + metadata: { + embeds: [], + }, + }); + + const models = await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_NEW, + order: [postWithEmptyEmbeds.id], + posts: [postWithEmptyEmbeds], + prepareRecordsOnly: true, + }); + await operator.batchRecords(models, 'test'); + + const postModel = await database.get('Post').find(postWithEmptyEmbeds.id) as PostModel; + + const result = updatePermalinkMetadata(postModel, referencedPostId, freshPostData); + + expect(result).toBeNull(); + }); + + it('should update permalink metadata when fresh post is newer', async () => { + const referencedPostId = 'referenced_post_456'; + + const referencingPost = TestHelper.fakePost({ + id: 'referencing_post_123', + channel_id: 'channel1', + metadata: { + embeds: [ + { + type: 'permalink', + url: '', + data: { + post_id: referencedPostId, + post: { + id: referencedPostId, + message: 'Old message', + edit_at: 1000, + update_at: 1000, + user_id: 'user_123', + create_at: 500, + }, + }, + }, + ], + }, + }); + + const models = await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_NEW, + order: [referencingPost.id], + posts: [referencingPost], + prepareRecordsOnly: true, + }); + await operator.batchRecords(models, 'test'); + + const postModel = await database.get('Post').find(referencingPost.id) as PostModel; + + const freshPostData = TestHelper.fakePost({ + id: referencedPostId, + message: 'Updated message', + edit_at: 2000, + update_at: 2000, + user_id: 'user_123', + create_at: 500, + }); + + const result = updatePermalinkMetadata(postModel, referencedPostId, freshPostData); + + expect(result).not.toBeNull(); + expect(result!.id).toBe(referencingPost.id); + expect(result!._preparedState).toBe('update'); + + await operator.batchRecords([result!], 'test update'); + }); + + it('should not update when fresh post is older or same age', async () => { + const referencedPostId = 'referenced_post_456'; + + const referencingPost = TestHelper.fakePost({ + id: 'referencing_post_123', + channel_id: 'channel1', + metadata: { + embeds: [ + { + type: 'permalink', + url: '', + data: { + post_id: referencedPostId, + post: { + id: referencedPostId, + message: 'Current message', + edit_at: 2000, + update_at: 2000, + user_id: 'user_123', + create_at: 500, + }, + }, + }, + ], + }, + }); + + const models = await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_NEW, + order: [referencingPost.id], + posts: [referencingPost], + prepareRecordsOnly: true, + }); + await operator.batchRecords(models, 'test'); + + const postModel = await database.get('Post').find(referencingPost.id) as PostModel; + + const freshPostData = TestHelper.fakePost({ + id: referencedPostId, + message: 'Older message', + edit_at: 1000, + update_at: 1000, + }); + + const result = updatePermalinkMetadata(postModel, referencedPostId, freshPostData); + + expect(result).toBeNull(); + }); + + it('should handle errors during post update gracefully', async () => { + const referencedPostId = 'referenced_post_456'; + + const referencingPost = TestHelper.fakePost({ + id: 'referencing_post_123', + channel_id: 'channel1', + metadata: { + embeds: [ + { + type: 'permalink', + url: '', + data: { + post_id: referencedPostId, + post: { + id: referencedPostId, + message: 'Old message', + edit_at: 1000, + update_at: 1000, + user_id: 'user_123', + create_at: 500, + }, + }, + }, + ], + }, + }); + + const models = await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_NEW, + order: [referencingPost.id], + posts: [referencingPost], + prepareRecordsOnly: true, + }); + await operator.batchRecords(models, 'test'); + + const postModel = await database.get('Post').find(referencingPost.id) as PostModel; + + const testError = new Error('Database update failed'); + jest.spyOn(postModel, 'prepareUpdate').mockImplementationOnce(() => { + throw testError; + }); + + const logWarningSpy = jest.spyOn(logModule, 'logWarning').mockImplementationOnce(() => {}); + + const freshPostData = TestHelper.fakePost({ + id: referencedPostId, + message: 'Updated message', + edit_at: 2000, + update_at: 2000, + user_id: 'user_123', + create_at: 500, + }); + + const result = updatePermalinkMetadata(postModel, referencedPostId, freshPostData); + + expect(result).toBeNull(); + expect(logWarningSpy).toHaveBeenCalledWith('Error updating permalink metadata:', testError); + }); + }); +}); diff --git a/app/utils/permalink_sync.ts b/app/utils/permalink_sync.ts new file mode 100644 index 000000000..6975483e4 --- /dev/null +++ b/app/utils/permalink_sync.ts @@ -0,0 +1,65 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {logWarning} from '@utils/log'; + +import type PostModel from '@typings/database/models/servers/post'; + +/** + * Update permalink metadata with fresh post data + */ +export function updatePermalinkMetadata( + referencingPost: PostModel, + referencedPostId: string, + freshPostData: Post, +): PostModel | null { + try { + const metadata = referencingPost.metadata; + if (!metadata?.embeds?.length) { + return null; + } + + let updated = false; + const updatedEmbeds = metadata.embeds.map((embed) => { + if (embed.type === 'permalink' && embed.data?.post_id === referencedPostId) { + const currentEditAt = embed.data.post?.edit_at || 0; + const freshEditAt = freshPostData.edit_at || 0; + + if (freshEditAt > currentEditAt) { + updated = true; + return { + ...embed, + data: { + ...embed.data, + post: { + ...embed.data.post, + id: freshPostData.id, + message: freshPostData.message, + edit_at: freshPostData.edit_at, + update_at: freshPostData.update_at, + user_id: freshPostData.user_id, + create_at: freshPostData.create_at, + }, + }, + }; + } + } + return embed; + }); + + if (updated) { + const updatedPost = referencingPost.prepareUpdate((post) => { + post.metadata = { + ...metadata, + embeds: updatedEmbeds, + }; + }); + return updatedPost; + } + + return null; + } catch (error) { + logWarning('Error updating permalink metadata:', error); + return null; + } +} diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index de5360b25..28129b474 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.originally_posted": "Originally posted in ", "mobile.permission_denied_dismiss": "Don't Allow", "mobile.permission_denied_retry": "Settings", "mobile.post_info.add_reaction": "Add Reaction", diff --git a/types/api/config.d.ts b/types/api/config.d.ts index 76b0b86fc..60e951227 100644 --- a/types/api/config.d.ts +++ b/types/api/config.d.ts @@ -70,6 +70,7 @@ interface ClientConfig { EnableLatex: string; EnableLdap: string; EnableLinkPreviews: string; + EnablePermalinkPreviews: string; EnableMarketplace: string; EnableMetrics: string; EnableMobileFileDownload: string; diff --git a/types/api/posts.d.ts b/types/api/posts.d.ts index 0b8699dd3..b7bfad4b2 100644 --- a/types/api/posts.d.ts +++ b/types/api/posts.d.ts @@ -30,7 +30,7 @@ type PostType = | 'custom_calls_recording' | 'custom_run_update'; -type PostEmbedType = 'image' | 'message_attachment' | 'opengraph'; +type PostEmbedType = 'image' | 'message_attachment' | 'opengraph' | 'permalink'; type PostAcknowledgement = { post_id: string; @@ -44,6 +44,15 @@ type PostPriority = { persistent_notifications?: boolean; }; +type PermalinkEmbedData = { + post_id: string; + post: Post; + team_name: string; + channel_display_name: string; + channel_type: string; + channel_id: string; +}; + type PostEmbed = { type: PostEmbedType; url: string;