Story: Display forward info (#4070)
This commit is contained in:
parent
9ef631665f
commit
b4291a1505
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -366,6 +366,12 @@ export interface ApiMessageForwardInfo {
|
||||
postAuthorTitle?: string;
|
||||
}
|
||||
|
||||
export interface ApiStoryForwardInfo {
|
||||
fromPeerId?: string;
|
||||
fromName?: string;
|
||||
storyId?: number;
|
||||
}
|
||||
|
||||
export type ApiMessageEntityDefault = {
|
||||
type: Exclude<
|
||||
`${ApiMessageEntityTypes}`,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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">
|
||||
|
||||
148
src/components/common/embedded/EmbeddedStoryForward.tsx
Normal file
148
src/components/common/embedded/EmbeddedStoryForward.tsx
Normal 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));
|
||||
@ -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,
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 }) => {
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user