Ability to show attachment if the permalink post contains attachments. (#9101)

* typescript and view component for permalink with user and message

* Old post edited handling in permalink

* Added test and update flag value to EnablePermalinkPreview

* Added test for permalink_preview component

* Added test for content/index.tsx for permalink

* Addressed review comments

* Unit test for missing file and review comments

* Added test to check handlePostEdited permalink sync only calls one time only

* Change TouchableOpacity to Pressable

* When user not found fetch the user from the server

* Removed the redundant test in the test for permalink_preview/index?

* ts to tsx

* Removed the circular dependency

* Address review comments

* displayname fallback

* remove permalink when permalink post is deleted

* UX review comments

* Linter fixes

* Test fixes

* File attachment in permalink preview component

* Fix the width and height of the image in permalink

* Added gredient when exceeds height of permalink container

* Minor

* Updated tests

* Minor

* Review comments

* Minor review comments

* type fixes

* Review comments

* Minor

* Address review comments

* Minor

* Merge fixes

* Addressed review comments

* Some more review comments

* linter fixes

* UX review and remove show more height not require

* Fix tests

* Review fixes, dev and ux

---------

Co-authored-by: yasserfaraazkhan <attitude3cena.yf@gmail.com>
This commit is contained in:
Rajat Dabade 2025-09-12 13:44:54 +05:30 committed by GitHub
parent 71fed8aa50
commit d732b5a33e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 511 additions and 120 deletions

View file

@ -45,7 +45,6 @@ type FileProps = {
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
fileWrapper: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
@ -64,7 +63,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
margin: 4,
},
audioFile: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
},

View file

@ -26,16 +26,17 @@ type FilesProps = {
isReplyPost: boolean;
postId?: string;
postProps?: Record<string, unknown>;
isPermalinkPreview?: boolean;
}
const MAX_VISIBLE_ROW_IMAGES = 4;
const styles = StyleSheet.create({
row: {
flex: 1,
flexDirection: 'row',
marginTop: 5,
flexShrink: 0,
},
container: {
rowItemContainer: {
flex: 1,
},
gutter: {
@ -59,6 +60,7 @@ const Files = ({
location,
postId,
postProps,
isPermalinkPreview = false,
}: FilesProps) => {
const galleryIdentifier = `${postId}-fileAttachments-${location}`;
const [inViewPort, setInViewPort] = useState(false);
@ -84,9 +86,14 @@ const Files = ({
const isSingleImage = useMemo(() => filesInfo.filter((f) => isImage(f) || isVideo(f)).length === 1, [filesInfo]);
const renderItems = (items: FileInfo[], moreImagesCount = 0, includeGutter = false) => {
const renderItems = (items: FileInfo[], moreImagesCount = 0, includeGutter = false, isImageRow = false) => {
let nonVisibleImagesCount: number;
let container: StyleProp<ViewStyle> = items.length > 1 ? styles.container : undefined;
// Apply flex: 1 for multiple items, except in permalink previews where vertical
// document lists should not use flex to prevent shrinking under maxHeight constraints.
// Horizontal image rows (includeGutter=true) still need flex for space distribution.
const shouldApplyFlex = items.length > 1 && !(isPermalinkPreview && !includeGutter);
let container: StyleProp<ViewStyle> = shouldApplyFlex ? styles.rowItemContainer : undefined;
const containerWithGutter = [container, styles.gutter];
const wrapperWidth = getViewPortWidth(isReplyPost, isTablet) - 6;
@ -98,9 +105,13 @@ const Files = ({
if (idx !== 0 && includeGutter) {
container = containerWithGutter;
}
const shouldRemoveMarginTop = isPermalinkPreview && (
(isImageRow) || // Remove marginTop for all images in image row
(!isImageRow && idx === 0 && imageAttachments.length === 0) // Remove marginTop for first non-image only if no images present
);
return (
<View
style={[container, styles.marginTop]}
style={[container, styles.marginTop, shouldRemoveMarginTop && {marginTop: 0}]}
testID={`${file.id}-file-container`}
key={file.id}
>
@ -138,10 +149,14 @@ const Files = ({
return (
<View
style={[styles.row, {width: portraitPostWidth}]}
style={[
styles.row,
{width: portraitPostWidth},
isPermalinkPreview && {marginTop: 0},
]}
testID='image-row'
>
{ renderItems(visibleImages, nonVisibleImagesCount, true) }
{ renderItems(visibleImages, nonVisibleImagesCount, true, true) }
</View>
);
};
@ -164,7 +179,10 @@ const Files = ({
<GalleryInit galleryIdentifier={galleryIdentifier}>
<Animated.View
testID='files-container'
style={failed ? styles.failed : undefined}
style={[
failed ? styles.failed : undefined,
isPermalinkPreview && {marginTop: 0},
]}
>
{renderImageRow()}
{renderItems(nonImageAttachments)}

View file

@ -116,6 +116,8 @@ const Content = ({isReplyPost, layoutWidth, location, post, theme, showPermalink
<PermalinkPreview
embedData={post.metadata!.embeds![0].data as PermalinkEmbedData}
location={location}
parentLocation={location}
parentPostId={post.id}
/>
);
}

View file

@ -24,19 +24,21 @@ const enhance = withObservables(['embedData'], ({database, embedData}: WithDatab
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(
const post = embedData?.post_id ? observePost(database, embedData.post_id) : of$(undefined);
const isOriginPostDeleted = post.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,
post,
isOriginPostDeleted,
};
});

View file

@ -0,0 +1,74 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useState, useCallback} from 'react';
import {DeviceEventEmitter, View, type LayoutChangeEvent, StyleSheet} from 'react-native';
import Files from '@components/files';
import {Events} from '@constants';
import type PostModel from '@typings/database/models/servers/post';
type PermalinkFilesProps = {
post: PostModel;
location: string;
isReplyPost: boolean;
failed?: boolean;
parentLocation?: string;
parentPostId?: string;
};
const styles = StyleSheet.create({
container: {
marginBottom: 8,
},
});
const PermalinkFiles = (props: PermalinkFilesProps) => {
const {parentLocation, parentPostId, post, location, isReplyPost, failed} = props;
const [layoutWidth, setLayoutWidth] = useState(0);
const listener = useCallback((viewableItemsMap: Record<string, boolean>) => {
if (!parentLocation || !parentPostId) {
return;
}
const parentKey = `${parentLocation}-${parentPostId}`;
if (viewableItemsMap[parentKey]) {
const viewableItems = {[`${location}-${post.id}`]: true};
DeviceEventEmitter.emit(Events.ITEM_IN_VIEWPORT, viewableItems);
}
}, [parentLocation, parentPostId, location, post.id]);
useEffect(() => {
if (!parentLocation || !parentPostId) {
return undefined;
}
const subscription = DeviceEventEmitter.addListener(Events.ITEM_IN_VIEWPORT, listener);
return () => subscription.remove();
}, [listener, parentLocation, parentPostId]);
const onLayout = useCallback((event: LayoutChangeEvent) => {
setLayoutWidth(event.nativeEvent.layout.width);
}, []);
return (
<View
onLayout={onLayout}
testID='permalink-files-container'
style={styles.container}
>
<Files
post={post}
location={location}
isReplyPost={isReplyPost}
failed={failed}
layoutWidth={layoutWidth}
isPermalinkPreview={true}
/>
</View>
);
};
export default PermalinkFiles;

View file

@ -6,11 +6,15 @@ import React from 'react';
import Markdown from '@components/markdown';
import {Screens} from '@constants';
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
import DatabaseManager from '@database/manager';
import {renderWithEverything} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import PermalinkPreview from './permalink_preview';
import type ServerDataOperator from '@database/operator/server_data_operator';
import type {Database} from '@nozbe/watermelondb';
jest.mock('@components/markdown', () => ({
__esModule: true,
default: jest.fn(),
@ -20,6 +24,45 @@ jest.mocked(Markdown).mockImplementation((props) =>
);
describe('components/post_list/post/body/content/permalink_preview/PermalinkPreview', () => {
let database: Database;
let operator: ServerDataOperator;
const serverUrl = 'http://localhost:8065';
beforeEach(async () => {
await DatabaseManager.init([serverUrl]);
const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
database = serverDatabaseAndOperator.database;
operator = serverDatabaseAndOperator.operator;
// Setup basic configs
await operator.handleConfigs({
configs: [
{id: 'EnablePermalinkPreviews', value: 'true'},
{id: 'TeammateNameDisplay', value: 'username'},
{id: 'EnablePublicLink', value: 'true'},
{id: 'EnableSecureFilePreview', value: 'false'},
],
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);
});
const renderPermalinkPreview = (props: Parameters<typeof PermalinkPreview>[0]) => {
return renderWithEverything(<PermalinkPreview {...props}/>, {database, serverUrl});
};
const baseProps: Parameters<typeof PermalinkPreview>[0] = {
embedData: {
post_id: 'post-123',
@ -45,16 +88,23 @@ describe('components/post_list/post/body/content/permalink_preview/PermalinkPrev
id: 'current-user',
locale: 'en',
}),
post: TestHelper.fakePostModel({
id: 'post-123',
userId: 'user-123',
message: 'This is a test message',
createAt: 1234567890000,
editAt: 0,
}),
isMilitaryTime: false,
teammateNameDisplay: 'username',
location: Screens.CHANNEL,
isOriginPostDeleted: false,
parentLocation: Screens.CHANNEL,
parentPostId: 'parent-post-123',
};
it('should render permalink preview correctly', () => {
const {getByText} = renderWithIntlAndTheme(
<PermalinkPreview {...baseProps}/>,
);
const {getByText} = renderPermalinkPreview(baseProps);
expect(getByText('testuser')).toBeTruthy();
expect(getByText('This is a test message')).toBeTruthy();
@ -63,9 +113,7 @@ describe('components/post_list/post/body/content/permalink_preview/PermalinkPrev
it('should not render when origin post is deleted', () => {
const props = {...baseProps, isOriginPostDeleted: true};
const {queryByText} = renderWithIntlAndTheme(
<PermalinkPreview {...props}/>,
);
const {queryByText} = renderPermalinkPreview(props);
expect(queryByText('testuser')).toBeNull();
expect(queryByText('This is a test message')).toBeNull();
@ -78,18 +126,15 @@ describe('components/post_list/post/body/content/permalink_preview/PermalinkPrev
...baseProps.embedData,
post: undefined as unknown as Post,
},
post: undefined,
};
const {queryByText} = renderWithIntlAndTheme(
<PermalinkPreview {...props}/>,
);
const {queryByText} = renderPermalinkPreview(props);
expect(queryByText('testuser')).toBeNull();
});
it('should handle press event without crashing', () => {
const {getByTestId} = renderWithIntlAndTheme(
<PermalinkPreview {...baseProps}/>,
);
const {getByTestId} = renderPermalinkPreview(baseProps);
const permalinkContainer = getByTestId('permalink-preview-container');
expect(() => {
fireEvent.press(permalinkContainer);
@ -98,9 +143,7 @@ describe('components/post_list/post/body/content/permalink_preview/PermalinkPrev
});
it('should display author name from user model', () => {
const {getByText} = renderWithIntlAndTheme(
<PermalinkPreview {...baseProps}/>,
);
const {getByText} = renderPermalinkPreview(baseProps);
expect(getByText('testuser')).toBeTruthy();
});
@ -118,17 +161,13 @@ describe('components/post_list/post/body/content/permalink_preview/PermalinkPrev
}),
},
};
const {getByText} = renderWithIntlAndTheme(
<PermalinkPreview {...props}/>,
);
const {getByText} = renderPermalinkPreview(props);
expect(getByText('Someone')).toBeTruthy();
});
it('should display channel name with ~ prefix for public channels', () => {
const {getByText} = renderWithIntlAndTheme(
<PermalinkPreview {...baseProps}/>,
);
const {getByText} = renderPermalinkPreview(baseProps);
expect(getByText('Originally posted in ~Test Channel')).toBeTruthy();
});
@ -142,15 +181,13 @@ describe('components/post_list/post/body/content/permalink_preview/PermalinkPrev
channel_display_name: 'testuser',
},
};
const {getByText} = renderWithIntlAndTheme(
<PermalinkPreview {...props}/>,
);
const {getByText} = renderPermalinkPreview(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 longMessage = 'This is a very long message that should be truncated when it exceeds the maximum character limit of 150 characters for permalink previews in the mobile app.';
const props = {
...baseProps,
embedData: {
@ -162,11 +199,10 @@ describe('components/post_list/post/body/content/permalink_preview/PermalinkPrev
}),
},
};
const {getByText} = renderWithIntlAndTheme(
<PermalinkPreview {...props}/>,
);
const {getByText} = renderPermalinkPreview(props);
expect(getByText('Line 1\n...Line 2\n...Line 3\n...Line 4')).toBeTruthy();
const expectedTruncated = longMessage.substring(0, 150) + '...';
expect(getByText(expectedTruncated)).toBeTruthy();
});
it('should handle empty message', () => {
@ -181,11 +217,8 @@ describe('components/post_list/post/body/content/permalink_preview/PermalinkPrev
}),
},
};
const {queryByText} = renderWithIntlAndTheme(
<PermalinkPreview {...props}/>,
);
const {queryByText} = renderPermalinkPreview(props);
// Should render but with empty message
expect(queryByText('This is a test message')).toBeNull();
});
@ -203,37 +236,133 @@ describe('components/post_list/post/body/content/permalink_preview/PermalinkPrev
}),
},
};
const {getByTestId} = renderWithIntlAndTheme(
<PermalinkPreview {...props}/>,
);
const {getByTestId} = renderPermalinkPreview(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}/>,
);
const {queryByTestId} = renderPermalinkPreview(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
const {getByText} = renderPermalinkPreview(baseProps);
expect(getByText('11:31 PM')).toBeTruthy();
});
it('should display profile picture', () => {
const {root} = renderWithIntlAndTheme(
<PermalinkPreview {...baseProps}/>,
);
describe('file attachments', () => {
it('should render PermalinkFiles component when post has file attachments', () => {
const fileInfo = TestHelper.fakeFileInfo({
id: 'file-123',
name: 'document.pdf',
mime_type: 'application/pdf',
size: 1048576,
});
// Profile picture should be rendered (check for the image component)
const profilePicture = root.findByType('ViewManagerAdapter_ExpoImage');
expect(profilePicture).toBeTruthy();
const propsWithFiles = {
...baseProps,
embedData: {
...baseProps.embedData,
post: TestHelper.fakePost({
id: 'post-123',
user_id: 'user-123',
message: 'Post with file attachment',
metadata: {
files: [fileInfo],
},
}),
},
};
const {getByText, getByTestId} = renderPermalinkPreview(propsWithFiles);
expect(getByText('Post with file attachment')).toBeTruthy();
expect(getByTestId('permalink-files-container')).toBeTruthy();
});
it('should render PermalinkFiles with multiple file attachments', () => {
const fileInfos = [
TestHelper.fakeFileInfo({
id: 'file-1',
name: 'image.jpg',
mime_type: 'image/jpeg',
size: 2048576,
}),
TestHelper.fakeFileInfo({
id: 'file-2',
name: 'document.pdf',
mime_type: 'application/pdf',
size: 1048576,
}),
];
const propsWithMultipleFiles = {
...baseProps,
embedData: {
...baseProps.embedData,
post: TestHelper.fakePost({
id: 'post-123',
user_id: 'user-123',
message: 'Post with multiple attachments',
metadata: {
files: fileInfos,
},
}),
},
};
const {getByText, getByTestId} = renderPermalinkPreview(propsWithMultipleFiles);
expect(getByText('Post with multiple attachments')).toBeTruthy();
expect(getByTestId('permalink-files-container')).toBeTruthy();
});
it('should not render PermalinkFiles when post has no file attachments', () => {
const propsWithoutFiles = {
...baseProps,
embedData: {
...baseProps.embedData,
post: TestHelper.fakePost({
id: 'post-123',
user_id: 'user-123',
message: 'Post without files',
metadata: {},
}),
},
};
const {getByText, queryByTestId} = renderPermalinkPreview(propsWithoutFiles);
expect(getByText('Post without files')).toBeTruthy();
expect(queryByTestId('permalink-files-container')).toBeNull();
});
it('should not render PermalinkFiles when files array is empty', () => {
const propsWithEmptyFiles = {
...baseProps,
embedData: {
...baseProps.embedData,
post: TestHelper.fakePost({
id: 'post-123',
user_id: 'user-123',
message: 'Post with empty files',
metadata: {
files: [],
},
}),
},
};
const {getByText, queryByTestId} = renderPermalinkPreview(propsWithEmptyFiles);
expect(getByText('Post with empty files')).toBeTruthy();
expect(queryByTestId('permalink-files-container')).toBeNull();
});
});
});

View file

@ -1,8 +1,9 @@
// 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 {LinearGradient} from 'expo-linear-gradient';
import React, {useMemo, useCallback, useEffect, useState} from 'react';
import {Text, View, Pressable, type LayoutChangeEvent} from 'react-native';
import {fetchUsersByIds} from '@actions/remote/user';
import EditedIndicator from '@components/edited_indicator';
@ -13,16 +14,20 @@ import ProfilePicture from '@components/profile_picture';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useUserLocale} from '@context/user_locale';
import {useWindowDimensions} from '@hooks/device';
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 PermalinkFiles from './permalink_files';
import type PostModel from '@typings/database/models/servers/post';
import type UserModel from '@typings/database/models/servers/user';
import type {AvailableScreens} from '@typings/screens/navigation';
const MAX_PERMALINK_PREVIEW_LINES = 4;
const MAX_PERMALINK_PREVIEW_CHARACTERS = 150;
const EDITED_INDICATOR_CONTEXT = ['paragraph'];
type PermalinkPreviewProps = {
@ -31,8 +36,11 @@ type PermalinkPreviewProps = {
currentUser?: UserModel;
isMilitaryTime: boolean;
teammateNameDisplay: string;
post?: PostModel;
isOriginPostDeleted?: boolean;
location: AvailableScreens;
parentLocation?: string;
parentPostId?: string;
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
@ -51,7 +59,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
},
shadowOpacity: 0.08,
shadowRadius: 3,
elevation: 2,
elevation: 1,
overflow: 'hidden',
},
header: {
flexDirection: 'row',
@ -75,8 +84,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
},
messageContainer: {
marginBottom: 8,
maxHeight: 20 * MAX_PERMALINK_PREVIEW_LINES,
overflow: 'hidden',
},
messageText: {
color: theme.centerChannelColor,
@ -85,6 +92,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
},
channelContext: {
marginTop: 8,
color: changeOpacity(theme.centerChannelColor, 0.64),
...typography('Body', 75),
},
@ -92,6 +100,17 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
color: theme.linkColor,
...typography('Body', 75, 'SemiBold'),
},
contentContainer: {
overflow: 'hidden',
position: 'relative',
},
gradientOverlay: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 60,
},
};
});
@ -101,14 +120,20 @@ const PermalinkPreview = ({
currentUser,
isMilitaryTime,
teammateNameDisplay,
post,
isOriginPostDeleted,
location,
parentLocation,
parentPostId,
}: PermalinkPreviewProps) => {
const theme = useTheme();
const serverUrl = useServerUrl();
const locale = useUserLocale();
const dimensions = useWindowDimensions();
const styles = getStyleSheet(theme);
const [showGradient, setShowGradient] = useState(false);
const maxPermalinkHeight = Math.round(dimensions.height * 0.5);
const textStyles = getMarkdownTextStyles(theme);
const blockStyles = getMarkdownBlockStyles(theme);
@ -125,28 +150,27 @@ const PermalinkPreview = ({
}
const {
post,
post: embedPost,
channel_display_name,
channel_type,
} = embedData;
const truncatedMessage = useMemo(() => {
const message = post?.message;
if (!message || typeof message !== 'string') {
const msg = embedPost?.message;
if (!msg || typeof msg !== '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...');
const cleanMessage = msg.trim();
if (cleanMessage.length > MAX_PERMALINK_PREVIEW_CHARACTERS) {
return cleanMessage.substring(0, MAX_PERMALINK_PREVIEW_CHARACTERS) + '...';
}
return cleanMessage;
}, [post?.message]);
}, [embedPost?.message]);
const isEdited = useMemo(() => post && post.edit_at > 0, [post]);
const isEdited = useMemo(() => embedData && embedData.post && embedData.post.edit_at > 0, [embedData]);
const authorDisplayName = useMemo(() => {
return displayUsername(author, locale, teammateNameDisplay);
@ -157,10 +181,21 @@ const PermalinkPreview = ({
return `~${displayName}`;
}, [channel_display_name, channel_type, authorDisplayName]);
const filesInfo = useMemo(() => {
return embedData?.post?.metadata?.files || [];
}, [embedData?.post?.metadata?.files]);
const hasFiles = filesInfo.length > 0;
const handlePress = usePreventDoubleTap(useCallback(() => {
// Navigation will be implemented in Task 5
}, []));
const handleContentLayout = useCallback((event: LayoutChangeEvent) => {
const {height} = event.nativeEvent.layout;
setShowGradient(height >= maxPermalinkHeight);
}, [maxPermalinkHeight]);
if (!post) {
return null;
}
@ -174,48 +209,70 @@ const PermalinkPreview = ({
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.contentContainer, {maxHeight: maxPermalinkHeight}]}>
<View onLayout={handleContentLayout}>
<View style={styles.header}>
<ProfilePicture
author={author}
size={32}
showStatus={false}
/>
<View style={styles.authorInfo}>
<Text
style={styles.authorName}
numberOfLines={1}
>
{authorDisplayName}
</Text>
<FormattedTime
timezone={getUserTimezone(currentUser)}
isMilitaryTime={isMilitaryTime}
value={embedPost.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'
<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>
{hasFiles && post && (
<PermalinkFiles
post={post}
location='permalink_preview'
isReplyPost={false}
parentLocation={parentLocation}
parentPostId={parentPostId}
/>
)}
</View>
{showGradient && (
<LinearGradient
colors={[changeOpacity(theme.centerChannelBg, 0), theme.centerChannelBg]}
style={styles.gradientOverlay}
pointerEvents='none'
/>
) : null}
)}
</View>
<Text style={styles.channelContext}>

View file

@ -187,6 +187,115 @@ describe('Permalink Sync Utils', () => {
expect(result).toBeNull();
});
it('should update permalink metadata including file metadata when fresh post is newer', async () => {
const referencedPostId = 'referenced_post_with_files';
const oldFileMetadata: FileInfo[] = [
{
id: 'file1',
name: 'old_file.txt',
extension: 'txt',
size: 100,
mime_type: 'text/plain',
width: 0,
height: 0,
has_preview_image: false,
user_id: 'user_123',
},
];
const newFileMetadata: FileInfo[] = [
{
id: 'file1',
name: 'old_file.txt',
extension: 'txt',
size: 100,
mime_type: 'text/plain',
width: 0,
height: 0,
has_preview_image: false,
user_id: 'user_123',
},
{
id: 'file2',
name: 'new_file.jpg',
extension: 'jpg',
size: 2000,
mime_type: 'image/jpeg',
width: 1920,
height: 1080,
has_preview_image: true,
user_id: 'user_123',
},
];
const referencingPost = TestHelper.fakePost({
id: 'referencing_post_files',
channel_id: 'channel1',
metadata: {
embeds: [
{
type: 'permalink',
url: '',
data: {
post_id: referencedPostId,
post: {
id: referencedPostId,
message: 'Post with old files',
edit_at: 1000,
update_at: 1000,
user_id: 'user_123',
create_at: 500,
metadata: {
files: oldFileMetadata,
},
},
},
},
],
},
});
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: 'Post with updated files',
edit_at: 2000,
update_at: 2000,
user_id: 'user_123',
create_at: 500,
metadata: {
files: newFileMetadata,
},
});
const result = updatePermalinkMetadata(postModel, referencedPostId, freshPostData);
expect(result).not.toBeNull();
expect(result!.id).toBe(referencingPost.id);
expect(result!._preparedState).toBe('update');
// Verify the metadata changes
expect(result!.metadata!.embeds![0].data.post.message).toBe('Post with updated files');
expect(result!.metadata!.embeds![0].data.post.edit_at).toBe(2000);
expect(result!.metadata!.embeds![0].data.post.metadata).toEqual({
files: newFileMetadata,
});
expect(result!.metadata!.embeds![0].data.post.metadata!.files).toHaveLength(2);
expect(result!.metadata!.embeds![0].data.post.metadata!.files![1].name).toBe('new_file.jpg');
await operator.batchRecords([result!], 'test file metadata update');
});
it('should handle errors during post update gracefully', async () => {
const referencedPostId = 'referenced_post_456';

View file

@ -39,6 +39,8 @@ export function updatePermalinkMetadata(
update_at: freshPostData.update_at,
user_id: freshPostData.user_id,
create_at: freshPostData.create_at,
file_ids: freshPostData.file_ids,
metadata: freshPostData.metadata,
},
},
};