From b4291a15051b9c9d46fa2d019db0684eed7b4e9c Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Tue, 12 Dec 2023 12:34:35 +0100 Subject: [PATCH] Story: Display forward info (#4070) --- src/api/gramjs/apiBuilders/stories.ts | 14 +- src/api/types/messages.ts | 6 + src/api/types/stories.ts | 3 +- .../common/embedded/EmbeddedMessage.scss | 5 +- .../common/embedded/EmbeddedMessage.tsx | 2 +- .../common/embedded/EmbeddedStoryForward.tsx | 148 ++++++++++++++++++ src/components/story/Story.tsx | 32 +++- src/components/story/StoryCaption.tsx | 33 +++- src/components/story/StoryViewer.module.scss | 41 ++++- .../story/hooks/useStoryPreloader.ts | 6 +- src/components/story/hooks/useStoryProps.ts | 2 + 11 files changed, 275 insertions(+), 17 deletions(-) create mode 100644 src/components/common/embedded/EmbeddedStoryForward.tsx diff --git a/src/api/gramjs/apiBuilders/stories.ts b/src/api/gramjs/apiBuilders/stories.ts index c63b1f443..9c1aff3a2 100644 --- a/src/api/gramjs/apiBuilders/stories.ts +++ b/src/api/gramjs/apiBuilders/stories.ts @@ -4,6 +4,7 @@ import type { ApiMediaArea, ApiMediaAreaCoordinates, ApiStealthMode, + ApiStoryForwardInfo, ApiStoryView, ApiTypeStory, MediaContent, @@ -42,7 +43,7 @@ export function buildApiStory(peerId: string, story: GramJs.TypeStoryItem): ApiT edited, pinned, expireDate, id, date, caption, entities, media, privacy, views, public: isPublic, noforwards, closeFriends, contacts, selectedContacts, - mediaAreas, sentReaction, out, + mediaAreas, sentReaction, out, fwdFrom, } = story; const content: MediaContent = { @@ -76,6 +77,7 @@ export function buildApiStory(peerId: string, story: GramJs.TypeStoryItem): ApiT ...(privacy && { visibility: buildPrivacyRules(privacy) }), ...(mediaAreas && { mediaAreas: mediaAreas.map(buildApiMediaArea).filter(Boolean) }), ...(sentReaction && { sentReaction: buildApiReaction(sentReaction) }), + ...(fwdFrom && { forwardInfo: buildApiStoryForwardInfo(fwdFrom) }), }; } @@ -166,3 +168,13 @@ export function buildApiPeerStories(peerStories: GramJs.PeerStories) { return buildCollectionByCallback(peerStories.stories, (story) => [story.id, buildApiStory(peerId, story)]); } + +export function buildApiStoryForwardInfo(forwardHeader: GramJs.TypeStoryFwdHeader): ApiStoryForwardInfo { + const { from, fromName, storyId } = forwardHeader; + + return { + storyId, + fromPeerId: from && getApiChatIdFromMtpPeer(from), + fromName, + }; +} diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index 10d236baf..8dacd10f0 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -366,6 +366,12 @@ export interface ApiMessageForwardInfo { postAuthorTitle?: string; } +export interface ApiStoryForwardInfo { + fromPeerId?: string; + fromName?: string; + storyId?: number; +} + export type ApiMessageEntityDefault = { type: Exclude< `${ApiMessageEntityTypes}`, diff --git a/src/api/types/stories.ts b/src/api/types/stories.ts index 0355b62f2..85f0824cb 100644 --- a/src/api/types/stories.ts +++ b/src/api/types/stories.ts @@ -1,6 +1,6 @@ import type { ApiPrivacySettings } from '../../types'; import type { - ApiGeoPoint, ApiReaction, ApiReactionCount, MediaContent, + ApiGeoPoint, ApiReaction, ApiReactionCount, ApiStoryForwardInfo, MediaContent, } from './messages'; export interface ApiStory { @@ -25,6 +25,7 @@ export interface ApiStory { visibility?: ApiPrivacySettings; sentReaction?: ApiReaction; mediaAreas?: ApiMediaArea[]; + forwardInfo?: ApiStoryForwardInfo; } export interface ApiStorySkipped { diff --git a/src/components/common/embedded/EmbeddedMessage.scss b/src/components/common/embedded/EmbeddedMessage.scss index b83ff3385..5aa66967f 100644 --- a/src/components/common/embedded/EmbeddedMessage.scss +++ b/src/components/common/embedded/EmbeddedMessage.scss @@ -123,7 +123,6 @@ text-overflow: ellipsis; height: 1.125rem; margin-bottom: 0; - flex: 1; &::after { content: none; @@ -131,6 +130,10 @@ } } + .multiline { + flex: 1; + } + .emoji { width: calc(1.125 * var(--message-text-size, 1rem)) !important; height: calc(1.125 * var(--message-text-size, 1rem)) !important; diff --git a/src/components/common/embedded/EmbeddedMessage.tsx b/src/components/common/embedded/EmbeddedMessage.tsx index 7ab830752..803223b56 100644 --- a/src/components/common/embedded/EmbeddedMessage.tsx +++ b/src/components/common/embedded/EmbeddedMessage.tsx @@ -209,7 +209,7 @@ const EmbeddedMessage: FC = ({ /> )}
-

+

{renderTextContent()}

diff --git a/src/components/common/embedded/EmbeddedStoryForward.tsx b/src/components/common/embedded/EmbeddedStoryForward.tsx new file mode 100644 index 000000000..082faa5e9 --- /dev/null +++ b/src/components/common/embedded/EmbeddedStoryForward.tsx @@ -0,0 +1,148 @@ +import type { FC } from '../../../lib/teact/teact'; +import React, { memo, useEffect, useRef } from '../../../lib/teact/teact'; +import { getActions, withGlobal } from '../../../global'; + +import type { + ApiPeer, + ApiStoryForwardInfo, + ApiTypeStory, +} from '../../../api/types'; +import type { IconName } from '../../../types/icons'; + +import { + getSenderTitle, + isUserId, +} from '../../../global/helpers'; +import { selectPeer, selectPeerStory } from '../../../global/selectors'; +import buildClassName from '../../../util/buildClassName'; +import { getPeerColorClass } from '../helpers/peerColor'; +import renderText from '../helpers/renderText'; +import { renderTextWithEntities } from '../helpers/renderTextWithEntities'; + +import { useFastClick } from '../../../hooks/useFastClick'; +import useLang from '../../../hooks/useLang'; +import useLastCallback from '../../../hooks/useLastCallback'; + +import Icon from '../Icon'; +import EmojiIconBackground from './EmojiIconBackground'; + +import './EmbeddedMessage.scss'; + +type OwnProps = { + forwardInfo: ApiStoryForwardInfo; + className?: string; +}; + +type StateProps = { + sender?: ApiPeer; + story?: ApiTypeStory; +}; + +const EmbeddedStoryForward: FC = ({ + className, + forwardInfo, + sender, + story, +}) => { + const { openStoryViewer, loadPeerStoriesByIds, openChat } = getActions(); + // eslint-disable-next-line no-null/no-null + const ref = useRef(null); + + const lang = useLang(); + + useEffect(() => { + if (!story && forwardInfo.fromPeerId && forwardInfo.storyId) { + loadPeerStoriesByIds({ + peerId: forwardInfo.fromPeerId, + storyIds: [forwardInfo.storyId], + }); + } + }, [forwardInfo, story]); + + const senderTitle = sender ? getSenderTitle(lang, sender) : forwardInfo.fromName; + + const openOriginalStory = useLastCallback(() => { + const { fromPeerId, storyId } = forwardInfo; + if (!fromPeerId) return; + + const isStoryReady = story && !('isDeleted' in story && story.isDeleted); + + if (isStoryReady) { + openStoryViewer({ + peerId: fromPeerId, + storyId, + isSingleStory: true, + }); + } else { + openChat({ id: fromPeerId }); + } + }); + + const { handleClick, handleMouseDown } = useFastClick(openOriginalStory); + + function renderTextContent() { + if (story && 'content' in story && story.content.text) { + return ( +

+ {renderTextWithEntities(story.content.text)} +

+ ); + } + + return undefined; + } + + function renderSender() { + if (!sender || !senderTitle) { + return undefined; + } + + const icon: IconName | undefined = !isUserId(sender.id) ? 'channel-filled' : 'user-filled'; + + return ( + <> + {icon && } + {senderTitle && renderText(senderTitle)} + + ); + } + + return ( +
+ {sender?.color?.backgroundEmojiId && ( + + )} +
+ {renderTextContent()} +
+ {renderSender()} +
+
+
+ ); +}; + +export default memo(withGlobal( + (global, { forwardInfo }): StateProps => { + const sender = forwardInfo.fromPeerId ? selectPeer(global, forwardInfo.fromPeerId) : undefined; + const story = forwardInfo.storyId && forwardInfo.fromPeerId + ? selectPeerStory(global, forwardInfo.fromPeerId, forwardInfo.storyId) : undefined; + return { + sender, + story, + }; + }, +)(EmbeddedStoryForward)); diff --git a/src/components/story/Story.tsx b/src/components/story/Story.tsx index 57513d961..87afafd51 100644 --- a/src/components/story/Story.tsx +++ b/src/components/story/Story.tsx @@ -15,6 +15,7 @@ import { EDITABLE_STORY_INPUT_CSS_SELECTOR, EDITABLE_STORY_INPUT_ID } from '../. import { getSenderTitle, isUserId } from '../../global/helpers'; import { selectChat, selectIsCurrentUserPremium, + selectPeer, selectPeerStories, selectPeerStory, selectTabState, selectUser, } from '../../global/selectors'; @@ -43,6 +44,7 @@ import useStoryProps from './hooks/useStoryProps'; import Avatar from '../common/Avatar'; import Composer from '../common/Composer'; +import Icon from '../common/Icon'; import Button from '../ui/Button'; import DropdownMenu from '../ui/DropdownMenu'; import MenuItem from '../ui/MenuItem'; @@ -74,6 +76,7 @@ interface OwnProps { interface StateProps { peer: ApiPeer; + forwardSender?: ApiPeer; story?: ApiTypeStory; isMuted: boolean; orderedIds?: number[]; @@ -98,6 +101,7 @@ function Story({ peerId, storyId, peer, + forwardSender, isMuted, isArchivedStories, isPrivateStories, @@ -152,6 +156,7 @@ function Story({ const { isDeletedStory, hasText, + hasForwardInfo, thumbnail, previewBlobUrl, isVideo, @@ -182,6 +187,10 @@ function Story({ const areViewsExpired = Boolean( isOut && (story!.date + viewersExpirePeriod) < getServerTime(), ); + + const forwardSenderTitle = forwardSender ? getSenderTitle(lang, forwardSender) + : (isLoadedStory && story.forwardInfo?.fromName); + const canCopyLink = Boolean( isLoadedStory && story.isPublic @@ -381,6 +390,11 @@ function Story({ openChat({ id: peerId }); }); + const handleForwardPeerClick = useLastCallback(() => { + onClose(); + openChat({ id: forwardSender!.id }); + }); + const handleOpenPrevStory = useLastCallback(() => { setCurrentTime(0); openPreviousStory(); @@ -591,6 +605,19 @@ function Story({ {renderText(getSenderTitle(lang, peer) || '')}
+ {forwardSenderTitle && ( + + + + {renderText(forwardSenderTitle)} + + + )} {story && 'date' in story && ( {formatRelativeTime(lang, serverTime, story.date)} )} @@ -753,7 +780,7 @@ function Story({ /> )} {hasText &&
} - {hasText && ( + {(hasText || hasForwardInfo) && ( ((global, { viewModal || forwardedStoryId || tabState.reactionPicker?.storyId || isReportModalOpen || isPrivacyModalOpen || isPremiumModalOpen || isDeleteModalOpen || safeLinkModalUrl || isStealthModalOpen || mapModal, ); + const forwardSenderId = story && 'forwardInfo' in story ? story.forwardInfo?.fromPeerId : undefined; + const forwardSender = forwardSenderId ? selectPeer(global, forwardSenderId) : undefined; return { peer: (user || chat)!, + forwardSender, story, orderedIds: isArchivedStories ? archiveIds : (isPrivateStories ? pinnedIds : orderedIds), isMuted, diff --git a/src/components/story/StoryCaption.tsx b/src/components/story/StoryCaption.tsx index 09e30c590..b0d2f51b2 100644 --- a/src/components/story/StoryCaption.tsx +++ b/src/components/story/StoryCaption.tsx @@ -13,6 +13,7 @@ import useLang from '../../hooks/useLang'; import usePrevDuringAnimation from '../../hooks/usePrevDuringAnimation'; import useShowTransition from '../../hooks/useShowTransition'; +import EmbeddedStoryForward from '../common/embedded/EmbeddedStoryForward'; import MessageText from '../common/MessageText'; import styles from './StoryViewer.module.scss'; @@ -27,6 +28,7 @@ interface OwnProps { const EXPAND_ANIMATION_DURATION_MS = 400; const OVERFLOW_THRESHOLD_PX = 5.75 * REM; +const LINES_TO_SHOW = 3; function StoryCaption({ story, isExpanded, className, onExpand, onFold, @@ -37,6 +39,8 @@ function StoryCaption({ // eslint-disable-next-line no-null/no-null const contentRef = useRef(null); // eslint-disable-next-line no-null/no-null + const textRef = useRef(null); + // eslint-disable-next-line no-null/no-null const showMoreButtonRef = useRef(null); const caption = story.content.text; @@ -74,15 +78,22 @@ function StoryCaption({ ); useEffect(() => { - if (!showMoreButtonRef.current || !contentRef.current) { + if (!showMoreButtonRef.current || !contentRef.current || !textRef.current) { return; } - const button = showMoreButtonRef.current; const container = contentRef.current; + const textContainer = textRef.current; + + const textOffsetTop = textContainer.offsetTop; + const lineHeight = parseInt(getComputedStyle(textContainer).lineHeight, 10); + const overflowShift = textOffsetTop + lineHeight * LINES_TO_SHOW; + + const button = showMoreButtonRef.current; const { offsetWidth } = button; requestMutation(() => { + container.style.setProperty('--_overflow-shift', `${overflowShift}px`); container.style.setProperty('--expand-button-width', `${offsetWidth}px`); }); }, [canExpand]); @@ -112,11 +123,19 @@ function StoryCaption({ ref={ref} className={buildClassName(styles.captionInner, 'allow-selection', 'custom-scroll')} > - + {story.forwardInfo && ( + + )} +
+ +
{shouldRenderShowMore && ( diff --git a/src/components/story/StoryViewer.module.scss b/src/components/story/StoryViewer.module.scss index c0ea32c7e..9caadeb75 100644 --- a/src/components/story/StoryViewer.module.scss +++ b/src/components/story/StoryViewer.module.scss @@ -516,7 +516,7 @@ pointer-events: none; @supports (bottom: env(safe-area-inset-bottom)) { - bottom: calc(5rem + env(safe-area-inset-bottom)); + bottom: calc(4.25rem + env(safe-area-inset-bottom)); } } @@ -545,8 +545,13 @@ pointer-events: all; } +.captionText :global(.custom-emoji) { + vertical-align: middle; +} + .hasOverflow { - transform: translateY(calc(100% - 5.75rem)); + --_overflow-shift: 5.75rem; + transform: translateY(calc(100% - var(--_overflow-shift))); } .expanded { @@ -570,7 +575,7 @@ overflow-y: hidden; mask-image: linear-gradient(to top, black 0%, black 0%), linear-gradient(to left, black 75%, transparent 100%); - mask-position: 100% 100%, 100% 4.5rem; + mask-position: 100% 100%, 100% calc(var(--_overflow-shift) - 1.25rem); mask-size: 100% 100%, calc(var(--expand-button-width, 0%) + 4rem) 1.25em; mask-repeat: no-repeat; -webkit-mask-composite: xor; @@ -720,3 +725,33 @@ -webkit-user-select: none; object-fit: cover; } + +.forwardHeader { + display: flex; + align-items: center; + gap: 0.125rem; + + &.clickable { + cursor: var(--custom-cursor, pointer); + } +} + +.forwardHeaderText { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + .forwardHeader.clickable:hover & { + text-decoration: underline; + } +} + +.forwardInfo { + --accent-color: var(--color-white); + --accent-background-color: rgba(0, 0, 0, 0.5); + + width: fit-content; + max-width: 100%; + margin-bottom: 0.5rem; + z-index: 1; +} diff --git a/src/components/story/hooks/useStoryPreloader.ts b/src/components/story/hooks/useStoryPreloader.ts index d7dcd0abd..bd449fa71 100644 --- a/src/components/story/hooks/useStoryPreloader.ts +++ b/src/components/story/hooks/useStoryPreloader.ts @@ -16,9 +16,11 @@ const FIRST_PRELOAD_DELAY = 1000; const canPreload = pause(FIRST_PRELOAD_DELAY); function useStoryPreloader(peerIds: string[]): void; -function useStoryPreloader(peerId: string, aroundStoryId?: number): void; -function useStoryPreloader(peerId: string | string[], aroundStoryId?: number) { +function useStoryPreloader(peerId?: string, aroundStoryId?: number): void; +function useStoryPreloader(peerId?: string | string[], aroundStoryId?: number) { useEffect(() => { + if (peerId === undefined) return; + const preloadHashes = async (mediaHashes: { hash: string; format: ApiMediaFormat }[]) => { await canPreload; mediaHashes.forEach(({ hash, format }) => { diff --git a/src/components/story/hooks/useStoryProps.ts b/src/components/story/hooks/useStoryProps.ts index b3f93b22a..d1632d4d3 100644 --- a/src/components/story/hooks/useStoryProps.ts +++ b/src/components/story/hooks/useStoryProps.ts @@ -11,6 +11,7 @@ export default function useStoryProps( const isLoadedStory = story && 'content' in story; const isDeletedStory = story && 'isDeleted' in story; const hasText = isLoadedStory ? hasMessageText(story) : false; + const hasForwardInfo = isLoadedStory && Boolean(story.forwardInfo); let thumbnail: string | undefined; if (isLoadedStory) { @@ -43,6 +44,7 @@ export default function useStoryProps( isLoadedStory, isDeletedStory, hasText, + hasForwardInfo, thumbnail, previewHash, previewBlobUrl,