// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import AgentPost from '@agents/components/agent_post'; import {isAgentPost} from '@agents/utils'; import React, {type ReactNode, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {useIntl} from 'react-intl'; import {Platform, type StyleProp, View, type ViewStyle, TouchableHighlight, type LayoutChangeEvent} from 'react-native'; import {KeyboardController} from 'react-native-keyboard-controller'; import {removePost} from '@actions/local/post'; import {showPermalink} from '@actions/remote/permalink'; import {fetchAndSwitchToThread} from '@actions/remote/thread'; import CallsCustomMessage from '@calls/components/calls_custom_message'; import {isCallsCustomMessage} from '@calls/utils'; import UnrevealedBurnOnReadPost from '@components/post_list/post/burn_on_read/unrevealed'; import SystemAvatar from '@components/system_avatar'; import SystemHeader from '@components/system_header'; import {POST_TIME_TO_FAIL} from '@constants/post'; import * as Screens from '@constants/screens'; import {useKeyboardAnimationContext} from '@context/keyboard_animation'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import {useIsTablet} from '@hooks/device'; import useDidMount from '@hooks/did_mount'; import PerformanceMetricsManager from '@managers/performance_metrics_manager'; import {openAsBottomSheet} from '@screens/navigation'; import {isBoRPost, isUnrevealedBoRPost} from '@utils/bor'; import {hasJumboEmojiOnly} from '@utils/emoji/helpers'; import {fromAutoResponder, isFromWebhook, isPostFailed, isPostPendingOrFailed, isSystemMessage} from '@utils/post'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import Avatar from './avatar'; import Body from './body'; import Footer from './footer'; import Header from './header'; import PreHeader from './pre_header'; import ShimmerAnimation from './shimmer_animation'; import SystemMessage from './system_message'; import UnreadDot from './unread_dot'; import useShimmerAnimation from './use_shimmer_animation'; import type PostModel from '@typings/database/models/servers/post'; import type ThreadModel from '@typings/database/models/servers/thread'; import type UserModel from '@typings/database/models/servers/user'; import type {SearchPattern} from '@typings/global/markdown'; import type {AvailableScreens} from '@typings/screens/navigation'; type PostProps = { appsEnabled: boolean; canDelete: boolean; currentUser?: UserModel; customEmojiNames: string[]; differentThreadSequence: boolean; hasFiles: boolean; hasReplies: boolean; highlight?: boolean; highlightPinnedOrSaved?: boolean; highlightReplyBar: boolean; isConsecutivePost?: boolean; isCRTEnabled?: boolean; isEphemeral: boolean; isFirstReply?: boolean; isPostAcknowledgementEnabled?: boolean; isSaved?: boolean; isLastReply?: boolean; isPostAddChannelMember: boolean; isPostPriorityEnabled: boolean; location: AvailableScreens; post: PostModel; rootId?: string; previousPost?: PostModel; isLastPost: boolean; hasReactions: boolean; searchPatterns?: SearchPattern[]; shouldRenderReplyButton?: boolean; showAddReaction?: boolean; skipSavedHeader?: boolean; skipPinnedHeader?: boolean; style?: StyleProp; testID?: string; thread?: ThreadModel; isChannelAutotranslated: boolean; }; const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { return { consecutive: {marginTop: 0}, consecutivePostContainer: { marginBottom: 10, marginRight: 10, marginLeft: Platform.select({ios: 34, android: 33}), marginTop: 10, }, container: {flexDirection: 'row'}, highlight: {backgroundColor: changeOpacity(theme.mentionHighlightBg, 0.5)}, highlightBar: { backgroundColor: theme.mentionHighlightBg, opacity: 1, }, highlightPinnedOrSaved: { backgroundColor: changeOpacity(theme.mentionHighlightBg, 0.2), }, pendingPost: {opacity: 0.5}, postContent: {paddingHorizontal: 16}, postStyle: { overflow: 'hidden', flex: 1, }, profilePictureContainer: { marginBottom: 5, marginRight: 10, marginTop: 10, }, rightColumn: { flex: 1, flexDirection: 'column', }, rightColumnPadding: {paddingBottom: 3}, }; }); const Post = ({ appsEnabled, canDelete, currentUser, customEmojiNames, differentThreadSequence, hasFiles, hasReplies, highlight, highlightPinnedOrSaved = true, highlightReplyBar, isCRTEnabled, isConsecutivePost, isEphemeral, isFirstReply, isSaved, isLastReply, isPostAcknowledgementEnabled, isPostAddChannelMember, isPostPriorityEnabled, location, post, rootId, hasReactions, searchPatterns, shouldRenderReplyButton, skipSavedHeader, skipPinnedHeader, showAddReaction = true, style, testID, thread, previousPost, isLastPost, isChannelAutotranslated, }: PostProps) => { const pressDetected = useRef(false); const intl = useIntl(); const serverUrl = useServerUrl(); const theme = useTheme(); const isTablet = useIsTablet(); const {blurAndDismissKeyboard, closeInputAccessoryView, showInputAccessoryView} = useKeyboardAnimationContext(); const styles = getStyleSheet(theme); const isAutoResponder = fromAutoResponder(post); const isPendingOrFailed = isPostPendingOrFailed(post); const isFailed = isPostFailed(post); const isSystemPost = isSystemMessage(post); const isCallsPost = isCallsCustomMessage(post); const borPost = isBoRPost(post); const isUnrevealedPost = isUnrevealedBoRPost(post); const isOwnPost = Boolean(currentUser && post.userId === currentUser.id); const isAgentPostType = isAgentPost(post); const hasBeenDeleted = (post.deleteAt !== 0); const isWebHook = isFromWebhook(post); const [layoutWidth, setLayoutWidth] = useState(0); const shimmerAnimationProps = useShimmerAnimation(post, isChannelAutotranslated, intl.locale, layoutWidth, theme); const hasSameRoot = useMemo(() => { if (isFirstReply) { return false; } else if (!post.rootId && !previousPost?.rootId && isConsecutivePost) { return true; } else if (post.rootId) { return true; } return false; }, [isConsecutivePost, post, previousPost, isFirstReply]); const isJumboEmoji = useMemo(() => { if (post.message.length && !(/^\s{4}/).test(post.message)) { return hasJumboEmojiOnly(post.message, customEmojiNames); } return false; }, [customEmojiNames, post.message]); const handlePostPress = useCallback(async () => { if ([Screens.SAVED_MESSAGES, Screens.MENTIONS, Screens.SEARCH, Screens.PINNED_MESSAGES].includes(location)) { showPermalink(serverUrl, '', post.id); return; } const isValidSystemMessage = isAutoResponder || !isSystemPost; if (isEphemeral || hasBeenDeleted) { removePost(serverUrl, post); } else if (isValidSystemMessage && !hasBeenDeleted && !isPendingOrFailed) { // BoR posts cannot have replies, so don't open threads screen for them if (!borPost && [Screens.CHANNEL, Screens.PERMALINK].includes(location)) { await blurAndDismissKeyboard(); const postRootId = post.rootId || post.id; fetchAndSwitchToThread(serverUrl, postRootId); } } setTimeout(() => { pressDetected.current = false; }, 300); }, [location, isAutoResponder, isSystemPost, isEphemeral, hasBeenDeleted, isPendingOrFailed, serverUrl, post, borPost, blurAndDismissKeyboard]); const handlePress = useCallback(() => { if (isBoRPost(post)) { return; } pressDetected.current = true; KeyboardController.dismiss(); if (post) { setTimeout(handlePostPress, 300); } }, [handlePostPress, post]); const showPostOptions = useCallback(async () => { if (!post) { return; } if (isSystemPost && (!canDelete || hasBeenDeleted)) { return; } if (isPendingOrFailed || isEphemeral) { return; } if (showInputAccessoryView) { closeInputAccessoryView(); } await blurAndDismissKeyboard(); const passProps = {sourceScreen: location, post, showAddReaction, serverUrl}; const title = isTablet ? intl.formatMessage({id: 'post.options.title', defaultMessage: 'Options'}) : ''; openAsBottomSheet({ closeButtonId: 'close-post-options', screen: Screens.POST_OPTIONS, theme, title, props: passProps, }); }, [post, isSystemPost, canDelete, hasBeenDeleted, isPendingOrFailed, isEphemeral, blurAndDismissKeyboard, closeInputAccessoryView, showInputAccessoryView, location, showAddReaction, serverUrl, isTablet, intl, theme]); const [, rerender] = useState(false); useEffect(() => { let t: NodeJS.Timeout|undefined; if (post.pendingPostId === post.id && !isFailed) { t = setTimeout(() => rerender(true), POST_TIME_TO_FAIL - (Date.now() - post.updateAt)); } return () => { if (t) { clearTimeout(t); } }; // Timer only needs to reset when post.id changes, not on other prop updates // eslint-disable-next-line react-hooks/exhaustive-deps }, [post.id]); useDidMount(() => { if (!isLastPost) { return; } if (location !== 'Channel' && location !== 'Thread') { return; } PerformanceMetricsManager.finishLoad(location === 'Thread' ? 'THREAD' : 'CHANNEL', serverUrl); PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl); }); const onLayout = useCallback((e: LayoutChangeEvent) => { setLayoutWidth(e.nativeEvent.layout.width); }, []); const highlightSaved = isSaved && !skipSavedHeader; const hightlightPinned = post.isPinned && !skipPinnedHeader; const itemTestID = `${testID}.${post.id}`; const rightColumnStyle: StyleProp = [styles.rightColumn, (Boolean(post.rootId) && isLastReply && styles.rightColumnPadding)]; const pendingPostStyle: StyleProp | undefined = isPendingOrFailed ? styles.pendingPost : undefined; let highlightedStyle: StyleProp; if (highlight) { highlightedStyle = styles.highlight; } else if ((highlightSaved || hightlightPinned) && highlightPinnedOrSaved) { highlightedStyle = styles.highlightPinnedOrSaved; } let header: ReactNode; let postAvatar: ReactNode; let consecutiveStyle: StyleProp; // If the post is a priority post: // 1. Show the priority label in channel screen // 2. Show the priority label in thread screen for the root post const showPostPriority = Boolean(isPostPriorityEnabled && post.metadata?.priority?.priority) && (location !== Screens.THREAD || !post.rootId); const sameSequence = hasReplies ? (hasReplies && post.rootId) : !post.rootId; if (!showPostPriority && hasSameRoot && isConsecutivePost && sameSequence) { consecutiveStyle = styles.consecutive; postAvatar = ; } else { postAvatar = ( {(isAutoResponder || isSystemPost) ? ( ) : ( )} ); if (isSystemPost && !isAutoResponder) { header = ( ); } else { header = (
); } } let body; if (isSystemPost && !isEphemeral && !isAutoResponder) { body = ( ); } else if (isCallsPost && !hasBeenDeleted) { body = ( ); } else if (isUnrevealedPost && !isOwnPost) { body = ( ); } else if (isAgentPostType && !hasBeenDeleted) { body = ( ); } else { body = ( ); } let unreadDot; let footer; if (isCRTEnabled && thread && location !== Screens.THREAD && !(rootId && location === Screens.PERMALINK)) { if (thread.replyCount > 0 || thread.isFollowing) { footer = (