Story: Display forward info (#4070)

This commit is contained in:
Alexander Zinchuk 2023-12-12 12:34:35 +01:00
parent 9ef631665f
commit b4291a1505
11 changed files with 275 additions and 17 deletions

View File

@ -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,
};
}

View File

@ -366,6 +366,12 @@ export interface ApiMessageForwardInfo {
postAuthorTitle?: string;
}
export interface ApiStoryForwardInfo {
fromPeerId?: string;
fromName?: string;
storyId?: number;
}
export type ApiMessageEntityDefault = {
type: Exclude<
`${ApiMessageEntityTypes}`,

View File

@ -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 {

View File

@ -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;

View File

@ -209,7 +209,7 @@ const EmbeddedMessage: FC<OwnProps> = ({
/>
)}
<div className="message-text">
<p className="embedded-text-wrapper">
<p className={buildClassName('embedded-text-wrapper', isQuote && 'multiline')}>
{renderTextContent()}
</p>
<div className="message-title">

View File

@ -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<OwnProps & StateProps> = ({
className,
forwardInfo,
sender,
story,
}) => {
const { openStoryViewer, loadPeerStoriesByIds, openChat } = getActions();
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(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 (
<p className="embedded-text-wrapper">
{renderTextWithEntities(story.content.text)}
</p>
);
}
return undefined;
}
function renderSender() {
if (!sender || !senderTitle) {
return undefined;
}
const icon: IconName | undefined = !isUserId(sender.id) ? 'channel-filled' : 'user-filled';
return (
<>
{icon && <Icon name={icon} className="embedded-chat-icon" />}
{senderTitle && renderText(senderTitle)}
</>
);
}
return (
<div
ref={ref}
className={buildClassName(
'EmbeddedMessage',
className,
getPeerColorClass(sender, true, true),
)}
dir={lang.isRtl ? 'rtl' : undefined}
onClick={handleClick}
onMouseDown={handleMouseDown}
>
{sender?.color?.backgroundEmojiId && (
<EmojiIconBackground
emojiDocumentId={sender.color.backgroundEmojiId}
className="EmbeddedMessage--background-icons"
/>
)}
<div className="message-text">
{renderTextContent()}
<div className="message-title">
{renderSender()}
</div>
</div>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(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));

View File

@ -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) || '')}
</span>
<div className={styles.storyMetaRow}>
{forwardSenderTitle && (
<span
className={buildClassName(
styles.storyMeta, styles.forwardHeader, forwardSender && styles.clickable,
)}
onClick={forwardSender ? handleForwardPeerClick : undefined}
>
<Icon name="loop" />
<span className={styles.forwardHeaderText}>
{renderText(forwardSenderTitle)}
</span>
</span>
)}
{story && 'date' in story && (
<span className={styles.storyMeta}>{formatRelativeTime(lang, serverTime, story.date)}</span>
)}
@ -753,7 +780,7 @@ function Story({
/>
)}
{hasText && <div className={styles.captionGradient} />}
{hasText && (
{(hasText || hasForwardInfo) && (
<StoryCaption
key={`caption-${storyId}-${peerId}`}
story={story as ApiStory}
@ -812,9 +839,12 @@ export default memo(withGlobal<OwnProps>((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,

View File

@ -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<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const textRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const showMoreButtonRef = useRef<HTMLDivElement>(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')}
>
<MessageText
messageOrStory={story}
withTranslucentThumbs
forcePlayback
/>
{story.forwardInfo && (
<EmbeddedStoryForward
forwardInfo={story.forwardInfo}
className={styles.forwardInfo}
/>
)}
<div ref={textRef} className={styles.captionText}>
<MessageText
messageOrStory={story}
withTranslucentThumbs
forcePlayback
/>
</div>
</div>
</div>
{shouldRenderShowMore && (

View File

@ -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;
}

View File

@ -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 }) => {

View File

@ -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,