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