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

View file

@ -26,16 +26,17 @@ type FilesProps = {
isReplyPost: boolean; isReplyPost: boolean;
postId?: string; postId?: string;
postProps?: Record<string, unknown>; postProps?: Record<string, unknown>;
isPermalinkPreview?: boolean;
} }
const MAX_VISIBLE_ROW_IMAGES = 4; const MAX_VISIBLE_ROW_IMAGES = 4;
const styles = StyleSheet.create({ const styles = StyleSheet.create({
row: { row: {
flex: 1,
flexDirection: 'row', flexDirection: 'row',
marginTop: 5, marginTop: 5,
flexShrink: 0,
}, },
container: { rowItemContainer: {
flex: 1, flex: 1,
}, },
gutter: { gutter: {
@ -59,6 +60,7 @@ const Files = ({
location, location,
postId, postId,
postProps, postProps,
isPermalinkPreview = false,
}: FilesProps) => { }: FilesProps) => {
const galleryIdentifier = `${postId}-fileAttachments-${location}`; const galleryIdentifier = `${postId}-fileAttachments-${location}`;
const [inViewPort, setInViewPort] = useState(false); 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 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 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 containerWithGutter = [container, styles.gutter];
const wrapperWidth = getViewPortWidth(isReplyPost, isTablet) - 6; const wrapperWidth = getViewPortWidth(isReplyPost, isTablet) - 6;
@ -98,9 +105,13 @@ const Files = ({
if (idx !== 0 && includeGutter) { if (idx !== 0 && includeGutter) {
container = containerWithGutter; 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 ( return (
<View <View
style={[container, styles.marginTop]} style={[container, styles.marginTop, shouldRemoveMarginTop && {marginTop: 0}]}
testID={`${file.id}-file-container`} testID={`${file.id}-file-container`}
key={file.id} key={file.id}
> >
@ -138,10 +149,14 @@ const Files = ({
return ( return (
<View <View
style={[styles.row, {width: portraitPostWidth}]} style={[
styles.row,
{width: portraitPostWidth},
isPermalinkPreview && {marginTop: 0},
]}
testID='image-row' testID='image-row'
> >
{ renderItems(visibleImages, nonVisibleImagesCount, true) } { renderItems(visibleImages, nonVisibleImagesCount, true, true) }
</View> </View>
); );
}; };
@ -164,7 +179,10 @@ const Files = ({
<GalleryInit galleryIdentifier={galleryIdentifier}> <GalleryInit galleryIdentifier={galleryIdentifier}>
<Animated.View <Animated.View
testID='files-container' testID='files-container'
style={failed ? styles.failed : undefined} style={[
failed ? styles.failed : undefined,
isPermalinkPreview && {marginTop: 0},
]}
> >
{renderImageRow()} {renderImageRow()}
{renderItems(nonImageAttachments)} {renderItems(nonImageAttachments)}

View file

@ -116,6 +116,8 @@ const Content = ({isReplyPost, layoutWidth, location, post, theme, showPermalink
<PermalinkPreview <PermalinkPreview
embedData={post.metadata!.embeds![0].data as PermalinkEmbedData} embedData={post.metadata!.embeds![0].data as PermalinkEmbedData}
location={location} 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 userId = embedData?.post?.user_id;
const author = userId ? observeUser(database, userId) : of$(undefined); 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) => { switchMap((p) => {
const initialDeleted = Boolean(embedData?.post?.delete_at > 0 || embedData?.post?.state === 'DELETED'); const initialDeleted = Boolean(embedData?.post?.delete_at > 0 || embedData?.post?.state === 'DELETED');
return of$(p ? p.deleteAt > 0 : initialDeleted); return of$(p ? p.deleteAt > 0 : initialDeleted);
}), }),
distinctUntilChanged(), distinctUntilChanged(),
) : of$(false); );
return { return {
teammateNameDisplay, teammateNameDisplay,
currentUser, currentUser,
isMilitaryTime, isMilitaryTime,
author, author,
post,
isOriginPostDeleted, 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 Markdown from '@components/markdown';
import {Screens} from '@constants'; 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 TestHelper from '@test/test_helper';
import PermalinkPreview from './permalink_preview'; import PermalinkPreview from './permalink_preview';
import type ServerDataOperator from '@database/operator/server_data_operator';
import type {Database} from '@nozbe/watermelondb';
jest.mock('@components/markdown', () => ({ jest.mock('@components/markdown', () => ({
__esModule: true, __esModule: true,
default: jest.fn(), default: jest.fn(),
@ -20,6 +24,45 @@ jest.mocked(Markdown).mockImplementation((props) =>
); );
describe('components/post_list/post/body/content/permalink_preview/PermalinkPreview', () => { 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] = { const baseProps: Parameters<typeof PermalinkPreview>[0] = {
embedData: { embedData: {
post_id: 'post-123', post_id: 'post-123',
@ -45,16 +88,23 @@ describe('components/post_list/post/body/content/permalink_preview/PermalinkPrev
id: 'current-user', id: 'current-user',
locale: 'en', locale: 'en',
}), }),
post: TestHelper.fakePostModel({
id: 'post-123',
userId: 'user-123',
message: 'This is a test message',
createAt: 1234567890000,
editAt: 0,
}),
isMilitaryTime: false, isMilitaryTime: false,
teammateNameDisplay: 'username', teammateNameDisplay: 'username',
location: Screens.CHANNEL, location: Screens.CHANNEL,
isOriginPostDeleted: false, isOriginPostDeleted: false,
parentLocation: Screens.CHANNEL,
parentPostId: 'parent-post-123',
}; };
it('should render permalink preview correctly', () => { it('should render permalink preview correctly', () => {
const {getByText} = renderWithIntlAndTheme( const {getByText} = renderPermalinkPreview(baseProps);
<PermalinkPreview {...baseProps}/>,
);
expect(getByText('testuser')).toBeTruthy(); expect(getByText('testuser')).toBeTruthy();
expect(getByText('This is a test message')).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', () => { it('should not render when origin post is deleted', () => {
const props = {...baseProps, isOriginPostDeleted: true}; const props = {...baseProps, isOriginPostDeleted: true};
const {queryByText} = renderWithIntlAndTheme( const {queryByText} = renderPermalinkPreview(props);
<PermalinkPreview {...props}/>,
);
expect(queryByText('testuser')).toBeNull(); expect(queryByText('testuser')).toBeNull();
expect(queryByText('This is a test message')).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, ...baseProps.embedData,
post: undefined as unknown as Post, post: undefined as unknown as Post,
}, },
post: undefined,
}; };
const {queryByText} = renderWithIntlAndTheme( const {queryByText} = renderPermalinkPreview(props);
<PermalinkPreview {...props}/>,
);
expect(queryByText('testuser')).toBeNull(); expect(queryByText('testuser')).toBeNull();
}); });
it('should handle press event without crashing', () => { it('should handle press event without crashing', () => {
const {getByTestId} = renderWithIntlAndTheme( const {getByTestId} = renderPermalinkPreview(baseProps);
<PermalinkPreview {...baseProps}/>,
);
const permalinkContainer = getByTestId('permalink-preview-container'); const permalinkContainer = getByTestId('permalink-preview-container');
expect(() => { expect(() => {
fireEvent.press(permalinkContainer); 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', () => { it('should display author name from user model', () => {
const {getByText} = renderWithIntlAndTheme( const {getByText} = renderPermalinkPreview(baseProps);
<PermalinkPreview {...baseProps}/>,
);
expect(getByText('testuser')).toBeTruthy(); expect(getByText('testuser')).toBeTruthy();
}); });
@ -118,17 +161,13 @@ describe('components/post_list/post/body/content/permalink_preview/PermalinkPrev
}), }),
}, },
}; };
const {getByText} = renderWithIntlAndTheme( const {getByText} = renderPermalinkPreview(props);
<PermalinkPreview {...props}/>,
);
expect(getByText('Someone')).toBeTruthy(); expect(getByText('Someone')).toBeTruthy();
}); });
it('should display channel name with ~ prefix for public channels', () => { it('should display channel name with ~ prefix for public channels', () => {
const {getByText} = renderWithIntlAndTheme( const {getByText} = renderPermalinkPreview(baseProps);
<PermalinkPreview {...baseProps}/>,
);
expect(getByText('Originally posted in ~Test Channel')).toBeTruthy(); 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', channel_display_name: 'testuser',
}, },
}; };
const {getByText} = renderWithIntlAndTheme( const {getByText} = renderPermalinkPreview(props);
<PermalinkPreview {...props}/>,
);
expect(getByText('Originally posted in ~testuser')).toBeTruthy(); expect(getByText('Originally posted in ~testuser')).toBeTruthy();
}); });
it('should truncate long messages', () => { 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 = { const props = {
...baseProps, ...baseProps,
embedData: { embedData: {
@ -162,11 +199,10 @@ describe('components/post_list/post/body/content/permalink_preview/PermalinkPrev
}), }),
}, },
}; };
const {getByText} = renderWithIntlAndTheme( const {getByText} = renderPermalinkPreview(props);
<PermalinkPreview {...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', () => { it('should handle empty message', () => {
@ -181,11 +217,8 @@ describe('components/post_list/post/body/content/permalink_preview/PermalinkPrev
}), }),
}, },
}; };
const {queryByText} = renderWithIntlAndTheme( const {queryByText} = renderPermalinkPreview(props);
<PermalinkPreview {...props}/>,
);
// Should render but with empty message
expect(queryByText('This is a test message')).toBeNull(); 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( const {getByTestId} = renderPermalinkPreview(props);
<PermalinkPreview {...props}/>,
);
expect(getByTestId('permalink_preview.edited_indicator_separate')).toBeTruthy(); expect(getByTestId('permalink_preview.edited_indicator_separate')).toBeTruthy();
}); });
it('should not show edited indicator when post is not edited', () => { it('should not show edited indicator when post is not edited', () => {
const {queryByTestId} = renderWithIntlAndTheme( const {queryByTestId} = renderPermalinkPreview(baseProps);
<PermalinkPreview {...baseProps}/>,
);
expect(queryByTestId('permalink_preview.edited_indicator_separate')).toBeNull(); expect(queryByTestId('permalink_preview.edited_indicator_separate')).toBeNull();
}); });
it('should display formatted time correctly', () => { it('should display formatted time correctly', () => {
const {getByText} = renderWithIntlAndTheme( const {getByText} = renderPermalinkPreview(baseProps);
<PermalinkPreview {...baseProps}/>,
);
// FormattedTime component should render the time text
expect(getByText('11:31 PM')).toBeTruthy(); expect(getByText('11:31 PM')).toBeTruthy();
}); });
it('should display profile picture', () => { describe('file attachments', () => {
const {root} = renderWithIntlAndTheme( it('should render PermalinkFiles component when post has file attachments', () => {
<PermalinkPreview {...baseProps}/>, 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 propsWithFiles = {
const profilePicture = root.findByType('ViewManagerAdapter_ExpoImage'); ...baseProps,
expect(profilePicture).toBeTruthy(); 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. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React, {useMemo, useCallback, useEffect} from 'react'; import {LinearGradient} from 'expo-linear-gradient';
import {Text, View, Pressable} from 'react-native'; import React, {useMemo, useCallback, useEffect, useState} from 'react';
import {Text, View, Pressable, type LayoutChangeEvent} from 'react-native';
import {fetchUsersByIds} from '@actions/remote/user'; import {fetchUsersByIds} from '@actions/remote/user';
import EditedIndicator from '@components/edited_indicator'; import EditedIndicator from '@components/edited_indicator';
@ -13,16 +14,20 @@ import ProfilePicture from '@components/profile_picture';
import {useServerUrl} from '@context/server'; import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme'; import {useTheme} from '@context/theme';
import {useUserLocale} from '@context/user_locale'; import {useUserLocale} from '@context/user_locale';
import {useWindowDimensions} from '@hooks/device';
import {usePreventDoubleTap} from '@hooks/utils'; import {usePreventDoubleTap} from '@hooks/utils';
import {getMarkdownTextStyles, getMarkdownBlockStyles} from '@utils/markdown'; import {getMarkdownTextStyles, getMarkdownBlockStyles} from '@utils/markdown';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography'; import {typography} from '@utils/typography';
import {displayUsername, getUserTimezone} from '@utils/user'; 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 UserModel from '@typings/database/models/servers/user';
import type {AvailableScreens} from '@typings/screens/navigation'; import type {AvailableScreens} from '@typings/screens/navigation';
const MAX_PERMALINK_PREVIEW_LINES = 4; const MAX_PERMALINK_PREVIEW_CHARACTERS = 150;
const EDITED_INDICATOR_CONTEXT = ['paragraph']; const EDITED_INDICATOR_CONTEXT = ['paragraph'];
type PermalinkPreviewProps = { type PermalinkPreviewProps = {
@ -31,8 +36,11 @@ type PermalinkPreviewProps = {
currentUser?: UserModel; currentUser?: UserModel;
isMilitaryTime: boolean; isMilitaryTime: boolean;
teammateNameDisplay: string; teammateNameDisplay: string;
post?: PostModel;
isOriginPostDeleted?: boolean; isOriginPostDeleted?: boolean;
location: AvailableScreens; location: AvailableScreens;
parentLocation?: string;
parentPostId?: string;
}; };
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
@ -51,7 +59,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
}, },
shadowOpacity: 0.08, shadowOpacity: 0.08,
shadowRadius: 3, shadowRadius: 3,
elevation: 2, elevation: 1,
overflow: 'hidden',
}, },
header: { header: {
flexDirection: 'row', flexDirection: 'row',
@ -75,8 +84,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
}, },
messageContainer: { messageContainer: {
marginBottom: 8, marginBottom: 8,
maxHeight: 20 * MAX_PERMALINK_PREVIEW_LINES,
overflow: 'hidden',
}, },
messageText: { messageText: {
color: theme.centerChannelColor, color: theme.centerChannelColor,
@ -85,6 +92,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
}, },
channelContext: { channelContext: {
marginTop: 8,
color: changeOpacity(theme.centerChannelColor, 0.64), color: changeOpacity(theme.centerChannelColor, 0.64),
...typography('Body', 75), ...typography('Body', 75),
}, },
@ -92,6 +100,17 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
color: theme.linkColor, color: theme.linkColor,
...typography('Body', 75, 'SemiBold'), ...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, currentUser,
isMilitaryTime, isMilitaryTime,
teammateNameDisplay, teammateNameDisplay,
post,
isOriginPostDeleted, isOriginPostDeleted,
location, location,
parentLocation,
parentPostId,
}: PermalinkPreviewProps) => { }: PermalinkPreviewProps) => {
const theme = useTheme(); const theme = useTheme();
const serverUrl = useServerUrl(); const serverUrl = useServerUrl();
const locale = useUserLocale(); const locale = useUserLocale();
const dimensions = useWindowDimensions();
const styles = getStyleSheet(theme); const styles = getStyleSheet(theme);
const [showGradient, setShowGradient] = useState(false);
const maxPermalinkHeight = Math.round(dimensions.height * 0.5);
const textStyles = getMarkdownTextStyles(theme); const textStyles = getMarkdownTextStyles(theme);
const blockStyles = getMarkdownBlockStyles(theme); const blockStyles = getMarkdownBlockStyles(theme);
@ -125,28 +150,27 @@ const PermalinkPreview = ({
} }
const { const {
post, post: embedPost,
channel_display_name, channel_display_name,
channel_type, channel_type,
} = embedData; } = embedData;
const truncatedMessage = useMemo(() => { const truncatedMessage = useMemo(() => {
const message = post?.message; const msg = embedPost?.message;
if (!msg || typeof msg !== 'string') {
if (!message || typeof message !== 'string') {
return ''; return '';
} }
const cleanMessage = message.trim(); const cleanMessage = msg.trim();
const lines = cleanMessage.split('\n');
if (lines.length > MAX_PERMALINK_PREVIEW_LINES) { if (cleanMessage.length > MAX_PERMALINK_PREVIEW_CHARACTERS) {
return lines.slice(0, MAX_PERMALINK_PREVIEW_LINES).join('\n...'); return cleanMessage.substring(0, MAX_PERMALINK_PREVIEW_CHARACTERS) + '...';
} }
return cleanMessage; 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(() => { const authorDisplayName = useMemo(() => {
return displayUsername(author, locale, teammateNameDisplay); return displayUsername(author, locale, teammateNameDisplay);
@ -157,10 +181,21 @@ const PermalinkPreview = ({
return `~${displayName}`; return `~${displayName}`;
}, [channel_display_name, channel_type, authorDisplayName]); }, [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(() => { const handlePress = usePreventDoubleTap(useCallback(() => {
// Navigation will be implemented in Task 5 // Navigation will be implemented in Task 5
}, [])); }, []));
const handleContentLayout = useCallback((event: LayoutChangeEvent) => {
const {height} = event.nativeEvent.layout;
setShowGradient(height >= maxPermalinkHeight);
}, [maxPermalinkHeight]);
if (!post) { if (!post) {
return null; return null;
} }
@ -174,48 +209,70 @@ const PermalinkPreview = ({
onPress={handlePress} onPress={handlePress}
testID='permalink-preview-container' testID='permalink-preview-container'
> >
<View style={styles.header}> <View style={[styles.contentContainer, {maxHeight: maxPermalinkHeight}]}>
<ProfilePicture <View onLayout={handleContentLayout}>
author={author || {id: post.user_id} as UserModel} <View style={styles.header}>
size={32} <ProfilePicture
showStatus={false} author={author}
/> size={32}
<View style={styles.authorInfo}> showStatus={false}
<Text />
style={styles.authorName} <View style={styles.authorInfo}>
numberOfLines={1} <Text
> style={styles.authorName}
{authorDisplayName} numberOfLines={1}
</Text> >
<FormattedTime {authorDisplayName}
timezone={getUserTimezone(currentUser)} </Text>
isMilitaryTime={isMilitaryTime} <FormattedTime
value={post.create_at} timezone={getUserTimezone(currentUser)}
style={styles.timestamp} isMilitaryTime={isMilitaryTime}
/> value={embedPost.create_at}
</View> style={styles.timestamp}
</View> />
</View>
</View>
<View style={styles.messageContainer}> <View style={styles.messageContainer}>
<Markdown <Markdown
baseTextStyle={styles.messageText} baseTextStyle={styles.messageText}
blockStyles={blockStyles} blockStyles={blockStyles}
channelId={embedData.channel_id} channelId={embedData.channel_id}
disableAtMentions={true} disableAtMentions={true}
location={location} location={location}
theme={theme} theme={theme}
textStyles={textStyles} textStyles={textStyles}
value={truncatedMessage} value={truncatedMessage}
/> />
{isEdited ? ( {isEdited ? (
<EditedIndicator <EditedIndicator
baseTextStyle={styles.messageText} baseTextStyle={styles.messageText}
theme={theme} theme={theme}
context={EDITED_INDICATOR_CONTEXT} context={EDITED_INDICATOR_CONTEXT}
iconSize={12} iconSize={12}
testID='permalink_preview.edited_indicator_separate' 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> </View>
<Text style={styles.channelContext}> <Text style={styles.channelContext}>

View file

@ -187,6 +187,115 @@ describe('Permalink Sync Utils', () => {
expect(result).toBeNull(); 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 () => { it('should handle errors during post update gracefully', async () => {
const referencedPostId = 'referenced_post_456'; const referencedPostId = 'referenced_post_456';

View file

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