From d732b5a33e6c88c6381601eb43f61618d3f393e2 Mon Sep 17 00:00:00 2001 From: Rajat Dabade Date: Fri, 12 Sep 2025 13:44:54 +0530 Subject: [PATCH] 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 --- app/components/files/file.tsx | 2 - app/components/files/files.tsx | 34 ++- .../post_list/post/body/content/content.tsx | 2 + .../body/content/permalink_preview/index.tsx | 6 +- .../permalink_preview/permalink_files.tsx | 74 ++++++ .../permalink_preview.test.tsx | 233 ++++++++++++++---- .../permalink_preview/permalink_preview.tsx | 169 ++++++++----- app/utils/permalink_sync.test.ts | 109 ++++++++ app/utils/permalink_sync.ts | 2 + 9 files changed, 511 insertions(+), 120 deletions(-) create mode 100644 app/components/post_list/post/body/content/permalink_preview/permalink_files.tsx diff --git a/app/components/files/file.tsx b/app/components/files/file.tsx index f67373949..5c883fdcd 100644 --- a/app/components/files/file.tsx +++ b/app/components/files/file.tsx @@ -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', }, diff --git a/app/components/files/files.tsx b/app/components/files/files.tsx index e9467c797..508c1ee54 100644 --- a/app/components/files/files.tsx +++ b/app/components/files/files.tsx @@ -26,16 +26,17 @@ type FilesProps = { isReplyPost: boolean; postId?: string; postProps?: Record; + 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 = 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 = 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 ( @@ -138,10 +149,14 @@ const Files = ({ return ( - { renderItems(visibleImages, nonVisibleImagesCount, true) } + { renderItems(visibleImages, nonVisibleImagesCount, true, true) } ); }; @@ -164,7 +179,10 @@ const Files = ({ {renderImageRow()} {renderItems(nonImageAttachments)} diff --git a/app/components/post_list/post/body/content/content.tsx b/app/components/post_list/post/body/content/content.tsx index 9ace7ad8e..e7cd1597f 100644 --- a/app/components/post_list/post/body/content/content.tsx +++ b/app/components/post_list/post/body/content/content.tsx @@ -116,6 +116,8 @@ const Content = ({isReplyPost, layoutWidth, location, post, theme, showPermalink ); } 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 index e3eff7a66..e67c492f3 100644 --- a/app/components/post_list/post/body/content/permalink_preview/index.tsx +++ b/app/components/post_list/post/body/content/permalink_preview/index.tsx @@ -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, }; }); diff --git a/app/components/post_list/post/body/content/permalink_preview/permalink_files.tsx b/app/components/post_list/post/body/content/permalink_preview/permalink_files.tsx new file mode 100644 index 000000000..acb09982b --- /dev/null +++ b/app/components/post_list/post/body/content/permalink_preview/permalink_files.tsx @@ -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) => { + 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 ( + + + + ); +}; + +export default PermalinkFiles; 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 index 083143b3d..8907aef60 100644 --- 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 @@ -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[0]) => { + return renderWithEverything(, {database, serverUrl}); + }; + const baseProps: Parameters[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( - , - ); + 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( - , - ); + 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( - , - ); + const {queryByText} = renderPermalinkPreview(props); expect(queryByText('testuser')).toBeNull(); }); it('should handle press event without crashing', () => { - const {getByTestId} = renderWithIntlAndTheme( - , - ); + 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( - , - ); + 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( - , - ); + const {getByText} = renderPermalinkPreview(props); expect(getByText('Someone')).toBeTruthy(); }); it('should display channel name with ~ prefix for public channels', () => { - const {getByText} = renderWithIntlAndTheme( - , - ); + 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( - , - ); + 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( - , - ); + 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( - , - ); + 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( - , - ); + 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( - , - ); + const {queryByTestId} = renderPermalinkPreview(baseProps); expect(queryByTestId('permalink_preview.edited_indicator_separate')).toBeNull(); }); it('should display formatted time correctly', () => { - const {getByText} = renderWithIntlAndTheme( - , - ); - - // 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( - , - ); + 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(); + }); }); }); 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 index 67400e642..27f58d561 100644 --- 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 @@ -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' > - - - - - {authorDisplayName} - - - - + + + + + + + {authorDisplayName} + + + + - - - {isEdited ? ( - + + {isEdited ? ( + + ) : null} + + + {hasFiles && post && ( + + )} + + + {showGradient && ( + - ) : null} + )} diff --git a/app/utils/permalink_sync.test.ts b/app/utils/permalink_sync.test.ts index 93747195d..db98eaf20 100644 --- a/app/utils/permalink_sync.test.ts +++ b/app/utils/permalink_sync.test.ts @@ -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'; diff --git a/app/utils/permalink_sync.ts b/app/utils/permalink_sync.ts index 6975483e4..29ac7bcaa 100644 --- a/app/utils/permalink_sync.ts +++ b/app/utils/permalink_sync.ts @@ -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, }, }, };