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:
parent
82c7787dc4
commit
113f9d942c
20 changed files with 2085 additions and 120 deletions
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
315
app/components/post_list/post/body/content/content.test.tsx
Normal file
315
app/components/post_list/post/body/content/content.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
126
app/components/post_list/post/body/content/content.tsx
Normal file
126
app/components/post_list/post/body/content/content.tsx
Normal 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;
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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));
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
281
app/queries/servers/post.test.ts
Normal file
281
app/queries/servers/post.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 [];
|
||||
}
|
||||
};
|
||||
|
|
|
|||
249
app/utils/permalink_sync.test.ts
Normal file
249
app/utils/permalink_sync.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
65
app/utils/permalink_sync.ts
Normal file
65
app/utils/permalink_sync.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
1
types/api/config.d.ts
vendored
1
types/api/config.d.ts
vendored
|
|
@ -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
11
types/api/posts.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue