TelegramPWA/src/components/story/MediaStory.tsx
2025-11-11 14:37:40 +01:00

213 lines
7.1 KiB
TypeScript

import {
memo, useCallback, useEffect, useRef,
} from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global';
import type { ApiTypeStory } from '../../api/types';
import { getStoryMediaHash } from '../../global/helpers';
import { selectChat, selectPinnedStories } from '../../global/selectors';
import buildClassName from '../../util/buildClassName';
import { formatMediaDuration } from '../../util/dates/dateFormat';
import stopEvent from '../../util/stopEvent';
import { preventMessageInputBlurWithBubbling } from '../middle/helpers/preventMessageInputBlur';
import useContextMenuHandlers from '../../hooks/useContextMenuHandlers';
import useLang from '../../hooks/useLang';
import useLastCallback from '../../hooks/useLastCallback';
import useMedia from '../../hooks/useMedia';
import useOldLang from '../../hooks/useOldLang';
import Icon from '../common/icons/Icon';
import Menu from '../ui/Menu';
import MenuItem from '../ui/MenuItem';
import MediaAreaOverlay from './mediaArea/MediaAreaOverlay';
import styles from './MediaStory.module.scss';
interface OwnProps {
story: ApiTypeStory;
isArchive?: boolean;
}
interface StateProps {
isProtected?: boolean;
isPinned?: boolean;
canPin?: boolean;
}
function MediaStory({
story, isProtected, isArchive, isPinned, canPin,
}: OwnProps & StateProps) {
const {
openStoryViewer,
loadPeerSkippedStories,
toggleStoryInProfile,
toggleStoryPinnedToTop,
showNotification,
} = getActions();
const lang = useLang();
const oldLang = useOldLang();
const containerRef = useRef<HTMLDivElement>();
const getTriggerElement = useLastCallback(() => containerRef.current);
const getRootElement = useLastCallback(() => document.body);
const getMenuElement = useLastCallback(() => document.querySelector('#portals .story-context-menu .bubble'));
const getLayout = useLastCallback(() => ({ withPortal: true, isDense: true }));
const peerId = story && story.peerId;
const isFullyLoaded = story && 'content' in story;
const isOwn = isFullyLoaded && story.isOut;
const isDeleted = story && 'isDeleted' in story;
const isUnsupportedStory = isFullyLoaded && Object.keys(story.content).length === 0;
const video = isFullyLoaded ? (story).content.video : undefined;
const duration = video && formatMediaDuration(video.duration);
const imageHash = isFullyLoaded ? getStoryMediaHash(story) : undefined;
const imgBlobUrl = useMedia(imageHash);
const thumbUrl = imgBlobUrl || video?.thumbnail?.dataUri;
useEffect(() => {
if (story && !(isFullyLoaded || isDeleted)) {
loadPeerSkippedStories({ peerId: story.peerId });
}
}, [isDeleted, isFullyLoaded, story]);
const {
isContextMenuOpen, contextMenuAnchor,
handleBeforeContextMenu, handleContextMenu,
handleContextMenuClose, handleContextMenuHide,
} = useContextMenuHandlers(containerRef, !isOwn);
const handleClick = useCallback(() => {
openStoryViewer({
peerId: story.peerId,
storyId: story.id,
isSinglePeer: true,
isPrivate: true,
isArchive,
});
}, [isArchive, story.id, story.peerId]);
const handleMouseDown = useLastCallback((e: React.MouseEvent<HTMLElement>) => {
preventMessageInputBlurWithBubbling(e);
handleBeforeContextMenu(e);
});
const handleUnarchiveClick = useLastCallback((e: React.SyntheticEvent) => {
stopEvent(e);
toggleStoryInProfile({ peerId, storyId: story.id, isInProfile: true });
showNotification({
message: oldLang('Story.ToastSavedToProfileText'),
});
handleContextMenuClose();
});
const handleArchiveClick = useLastCallback((e: React.SyntheticEvent) => {
stopEvent(e);
toggleStoryInProfile({ peerId, storyId: story.id, isInProfile: false });
showNotification({
message: oldLang('Story.ToastRemovedFromProfileText'),
});
handleContextMenuClose();
});
const handleTogglePinned = useLastCallback(() => {
toggleStoryPinnedToTop({ peerId, storyId: story.id });
handleContextMenuClose();
});
return (
<div
ref={containerRef}
className={buildClassName(styles.root, 'scroll-item')}
onMouseDown={handleMouseDown}
onClick={handleClick}
onContextMenu={handleContextMenu}
>
{isDeleted && (
<span>
<Icon className={styles.mainIcon} name="story-expired" />
{oldLang('ExpiredStory')}
</span>
)}
{isUnsupportedStory && (
<span>
<Icon className={styles.mainIcon} name="warning" />
{lang('StoryUnsupported')}
</span>
)}
{isPinned && <Icon className={buildClassName(styles.overlayIcon, styles.pinnedIcon)} name="pin-badge" />}
{isFullyLoaded && Boolean(story.views?.viewsCount) && (
<span className={buildClassName(styles.overlayIcon, styles.viewsCount)}>
<Icon name="eye" />
{story.views.viewsCount}
</span>
)}
{duration && <span className={buildClassName(styles.overlayIcon, styles.duration)}>{duration}</span>}
<div className={styles.wrapper}>
{thumbUrl && (
<img src={thumbUrl} alt="" className={styles.media} draggable={false} />
)}
{isFullyLoaded && <MediaAreaOverlay story={story} />}
{isProtected && <span className="protector" />}
</div>
{contextMenuAnchor !== undefined && (
<Menu
isOpen={isContextMenuOpen}
anchor={contextMenuAnchor}
getTriggerElement={getTriggerElement}
getRootElement={getRootElement}
getMenuElement={getMenuElement}
getLayout={getLayout}
className={buildClassName(styles.contextMenu, 'story-context-menu')}
autoClose
onClose={handleContextMenuClose}
onCloseAnimationEnd={handleContextMenuHide}
withPortal
>
{isArchive && (
<MenuItem icon="archive" onClick={handleUnarchiveClick}>
{oldLang('StoryList.SaveToProfile')}
</MenuItem>
)}
{!isArchive && (
<MenuItem icon="archive" onClick={handleArchiveClick}>
{oldLang('Story.Context.RemoveFromProfile')}
</MenuItem>
)}
{!isArchive && !isPinned && canPin && (
<MenuItem icon="pin" onClick={handleTogglePinned}>
{oldLang('StoryList.ItemAction.Pin')}
</MenuItem>
)}
{!isArchive && isPinned && (
<MenuItem icon="unpin" onClick={handleTogglePinned}>
{oldLang('StoryList.ItemAction.Unpin')}
</MenuItem>
)}
</Menu>
)}
</div>
);
}
export default memo(withGlobal<OwnProps>((global, { story }): Complete<StateProps> => {
const chat = selectChat(global, story.peerId);
const isProtected = chat?.isProtected;
const { maxPinnedStoriesCount } = global.appConfig;
const isOwn = 'isOut' in story && story.isOut;
const pinnedStories = selectPinnedStories(global, story.peerId);
const isPinned = pinnedStories?.some((pinnedStory) => pinnedStory.id === story.id);
const canPinMore = isOwn && (!maxPinnedStoriesCount || (pinnedStories?.length || 0) < maxPinnedStoriesCount);
return {
isProtected,
isPinned,
canPin: canPinMore,
};
})(MediaStory));