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 <attitude3cena.yf@gmail.com>
This commit is contained in:
Rajat Dabade 2025-09-10 17:12:16 +05:30 committed by GitHub
parent 82c7787dc4
commit 113f9d942c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 2085 additions and 120 deletions

View file

@ -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', () => {

View file

@ -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,

View file

@ -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';

View file

@ -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';

View file

@ -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(
<Content
{...baseProps}
post={post}
/>,
);
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(
<Content
{...baseProps}
post={post}
/>,
);
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(
<Content
{...baseProps}
post={post}
/>,
);
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(
<Content
{...baseProps}
post={post}
/>,
);
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(
<Content
{...customProps}
post={post}
/>,
);
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(
<Content
{...baseProps}
post={post}
/>,
);
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(
<Content
{...baseProps}
post={post}
/>,
);
const permalinkPreview = getByTestId('permalink-preview');
expect(permalinkPreview.props.embedData).toEqual(gmEmbedData);
expect(permalinkPreview.props.embedData.channel_type).toBe('G');
});
});

View file

@ -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<string, string> = {
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 (
<ImagePreview
isReplyPost={isReplyPost}
layoutWidth={layoutWidth}
location={location}
metadata={post.metadata}
postId={post.id}
theme={theme}
/>
);
case contentType.opengraph:
if (isYoutubeLink(post.metadata!.embeds![0].url)) {
return (
<YouTube
isReplyPost={isReplyPost}
layoutWidth={layoutWidth}
metadata={post.metadata}
/>
);
}
return (
<Opengraph
isReplyPost={isReplyPost}
layoutWidth={layoutWidth}
location={location}
metadata={post.metadata}
postId={post.id}
removeLinkPreview={post.props?.remove_link_preview === 'true'}
theme={theme}
/>
);
case contentType.message_attachment:
if (attachments.length) {
return (
<MessageAttachments
attachments={attachments}
channelId={post.channelId}
layoutWidth={layoutWidth}
location={location}
metadata={post.metadata}
postId={post.id}
theme={theme}
/>
);
}
break;
case contentType.app_bindings:
if (nAppBindings) {
return (
<EmbeddedBindings
location={location}
post={post}
theme={theme}
/>
);
}
break;
case contentType.permalink:
if (!showPermalinkPreviews) {
return null;
}
return (
<PermalinkPreview
embedData={post.metadata!.embeds![0].data as PermalinkEmbedData}
location={location}
/>
);
}
return null;
};
export default Content;

View file

@ -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<string, string> = {
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 (
<ImagePreview
isReplyPost={isReplyPost}
layoutWidth={layoutWidth}
location={location}
metadata={post.metadata}
postId={post.id}
theme={theme}
/>
);
case contentType.opengraph:
if (isYoutubeLink(post.metadata!.embeds![0].url)) {
return (
<YouTube
isReplyPost={isReplyPost}
layoutWidth={layoutWidth}
metadata={post.metadata}
/>
);
}
return (
<Opengraph
isReplyPost={isReplyPost}
layoutWidth={layoutWidth}
location={location}
metadata={post.metadata}
postId={post.id}
removeLinkPreview={post.props?.remove_link_preview === 'true'}
theme={theme}
/>
);
case contentType.message_attachment:
if (attachments.length) {
return (
<MessageAttachments
attachments={attachments}
channelId={post.channelId}
layoutWidth={layoutWidth}
location={location}
metadata={post.metadata}
postId={post.id}
theme={theme}
/>
);
}
break;
case contentType.app_bindings:
if (nAppBindings) {
return (
<EmbeddedBindings
location={location}
post={post}
theme={theme}
/>
);
}
break;
}
return null;
};
export default Content;
export default withDatabase(enhance(Content));

View file

@ -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(
<EnhancedPermalinkPreview
embedData={embedData}
location={Screens.CHANNEL}
/>,
{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(
<EnhancedPermalinkPreview
embedData={embedData}
location={Screens.CHANNEL}
/>,
{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(
<EnhancedPermalinkPreview
embedData={embedData}
location={Screens.CHANNEL}
/>,
{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(
<EnhancedPermalinkPreview
embedData={embedData}
location={Screens.CHANNEL}
/>,
{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(
<EnhancedPermalinkPreview
embedData={embedData}
location={Screens.CHANNEL}
/>,
{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(
<EnhancedPermalinkPreview
embedData={embedData}
location={Screens.CHANNEL}
/>,
{database, serverUrl},
);
await waitFor(() => {
const permalinkPreview = getByTestId('permalink-preview');
expect(permalinkPreview.props.isMilitaryTime).toBe(true);
});
});
});

View file

@ -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));

View file

@ -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<typeof PermalinkPreview>[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(
<PermalinkPreview {...baseProps}/>,
);
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(
<PermalinkPreview {...props}/>,
);
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(
<PermalinkPreview {...props}/>,
);
expect(queryByText('testuser')).toBeNull();
});
it('should handle press event without crashing', () => {
const {getByTestId} = renderWithIntlAndTheme(
<PermalinkPreview {...baseProps}/>,
);
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(
<PermalinkPreview {...baseProps}/>,
);
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(
<PermalinkPreview {...props}/>,
);
expect(getByText('Someone')).toBeTruthy();
});
it('should display channel name with ~ prefix for public channels', () => {
const {getByText} = renderWithIntlAndTheme(
<PermalinkPreview {...baseProps}/>,
);
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(
<PermalinkPreview {...props}/>,
);
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(
<PermalinkPreview {...props}/>,
);
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(
<PermalinkPreview {...props}/>,
);
// 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(
<PermalinkPreview {...props}/>,
);
expect(getByTestId('permalink_preview.edited_indicator_separate')).toBeTruthy();
});
it('should not show edited indicator when post is not edited', () => {
const {queryByTestId} = renderWithIntlAndTheme(
<PermalinkPreview {...baseProps}/>,
);
expect(queryByTestId('permalink_preview.edited_indicator_separate')).toBeNull();
});
it('should display formatted time correctly', () => {
const {getByText} = renderWithIntlAndTheme(
<PermalinkPreview {...baseProps}/>,
);
// FormattedTime component should render the time text
expect(getByText('11:31 PM')).toBeTruthy();
});
it('should display profile picture', () => {
const {root} = renderWithIntlAndTheme(
<PermalinkPreview {...baseProps}/>,
);
// Profile picture should be rendered (check for the image component)
const profilePicture = root.findByType('ViewManagerAdapter_ExpoImage');
expect(profilePicture).toBeTruthy();
});
});

View file

@ -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 (
<Pressable
style={({pressed}) => [
styles.container,
{opacity: pressed ? 0.8 : 1},
]}
onPress={handlePress}
testID='permalink-preview-container'
>
<View style={styles.header}>
<ProfilePicture
author={author || {id: post.user_id} as UserModel}
size={32}
showStatus={false}
/>
<View style={styles.authorInfo}>
<Text
style={styles.authorName}
numberOfLines={1}
>
{authorDisplayName}
</Text>
<FormattedTime
timezone={getUserTimezone(currentUser)}
isMilitaryTime={isMilitaryTime}
value={post.create_at}
style={styles.timestamp}
/>
</View>
</View>
<View style={styles.messageContainer}>
<Markdown
baseTextStyle={styles.messageText}
blockStyles={blockStyles}
channelId={embedData.channel_id}
disableAtMentions={true}
location={location}
theme={theme}
textStyles={textStyles}
value={truncatedMessage}
/>
{isEdited ? (
<EditedIndicator
baseTextStyle={styles.messageText}
theme={theme}
context={EDITED_INDICATOR_CONTEXT}
iconSize={12}
testID='permalink_preview.edited_indicator_separate'
/>
) : null}
</View>
<Text style={styles.channelContext}>
<FormattedText
id='mobile.permalink_preview.originally_posted'
defaultMessage='Originally posted in '
style={styles.channelContext}
/>
<Text style={styles.channelName}>
{channelContextText}
</Text>
</Text>
</Pressable>
);
};
export default PermalinkPreview;

View file

@ -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);
});
});
});

View file

@ -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<PostModel[]> => {
try {
const clauses: Q.Clause[] = [
Q.where('metadata', Q.notEq(null)),
Q.where('delete_at', Q.eq(0)),
];
const postsWithMetadata = await database.get<PostModel>(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<PostModel[]> => {
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<PostModel[]> => {
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 [];
}
};

View file

@ -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);
});
});
});

View file

@ -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;
}
}

View file

@ -772,6 +772,7 @@
"mobile.pdf_viewer.password_limit_reached": "Youve 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",

View file

@ -70,6 +70,7 @@ interface ClientConfig {
EnableLatex: string;
EnableLdap: string;
EnableLinkPreviews: string;
EnablePermalinkPreviews: string;
EnableMarketplace: string;
EnableMetrics: string;
EnableMobileFileDownload: string;

11
types/api/posts.d.ts vendored
View file

@ -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;