Media Viewer: Support switching avatars (#1929)

This commit is contained in:
Alexander Zinchuk 2022-08-05 19:22:43 +02:00
parent 06fe3a2640
commit a55b410e6a
22 changed files with 439 additions and 381 deletions

View File

@ -66,22 +66,25 @@ const GroupChatInfo: FC<OwnProps & StateProps> = ({
const {
loadFullChat,
openMediaViewer,
loadProfilePhotos,
} = getActions();
const isSuperGroup = chat && isChatSuperGroup(chat);
const { id: chatId, isMin, isRestricted } = chat || {};
useEffect(() => {
if (chatId && !isMin && withFullInfo && lastSyncTime) {
loadFullChat({ chatId });
if (chatId && !isMin && lastSyncTime) {
if (withFullInfo) loadFullChat({ chatId });
if (withMediaViewer) loadProfilePhotos({ profileId: chatId });
}
}, [chatId, isMin, lastSyncTime, withFullInfo, loadFullChat, isSuperGroup]);
}, [chatId, isMin, lastSyncTime, withFullInfo, loadFullChat, loadProfilePhotos, isSuperGroup, withMediaViewer]);
const handleAvatarViewerOpen = useCallback((e: ReactMouseEvent<HTMLDivElement, MouseEvent>, hasMedia: boolean) => {
if (chat && hasMedia) {
e.stopPropagation();
openMediaViewer({
avatarOwnerId: chat.id,
mediaId: 0,
origin: avatarSize === 'jumbo' ? MediaViewerOrigin.ProfileAvatar : MediaViewerOrigin.MiddleHeaderAvatar,
});
}

View File

@ -67,22 +67,25 @@ const PrivateChatInfo: FC<OwnProps & StateProps> = ({
const {
loadFullUser,
openMediaViewer,
loadProfilePhotos,
} = getActions();
const { id: userId } = user || {};
const fullName = getUserFullName(user);
useEffect(() => {
if (withFullInfo && lastSyncTime && userId) {
loadFullUser({ userId });
if (userId && lastSyncTime) {
if (withFullInfo) loadFullUser({ userId });
if (withMediaViewer) loadProfilePhotos({ profileId: userId });
}
}, [userId, loadFullUser, lastSyncTime, withFullInfo]);
}, [userId, loadFullUser, loadProfilePhotos, lastSyncTime, withFullInfo, withMediaViewer]);
const handleAvatarViewerOpen = useCallback((e: ReactMouseEvent<HTMLDivElement, MouseEvent>, hasMedia: boolean) => {
if (user && hasMedia) {
e.stopPropagation();
openMediaViewer({
avatarOwnerId: user.id,
mediaId: 0,
origin: avatarSize === 'jumbo' ? MediaViewerOrigin.ProfileAvatar : MediaViewerOrigin.MiddleHeaderAvatar,
});
}

View File

@ -9,6 +9,7 @@ import type { GlobalState } from '../../global/types';
import { MediaViewerOrigin } from '../../types';
import { IS_TOUCH_ENV } from '../../util/environment';
import { MEMO_EMPTY_ARRAY } from '../../util/memo';
import { selectChat, selectUser, selectUserStatus } from '../../global/selectors';
import {
getUserFullName, getUserStatus, isChatChannel, isUserOnline,
@ -18,6 +19,7 @@ import { captureEvents, SwipeDirection } from '../../util/captureEvents';
import buildClassName from '../../util/buildClassName';
import usePhotosPreload from './hooks/usePhotosPreload';
import useLang from '../../hooks/useLang';
import usePrevious from '../../hooks/usePrevious';
import VerifiedIcon from './VerifiedIcon';
import ProfilePhoto from './ProfilePhoto';
@ -40,6 +42,8 @@ type StateProps =
isSavedMessages?: boolean;
animationLevel: 0 | 1 | 2;
serverTimeOffset: number;
mediaId?: number;
avatarOwnerId?: string;
}
& Pick<GlobalState, 'connectionState'>;
@ -52,6 +56,8 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
connectionState,
animationLevel,
serverTimeOffset,
mediaId,
avatarOwnerId,
}) => {
const {
loadFullUser,
@ -64,15 +70,26 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
const { id: userId } = user || {};
const { id: chatId } = chat || {};
const fullName = user ? getUserFullName(user) : (chat ? chat.title : '');
const photos = user?.photos || chat?.photos || [];
const slideAnimation = animationLevel >= 1
? (lang.isRtl ? 'slide-optimized-rtl' : 'slide-optimized')
const photos = user?.photos || chat?.photos || MEMO_EMPTY_ARRAY;
const prevMediaId = usePrevious(mediaId);
const prevAvatarOwnerId = usePrevious(avatarOwnerId);
const [hasSlideAnimation, setHasSlideAnimation] = useState(true);
const slideAnimation = hasSlideAnimation
? animationLevel >= 1 ? (lang.isRtl ? 'slide-optimized-rtl' : 'slide-optimized') : 'none'
: 'none';
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
const isFirst = isSavedMessages || photos.length <= 1 || currentPhotoIndex === 0;
const isLast = isSavedMessages || photos.length <= 1 || currentPhotoIndex === photos.length - 1;
// Set the current avatar photo to the last selected photo in Media Viewer after it is closed
useEffect(() => {
if (prevAvatarOwnerId && prevMediaId !== undefined && mediaId === undefined) {
setHasSlideAnimation(false);
setCurrentPhotoIndex(prevMediaId);
}
}, [mediaId, prevMediaId, prevAvatarOwnerId]);
// Deleting the last profile photo may result in an error
useEffect(() => {
if (currentPhotoIndex > photos.length) {
@ -91,7 +108,7 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
const handleProfilePhotoClick = useCallback(() => {
openMediaViewer({
avatarOwnerId: userId || chatId,
profilePhotoIndex: currentPhotoIndex,
mediaId: currentPhotoIndex,
origin: forceShowSelf ? MediaViewerOrigin.SettingsAvatar : MediaViewerOrigin.ProfileAvatar,
});
}, [openMediaViewer, userId, chatId, currentPhotoIndex, forceShowSelf]);
@ -106,7 +123,7 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
if (isFirst) {
return;
}
setHasSlideAnimation(true);
setCurrentPhotoIndex(currentPhotoIndex - 1);
}, [currentPhotoIndex, isFirst]);
@ -114,7 +131,7 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
if (isLast) {
return;
}
setHasSlideAnimation(true);
setCurrentPhotoIndex(currentPhotoIndex + 1);
}, [currentPhotoIndex, isLast]);
@ -251,6 +268,7 @@ export default memo(withGlobal<OwnProps>(
const chat = selectChat(global, userId);
const isSavedMessages = !forceShowSelf && user && user.isSelf;
const { animationLevel } = global.settings.byKey;
const { mediaId, avatarOwnerId } = global.mediaViewer;
return {
connectionState,
@ -260,6 +278,8 @@ export default memo(withGlobal<OwnProps>(
isSavedMessages,
animationLevel,
serverTimeOffset,
mediaId,
avatarOwnerId,
};
},
)(ProfileInfo));

View File

@ -15,6 +15,7 @@ import {
} from '../../global/helpers';
import renderText from './helpers/renderText';
import buildClassName from '../../util/buildClassName';
import safePlay from '../../util/safePlay';
import { getFirstLetters } from '../../util/textFormat';
import useMedia from '../../hooks/useMedia';
import useLang from '../../hooks/useLang';
@ -80,7 +81,7 @@ const ProfilePhoto: FC<OwnProps> = ({
videoRef.current.pause();
videoRef.current.currentTime = 0;
} else {
videoRef.current.play();
safePlay(videoRef.current);
}
}, [notActive]);

View File

@ -79,10 +79,10 @@ const MediaResults: FC<OwnProps & StateProps> = ({
}).filter(Boolean);
}, [globalMessagesByChatId, foundIds]);
const handleSelectMedia = useCallback((messageId: number, chatId: string) => {
const handleSelectMedia = useCallback((id: number, chatId: string) => {
openMediaViewer({
chatId,
messageId,
mediaId: id,
origin: MediaViewerOrigin.SearchResult,
});
}, [openMediaViewer]);

View File

@ -176,6 +176,12 @@
}
}
}
.is-protected {
user-select: none;
-webkit-touch-callout: none;
pointer-events: none;
}
}
.ghost {

View File

@ -4,28 +4,13 @@ import React, {
} from '../../lib/teact/teact';
import type {
ApiChat, ApiDimensions, ApiMessage, ApiUser,
ApiChat, ApiMessage, ApiUser,
} from '../../api/types';
import { ApiMediaFormat } from '../../api/types';
import { MediaViewerOrigin } from '../../types';
import { getActions, withGlobal } from '../../global';
import {
getChatAvatarHash,
getChatMediaMessageIds,
getMessageDocument,
getMessageFileName,
getMessageMediaFormat,
getMessageMediaHash,
getMessageMediaThumbDataUri,
getMessagePhoto,
getMessageVideo,
getMessageWebPagePhoto,
getMessageWebPageVideo,
getPhotoFullDimensions, getVideoAvatarMediaHash,
getVideoDimensions,
isMessageDocumentPhoto,
isMessageDocumentVideo,
} from '../../global/helpers';
import {
selectChat,
@ -43,22 +28,18 @@ import { stopCurrentAudio } from '../../util/audioPlayer';
import captureEscKeyListener from '../../util/captureEscKeyListener';
import { IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment';
import { ANIMATION_END_DELAY } from '../../config';
import {
AVATAR_FULL_DIMENSIONS, MEDIA_VIEWER_MEDIA_QUERY, VIDEO_AVATAR_FULL_DIMENSIONS,
} from '../common/helpers/mediaDimensions';
import { MEDIA_VIEWER_MEDIA_QUERY } from '../common/helpers/mediaDimensions';
import windowSize from '../../util/windowSize';
import { animateClosing, animateOpening } from './helpers/ghostAnimation';
import { renderMessageText } from '../common/helpers/renderMessageText';
import useBlurSync from '../../hooks/useBlurSync';
import useFlag from '../../hooks/useFlag';
import useForceUpdate from '../../hooks/useForceUpdate';
import { dispatchHeavyAnimationEvent } from '../../hooks/useHeavyAnimationCheck';
import useHistoryBack from '../../hooks/useHistoryBack';
import useLang from '../../hooks/useLang';
import useMedia from '../../hooks/useMedia';
import useMediaWithLoadProgress from '../../hooks/useMediaWithLoadProgress';
import usePrevious from '../../hooks/usePrevious';
import { useMediaProps } from './hooks/useMediaProps';
import ReportModal from '../common/ReportModal';
import Button from '../ui/Button';
@ -73,12 +54,11 @@ import './MediaViewer.scss';
type StateProps = {
chatId?: string;
threadId?: number;
messageId?: number;
mediaId?: number;
senderId?: string;
isChatWithSelf?: boolean;
origin?: MediaViewerOrigin;
avatarOwner?: ApiChat | ApiUser;
profilePhotoIndex?: number;
message?: ApiMessage;
chatMessages?: Record<number, ApiMessage>;
collectionIds?: number[];
@ -90,12 +70,11 @@ const ANIMATION_DURATION = 350;
const MediaViewer: FC<StateProps> = ({
chatId,
threadId,
messageId,
mediaId,
senderId,
isChatWithSelf,
origin,
avatarOwner,
profilePhotoIndex,
message,
chatMessages,
collectionIds,
@ -109,40 +88,11 @@ const MediaViewer: FC<StateProps> = ({
toggleChatInfo,
} = getActions();
const isOpen = Boolean(avatarOwner || messageId);
const isFromSharedMedia = origin === MediaViewerOrigin.SharedMedia;
const isFromSearch = origin === MediaViewerOrigin.SearchResult;
/* Content */
const photo = message ? getMessagePhoto(message) : undefined;
const video = message ? getMessageVideo(message) : undefined;
const webPagePhoto = message ? getMessageWebPagePhoto(message) : undefined;
const webPageVideo = message ? getMessageWebPageVideo(message) : undefined;
const isDocumentPhoto = message ? isMessageDocumentPhoto(message) : false;
const isDocumentVideo = message ? isMessageDocumentVideo(message) : false;
const isVideo = Boolean(video || webPageVideo || isDocumentVideo);
const { isGif } = video || webPageVideo || {};
const isPhoto = Boolean(!isVideo && (photo || webPagePhoto || isDocumentPhoto));
const isAvatar = Boolean(avatarOwner);
/* Navigation */
const singleMessageId = webPagePhoto || webPageVideo ? messageId : undefined;
const messageIds = useMemo(() => {
return singleMessageId
? [singleMessageId]
: getChatMediaMessageIds(chatMessages || {}, collectionIds || [], isFromSharedMedia);
}, [singleMessageId, chatMessages, collectionIds, isFromSharedMedia]);
const selectedMediaMessageIndex = messageId ? messageIds.indexOf(messageId) : -1;
const isOpen = Boolean(avatarOwner || mediaId);
/* Animation */
const animationKey = useRef<number>();
const prevSenderId = usePrevious<string | undefined>(senderId);
if (isOpen && (!prevSenderId || prevSenderId !== senderId || !animationKey.current)) {
animationKey.current = selectedMediaMessageIndex;
}
const headerAnimation = animationLevel === 2 ? 'slide-fade' : 'none';
const isGhostAnimation = animationLevel === 2;
@ -150,80 +100,46 @@ const MediaViewer: FC<StateProps> = ({
const [isReportModalOpen, openReportModal, closeReportModal] = useFlag();
const [zoomLevelChange, setZoomLevelChange] = useState<number>(1);
/* Media data */
function getMediaHash(isFull?: boolean) {
if (isAvatar && profilePhotoIndex !== undefined) {
const { photos } = avatarOwner!;
const avatarPhoto = photos && photos[profilePhotoIndex];
return avatarPhoto
// Video for avatar should be used only for full size
? (avatarPhoto.isVideo && isFull ? getVideoAvatarMediaHash(avatarPhoto) : `photo${avatarPhoto.id}?size=c`)
: getChatAvatarHash(avatarOwner!, isFull ? 'big' : 'normal');
const {
webPagePhoto,
webPageVideo,
isVideo,
isPhoto,
bestImageData,
dimensions,
isGif,
isFromSharedMedia,
avatarPhoto,
fileName,
fullMediaBlobUrl,
previewBlobUrl,
} = useMediaProps({
message, avatarOwner, mediaId, delay: isGhostAnimation && ANIMATION_DURATION,
});
const canReport = !!avatarPhoto && !isChatWithSelf;
/* Navigation */
const singleMediaId = webPagePhoto || webPageVideo ? mediaId : undefined;
const mediaIds = useMemo(() => {
if (singleMediaId) {
return [singleMediaId];
} else if (avatarOwner) {
return avatarOwner.photos?.map((p, i) => i) || [];
} else {
return getChatMediaMessageIds(chatMessages || {}, collectionIds || [], isFromSharedMedia);
}
}, [singleMediaId, avatarOwner, chatMessages, collectionIds, isFromSharedMedia]);
return message && getMessageMediaHash(message, isFull ? 'viewerFull' : 'viewerPreview');
}
const selectedMediaIndex = mediaId ? mediaIds.indexOf(mediaId) : -1;
const pictogramBlobUrl = useMedia(
message && (isFromSharedMedia || isFromSearch) && getMessageMediaHash(message, 'pictogram'),
undefined,
ApiMediaFormat.BlobUrl,
undefined,
isGhostAnimation && ANIMATION_DURATION,
);
const previewMediaHash = getMediaHash();
const previewBlobUrl = useMedia(
previewMediaHash,
undefined,
ApiMediaFormat.BlobUrl,
undefined,
isGhostAnimation && ANIMATION_DURATION,
);
const { mediaData: fullMediaBlobUrl } = useMediaWithLoadProgress(
getMediaHash(true),
undefined,
message && getMessageMediaFormat(message, 'viewerFull'),
undefined,
isGhostAnimation && ANIMATION_DURATION,
);
const avatarPhoto = avatarOwner?.photos?.[profilePhotoIndex!];
const isVideoAvatar = Boolean(isAvatar && avatarPhoto?.isVideo);
const canReport = !!avatarPhoto && profilePhotoIndex! > 0 && !isChatWithSelf;
const localBlobUrl = (photo || video) ? (photo || video)!.blobUrl : undefined;
let bestImageData = (!isVideo && (localBlobUrl || fullMediaBlobUrl)) || previewBlobUrl || pictogramBlobUrl;
const thumbDataUri = useBlurSync(!bestImageData && message && getMessageMediaThumbDataUri(message));
if (!bestImageData && origin !== MediaViewerOrigin.SearchResult) {
bestImageData = thumbDataUri;
}
if (isVideoAvatar && previewBlobUrl) {
bestImageData = previewBlobUrl;
}
const fileName = message
? getMessageFileName(message)
: isAvatar
? `avatar${avatarOwner!.id}-${profilePhotoIndex}.${avatarOwner?.hasVideoAvatar ? 'mp4' : 'jpg'}`
: undefined;
let dimensions!: ApiDimensions;
if (message) {
if (isDocumentPhoto || isDocumentVideo) {
dimensions = getMessageDocument(message)!.mediaSize!;
} else if (photo || webPagePhoto) {
dimensions = getPhotoFullDimensions((photo || webPagePhoto)!)!;
} else if (video || webPageVideo) {
dimensions = getVideoDimensions((video || webPageVideo)!)!;
}
} else {
dimensions = isVideoAvatar ? VIDEO_AVATAR_FULL_DIMENSIONS : AVATAR_FULL_DIMENSIONS;
if (isOpen && (!prevSenderId || prevSenderId !== senderId || !animationKey.current)) {
animationKey.current = selectedMediaIndex;
}
useEffect(() => {
if (!IS_SINGLE_COLUMN_LAYOUT) {
return;
}
if (!IS_SINGLE_COLUMN_LAYOUT) return;
document.body.classList.toggle('is-media-viewer-open', isOpen);
}, [isOpen]);
@ -277,28 +193,31 @@ const MediaViewer: FC<StateProps> = ({
if (IS_SINGLE_COLUMN_LAYOUT) {
setTimeout(() => {
toggleChatInfo(false, { forceSyncOnIOs: true });
focusMessage({ chatId, threadId, messageId });
focusMessage({ chatId, threadId, mediaId });
}, ANIMATION_DURATION);
} else {
focusMessage({ chatId, threadId, messageId });
focusMessage({ chatId, threadId, mediaId });
}
}, [close, chatId, threadId, focusMessage, toggleChatInfo, messageId]);
}, [close, chatId, threadId, focusMessage, toggleChatInfo, mediaId]);
const handleForward = useCallback(() => {
openForwardMenu({
fromChatId: chatId,
messageIds: [messageId],
messageIds: [mediaId],
});
}, [openForwardMenu, chatId, messageId]);
}, [openForwardMenu, chatId, mediaId]);
const selectMessage = useCallback((id?: number) => openMediaViewer({
chatId,
threadId,
messageId: id,
origin,
}, {
forceOnHeavyAnimation: true,
}), [chatId, openMediaViewer, origin, threadId]);
const selectMedia = useCallback((id?: number) => {
openMediaViewer({
chatId,
threadId,
mediaId: id,
avatarOwnerId: avatarOwner?.id,
origin,
}, {
forceOnHeavyAnimation: true,
});
}, [avatarOwner?.id, chatId, openMediaViewer, origin, threadId]);
useEffect(() => (isOpen ? captureEscKeyListener(() => {
close();
@ -323,14 +242,14 @@ const MediaViewer: FC<StateProps> = ({
};
}, [isOpen]);
const getMessageId = useCallback((fromId?: number, direction?: number): number | undefined => {
if (!fromId) return undefined;
const index = messageIds.indexOf(fromId);
if ((direction === -1 && index > 0) || (direction === 1 && index < messageIds.length - 1)) {
return messageIds[index + direction];
const getMediaId = useCallback((fromId?: number, direction?: number): number | undefined => {
if (fromId === undefined) return undefined;
const index = mediaIds.indexOf(fromId);
if ((direction === -1 && index > 0) || (direction === 1 && index < mediaIds.length - 1)) {
return mediaIds[index + direction];
}
return undefined;
}, [messageIds]);
}, [mediaIds]);
const lang = useLang();
@ -340,17 +259,17 @@ const MediaViewer: FC<StateProps> = ({
});
function renderSenderInfo() {
return isAvatar ? (
return avatarOwner ? (
<SenderInfo
key={avatarOwner!.id}
chatId={avatarOwner!.id}
key={avatarOwner.id}
chatId={avatarOwner.id}
isAvatar
/>
) : (
<SenderInfo
key={messageId}
key={mediaId}
chatId={chatId}
messageId={messageId}
messageId={mediaId}
/>
);
}
@ -384,7 +303,7 @@ const MediaViewer: FC<StateProps> = ({
onForward={handleForward}
zoomLevelChange={zoomLevelChange}
setZoomLevelChange={setZoomLevelChange}
isAvatar={isAvatar}
isAvatar={Boolean(avatarOwner)}
/>
<ReportModal
isOpen={isReportModalOpen}
@ -395,23 +314,21 @@ const MediaViewer: FC<StateProps> = ({
/>
</div>
<MediaViewerSlides
messageId={messageId}
getMessageId={getMessageId}
mediaId={mediaId}
getMediaId={getMediaId}
chatId={chatId}
isPhoto={isPhoto}
isGif={isGif}
threadId={threadId}
avatarOwnerId={avatarOwner && avatarOwner.id}
profilePhotoIndex={profilePhotoIndex}
avatarOwnerId={avatarOwner?.id}
origin={origin}
isOpen={isOpen}
hasFooter={hasFooter}
zoomLevelChange={zoomLevelChange}
isActive
isVideo={isVideo}
animationLevel={animationLevel}
onClose={close}
selectMessage={selectMessage}
selectMedia={selectMedia}
onFooterClick={handleFooterClick}
/>
</ShowTransition>
@ -423,9 +340,8 @@ export default memo(withGlobal(
const {
chatId,
threadId,
messageId,
mediaId,
avatarOwnerId,
profilePhotoIndex,
origin,
} = global.mediaViewer;
const {
@ -435,18 +351,18 @@ export default memo(withGlobal(
let isChatWithSelf = !!chatId && selectIsChatWithSelf(global, chatId);
if (origin === MediaViewerOrigin.SearchResult) {
if (!(chatId && messageId)) {
if (!(chatId && mediaId)) {
return { animationLevel };
}
const message = selectChatMessage(global, chatId, messageId);
const message = selectChatMessage(global, chatId, mediaId);
if (!message) {
return { animationLevel };
}
return {
chatId,
messageId,
mediaId,
senderId: message.senderId,
isChatWithSelf,
origin,
@ -460,25 +376,24 @@ export default memo(withGlobal(
isChatWithSelf = selectIsChatWithSelf(global, avatarOwnerId);
return {
messageId: -1,
mediaId,
senderId: avatarOwnerId,
avatarOwner: sender,
isChatWithSelf,
profilePhotoIndex: profilePhotoIndex || 0,
animationLevel,
origin,
};
}
if (!(chatId && threadId && messageId)) {
if (!(chatId && threadId && mediaId)) {
return { animationLevel };
}
let message: ApiMessage | undefined;
if (origin && [MediaViewerOrigin.ScheduledAlbum, MediaViewerOrigin.ScheduledInline].includes(origin)) {
message = selectScheduledMessage(global, chatId, messageId);
message = selectScheduledMessage(global, chatId, mediaId);
} else {
message = selectChatMessage(global, chatId, messageId);
message = selectChatMessage(global, chatId, mediaId);
}
if (!message) {
@ -505,7 +420,7 @@ export default memo(withGlobal(
return {
chatId,
threadId,
messageId,
mediaId,
senderId: message.senderId,
isChatWithSelf,
origin,

View File

@ -5,37 +5,17 @@ import { withGlobal } from '../../global';
import type {
ApiChat, ApiDimensions, ApiMessage, ApiUser,
} from '../../api/types';
import { ApiMediaFormat } from '../../api/types';
import { MediaViewerOrigin } from '../../types';
import { IS_SINGLE_COLUMN_LAYOUT, IS_TOUCH_ENV } from '../../util/environment';
import useBlurSync from '../../hooks/useBlurSync';
import useMedia from '../../hooks/useMedia';
import useMediaWithLoadProgress from '../../hooks/useMediaWithLoadProgress';
import {
getChatAvatarHash,
getMessageDocument,
getMessageFileSize,
getMessageMediaFormat,
getMessageMediaHash,
getMessageMediaThumbDataUri,
getMessagePhoto,
getMessageVideo,
getMessageWebPagePhoto,
getMessageWebPageVideo,
getPhotoFullDimensions, getVideoAvatarMediaHash,
getVideoDimensions,
isMessageDocumentPhoto,
isMessageDocumentVideo,
} from '../../global/helpers';
import {
selectChat, selectChatMessage, selectIsMessageProtected, selectScheduledMessage, selectUser,
} from '../../global/selectors';
import {
AVATAR_FULL_DIMENSIONS, calculateMediaViewerDimensions, VIDEO_AVATAR_FULL_DIMENSIONS,
} from '../common/helpers/mediaDimensions';
import { calculateMediaViewerDimensions } from '../common/helpers/mediaDimensions';
import { renderMessageText } from '../common/helpers/renderMessageText';
import stopEvent from '../../util/stopEvent';
import buildClassName from '../../util/buildClassName';
import { useMediaProps } from './hooks/useMediaProps';
import Spinner from '../ui/Spinner';
import MediaViewerFooter from './MediaViewerFooter';
@ -44,11 +24,10 @@ import VideoPlayer from './VideoPlayer';
import './MediaViewerContent.scss';
type OwnProps = {
messageId?: number;
mediaId?: number;
chatId?: string;
threadId?: number;
avatarOwnerId?: string;
profilePhotoIndex?: number;
origin?: MediaViewerOrigin;
isActive?: boolean;
animationLevel: 0 | 1 | 2;
@ -60,11 +39,10 @@ type OwnProps = {
type StateProps = {
chatId?: string;
messageId?: number;
mediaId?: number;
senderId?: string;
threadId?: number;
avatarOwner?: ApiChat | ApiUser;
profilePhotoIndex?: number;
message?: ApiMessage;
origin?: MediaViewerOrigin;
isProtected?: boolean;
@ -77,12 +55,11 @@ const ANIMATION_DURATION = 350;
const MediaViewerContent: FC<OwnProps & StateProps> = (props) => {
const {
messageId,
mediaId,
isActive,
avatarOwner,
chatId,
message,
profilePhotoIndex,
origin,
animationLevel,
isFooterHidden,
@ -94,63 +71,27 @@ const MediaViewerContent: FC<OwnProps & StateProps> = (props) => {
onFooterClick,
setIsFooterHidden,
} = props;
/* Content */
const photo = message ? getMessagePhoto(message) : undefined;
const video = message ? getMessageVideo(message) : undefined;
const webPagePhoto = message ? getMessageWebPagePhoto(message) : undefined;
const webPageVideo = message ? getMessageWebPageVideo(message) : undefined;
const isDocumentPhoto = message ? isMessageDocumentPhoto(message) : false;
const isDocumentVideo = message ? isMessageDocumentVideo(message) : false;
const isVideo = Boolean(video || webPageVideo || isDocumentVideo);
const isPhoto = Boolean(!isVideo && (photo || webPagePhoto || isDocumentPhoto));
const { isGif } = video || webPageVideo || {};
const isOpen = Boolean(avatarOwner || messageId);
const isAvatar = Boolean(avatarOwner);
const isVideoAvatar = isAvatar && avatarOwner.hasVideoAvatar;
const isFromSharedMedia = origin === MediaViewerOrigin.SharedMedia;
const isFromSearch = origin === MediaViewerOrigin.SearchResult;
const isGhostAnimation = animationLevel === 2;
/* Media data */
function getMediaHash(isFull?: boolean) {
if (isAvatar && profilePhotoIndex !== undefined) {
const { photos, hasVideoAvatar } = avatarOwner!;
const avatarPhoto = photos && photos[profilePhotoIndex];
return avatarPhoto ? (hasVideoAvatar ? getVideoAvatarMediaHash(avatarPhoto) : `photo${avatarPhoto.id}?size=c`)
: getChatAvatarHash(avatarOwner!, isFull ? 'big' : 'normal');
}
return message && getMessageMediaHash(message, isFull ? 'viewerFull' : 'viewerPreview');
}
const pictogramBlobUrl = useMedia(
message && (isFromSharedMedia || isFromSearch) && getMessageMediaHash(message, 'pictogram'),
undefined,
ApiMediaFormat.BlobUrl,
undefined,
isGhostAnimation && ANIMATION_DURATION,
);
const previewMediaHash = getMediaHash();
const previewBlobUrl = useMedia(
previewMediaHash,
undefined,
ApiMediaFormat.BlobUrl,
undefined,
isGhostAnimation && ANIMATION_DURATION,
);
const {
mediaData: fullMediaBlobUrl,
isVideo,
isPhoto,
bestImageData,
dimensions,
isGif,
isVideoAvatar,
localBlobUrl,
fullMediaBlobUrl,
previewBlobUrl,
pictogramBlobUrl,
videoSize,
loadProgress,
} = useMediaWithLoadProgress(
getMediaHash(true),
undefined,
message && getMessageMediaFormat(message, 'viewerFull'),
undefined,
isGhostAnimation && ANIMATION_DURATION,
);
} = useMediaProps({
message, avatarOwner, mediaId, origin, delay: isGhostAnimation && ANIMATION_DURATION,
});
const isOpen = Boolean(avatarOwner || mediaId);
const toggleControls = useCallback((isVisible) => {
setIsFooterHidden?.(!isVisible);
@ -164,29 +105,7 @@ const MediaViewerContent: FC<OwnProps & StateProps> = (props) => {
toggleControls(false);
}, [toggleControls]);
const localBlobUrl = (photo || video) ? (photo || video)!.blobUrl : undefined;
let bestImageData = (!isVideo && (localBlobUrl || fullMediaBlobUrl)) || previewBlobUrl || pictogramBlobUrl;
const thumbDataUri = useBlurSync(!bestImageData && message && getMessageMediaThumbDataUri(message));
if (!bestImageData && origin !== MediaViewerOrigin.SearchResult) {
bestImageData = thumbDataUri;
}
const videoSize = message ? getMessageFileSize(message) : undefined;
let dimensions!: ApiDimensions;
if (message) {
if (isDocumentPhoto || isDocumentVideo) {
dimensions = getMessageDocument(message)!.mediaSize!;
} else if (photo || webPagePhoto) {
dimensions = getPhotoFullDimensions((photo || webPagePhoto)!)!;
} else if (video || webPageVideo) {
dimensions = getVideoDimensions((video || webPageVideo)!)!;
}
} else {
dimensions = isVideoAvatar ? VIDEO_AVATAR_FULL_DIMENSIONS : AVATAR_FULL_DIMENSIONS;
}
if (isAvatar) {
if (avatarOwner) {
if (!isVideoAvatar) {
return (
<div key={chatId} className="MediaViewerContent">
@ -194,6 +113,7 @@ const MediaViewerContent: FC<OwnProps & StateProps> = (props) => {
fullMediaBlobUrl || previewBlobUrl,
calculateMediaViewerDimensions(dimensions, false),
!IS_SINGLE_COLUMN_LAYOUT && !isProtected,
isProtected,
)}
</div>
);
@ -201,7 +121,7 @@ const MediaViewerContent: FC<OwnProps & StateProps> = (props) => {
return (
<div key={chatId} className="MediaViewerContent">
<VideoPlayer
key={messageId}
key={mediaId}
url={localBlobUrl || fullMediaBlobUrl}
isGif
posterData={bestImageData}
@ -211,6 +131,7 @@ const MediaViewerContent: FC<OwnProps & StateProps> = (props) => {
isMediaViewerOpen={isOpen && isActive}
areControlsVisible={!isFooterHidden}
toggleControls={toggleControls}
isProtected={isProtected}
noPlay={!isActive}
onClose={onClose}
isMuted
@ -228,23 +149,24 @@ const MediaViewerContent: FC<OwnProps & StateProps> = (props) => {
return (
<div
className={`MediaViewerContent ${hasFooter ? 'has-footer' : ''}`}
className={buildClassName('MediaViewerContent', hasFooter && 'has-footer')}
onMouseMove={!isGif && !IS_TOUCH_ENV ? handleMouseMove : undefined}
onMouseOut={!isGif && !IS_TOUCH_ENV ? handleMouseOut : undefined}
>
{isProtected && <div onContextMenu={stopEvent} className="protector" />}
{isPhoto && renderPhoto(
localBlobUrl || fullMediaBlobUrl || previewBlobUrl || pictogramBlobUrl,
message && calculateMediaViewerDimensions(dimensions!, hasFooter),
!IS_SINGLE_COLUMN_LAYOUT && !isProtected,
isProtected,
)}
{isVideo && (!isActive ? renderVideoPreview(
bestImageData,
message && calculateMediaViewerDimensions(dimensions!, hasFooter, true),
!IS_SINGLE_COLUMN_LAYOUT && !isProtected,
isProtected,
) : (
<VideoPlayer
key={messageId}
key={mediaId}
url={localBlobUrl || fullMediaBlobUrl}
isGif={isGif}
posterData={bestImageData}
@ -257,6 +179,7 @@ const MediaViewerContent: FC<OwnProps & StateProps> = (props) => {
noPlay={!isActive}
onClose={onClose}
isMuted={isMuted}
isProtected={isProtected}
volume={volume}
playbackRate={playbackRate}
/>
@ -265,6 +188,7 @@ const MediaViewerContent: FC<OwnProps & StateProps> = (props) => {
<MediaViewerFooter
text={textParts}
onClick={onFooterClick}
isProtected={isProtected}
isHidden={isFooterHidden}
isForVideo={isVideo && !isGif}
/>
@ -278,9 +202,8 @@ export default memo(withGlobal<OwnProps>(
const {
chatId,
threadId,
messageId,
mediaId,
avatarOwnerId,
profilePhotoIndex,
origin,
} = ownProps;
@ -291,18 +214,18 @@ export default memo(withGlobal<OwnProps>(
} = global.mediaViewer;
if (origin === MediaViewerOrigin.SearchResult) {
if (!(chatId && messageId)) {
if (!(chatId && mediaId)) {
return { volume, isMuted, playbackRate };
}
const message = selectChatMessage(global, chatId, messageId);
const message = selectChatMessage(global, chatId, mediaId);
if (!message) {
return { volume, isMuted, playbackRate };
}
return {
chatId,
messageId,
mediaId,
senderId: message.senderId,
origin,
message,
@ -317,10 +240,9 @@ export default memo(withGlobal<OwnProps>(
const sender = selectUser(global, avatarOwnerId) || selectChat(global, avatarOwnerId);
return {
messageId: -1,
mediaId,
senderId: avatarOwnerId,
avatarOwner: sender,
profilePhotoIndex: profilePhotoIndex || 0,
origin,
volume,
isMuted,
@ -328,15 +250,15 @@ export default memo(withGlobal<OwnProps>(
};
}
if (!(chatId && threadId && messageId)) {
if (!(chatId && threadId && mediaId)) {
return { volume, isMuted, playbackRate };
}
let message: ApiMessage | undefined;
if (origin && [MediaViewerOrigin.ScheduledAlbum, MediaViewerOrigin.ScheduledInline].includes(origin)) {
message = selectScheduledMessage(global, chatId, messageId);
message = selectScheduledMessage(global, chatId, mediaId);
} else {
message = selectChatMessage(global, chatId, messageId);
message = selectChatMessage(global, chatId, mediaId);
}
if (!message) {
@ -346,7 +268,7 @@ export default memo(withGlobal<OwnProps>(
return {
chatId,
threadId,
messageId,
mediaId,
senderId: message.senderId,
origin,
message,
@ -358,15 +280,19 @@ export default memo(withGlobal<OwnProps>(
},
)(MediaViewerContent));
function renderPhoto(blobUrl?: string, imageSize?: ApiDimensions, canDrag?: boolean) {
function renderPhoto(blobUrl?: string, imageSize?: ApiDimensions, canDrag?: boolean, isProtected?: boolean) {
return blobUrl
? (
<img
src={blobUrl}
alt=""
style={imageSize ? `width: ${imageSize.width}px` : ''}
draggable={Boolean(canDrag)}
/>
<div style="position: relative;">
{isProtected && <div onContextMenu={stopEvent} className="protector" />}
<img
src={blobUrl}
alt=""
className={buildClassName(isProtected && 'is-protected')}
style={imageSize ? `width: ${imageSize.width}px` : ''}
draggable={Boolean(canDrag)}
/>
</div>
)
: (
<div
@ -378,7 +304,7 @@ function renderPhoto(blobUrl?: string, imageSize?: ApiDimensions, canDrag?: bool
);
}
function renderVideoPreview(blobUrl?: string, imageSize?: ApiDimensions, canDrag?: boolean) {
function renderVideoPreview(blobUrl?: string, imageSize?: ApiDimensions, canDrag?: boolean, isProtected?: boolean) {
const wrapperStyle = imageSize && `width: ${imageSize.width}px; height: ${imageSize.height}px`;
const videoStyle = `background-image: url(${blobUrl})`;
return blobUrl
@ -386,12 +312,14 @@ function renderVideoPreview(blobUrl?: string, imageSize?: ApiDimensions, canDrag
<div
className="VideoPlayer"
>
{isProtected && <div onContextMenu={stopEvent} className="protector" />}
<div
style={wrapperStyle}
>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
style={videoStyle}
className={buildClassName(isProtected && 'is-protected')}
draggable={Boolean(canDrag)}
/>
</div>

View File

@ -17,10 +17,11 @@ type OwnProps = {
onClick: () => void;
isHidden?: boolean;
isForVideo: boolean;
isProtected?: boolean;
};
const MediaViewerFooter: FC<OwnProps> = ({
text = '', isHidden, isForVideo, onClick,
text = '', isHidden, isForVideo, onClick, isProtected,
}) => {
const [isMultiline, setIsMultiline] = useState(false);
useEffect(() => {
@ -54,6 +55,7 @@ const MediaViewerFooter: FC<OwnProps> = ({
'MediaViewerFooter',
isForVideo && 'is-for-video',
isHidden && 'is-hidden',
isProtected && 'is-protected',
);
return (

View File

@ -27,18 +27,16 @@ import './MediaViewerSlides.scss';
const { easeOutCubic, easeOutQuart } = timingFunctions;
type OwnProps = {
messageId?: number;
getMessageId: (fromId?: number, direction?: number) => number | undefined;
mediaId?: number;
getMediaId: (fromId?: number, direction?: number) => number | undefined;
isVideo?: boolean;
isGif?: boolean;
isPhoto?: boolean;
isOpen?: boolean;
selectMessage: (id?: number) => void;
selectMedia: (id?: number) => void;
chatId?: string;
threadId?: number;
isActive?: boolean;
avatarOwnerId?: string;
profilePhotoIndex?: number;
origin?: MediaViewerOrigin;
animationLevel: 0 | 1 | 2;
onClose: () => void;
@ -74,14 +72,13 @@ enum SwipeDirection {
}
const MediaViewerSlides: FC<OwnProps> = ({
messageId,
getMessageId,
selectMessage,
mediaId,
getMediaId,
selectMedia,
isVideo,
isGif,
isPhoto,
isOpen,
isActive,
hasFooter,
zoomLevelChange,
animationLevel,
@ -96,7 +93,7 @@ const MediaViewerSlides: FC<OwnProps> = ({
const swipeDirectionRef = useRef<SwipeDirection | undefined>(undefined);
const isActiveRef = useRef(true);
const isReleasedRef = useRef(false);
const [activeMessageId, setActiveMessageId] = useState<number | undefined>(messageId);
const [activeMediaId, setActiveMediaId] = useState<number | undefined>(mediaId);
const prevZoomLevelChange = usePrevious(zoomLevelChange);
const hasZoomChanged = prevZoomLevelChange !== undefined && prevZoomLevelChange !== zoomLevelChange;
const forceUpdate = useForceUpdate();
@ -112,7 +109,7 @@ const MediaViewerSlides: FC<OwnProps> = ({
forceUpdate();
}, [forceUpdate]);
const selectMessageDebounced = useDebouncedCallback(selectMessage, [], DEBOUNCE_MESSAGE, true);
const selectMediaDebounced = useDebouncedCallback(selectMedia, [], DEBOUNCE_MESSAGE, true);
const clearSwipeDirectionDebounced = useDebouncedCallback(() => {
swipeDirectionRef.current = undefined;
}, [], DEBOUNCE_SWIPE, true);
@ -135,7 +132,7 @@ const MediaViewerSlides: FC<OwnProps> = ({
useTimeout(() => setIsFooterHidden(false), ANIMATION_DURATION - 150);
useEffect(() => {
if (!containerRef.current || !activeMessageId) {
if (!containerRef.current || activeMediaId === undefined) {
return undefined;
}
let lastTransform = lastTransformRef.current;
@ -159,13 +156,13 @@ const MediaViewerSlides: FC<OwnProps> = ({
}, 500, false, true);
const changeSlide = (direction: number) => {
const mId = getMessageId(activeMessageId, direction);
if (mId) {
const mId = getMediaId(activeMediaId, direction);
if (mId !== undefined) {
const offset = (windowWidth + SLIDES_GAP) * direction;
transformRef.current.x += offset;
isActiveRef.current = false;
setActiveMessageId(mId);
selectMessageDebounced(mId);
setActiveMediaId(mId);
selectMediaDebounced(mId);
setIsActiveDebounced(true);
lastTransform = { x: 0, y: 0, scale: 1 };
if (animationLevel === 0) {
@ -347,19 +344,19 @@ const MediaViewerSlides: FC<OwnProps> = ({
}
// Get horizontal swipe direction
const direction = x < 0 ? 1 : -1;
const mId = getMessageId(activeMessageId, x < 0 ? 1 : -1);
const mId = getMediaId(activeMediaId, x < 0 ? 1 : -1);
// Get the direction of the last pan gesture.
// Could be different from the total horizontal swipe direction
// if user starts a swipe in one direction and then changes the direction
// we need to cancel slide transition
const dirX = panDelta.x < 0 ? -1 : 1;
if (mId && absX >= SWIPE_X_THRESHOLD && direction === dirX) {
if (mId !== undefined && absX >= SWIPE_X_THRESHOLD && direction === dirX) {
const offset = (windowWidth + SLIDES_GAP) * direction;
// If image is shifted by more than SWIPE_X_THRESHOLD,
// We shift everything by one screen width and then set new active message id
transformRef.current.x += offset;
setActiveMessageId(mId);
selectMessageDebounced(mId);
setActiveMediaId(mId);
selectMediaDebounced(mId);
}
// Then we always return to the original position
cancelAnimation = animateNumber({
@ -606,13 +603,13 @@ const MediaViewerSlides: FC<OwnProps> = ({
}, [
onClose,
setTransform,
getMessageId,
activeMessageId,
getMediaId,
activeMediaId,
windowWidth,
windowHeight,
clickXThreshold,
shouldCloseOnVideo,
selectMessageDebounced,
selectMediaDebounced,
setIsActiveDebounced,
clearSwipeDirectionDebounced,
animationLevel,
@ -623,12 +620,13 @@ const MediaViewerSlides: FC<OwnProps> = ({
if (!containerRef.current || !hasZoomChanged) return;
const { scale } = transformRef.current;
const dir = zoomLevelChange > 0 ? -1 : +1;
const minZoom = MIN_ZOOM * 0.5;
const minZoom = MIN_ZOOM * 0.6;
const maxZoom = MAX_ZOOM * 3;
const steps = 100;
let steps = 100;
let prevValue = 0;
if (scale <= minZoom && dir > 0) return;
if (scale >= maxZoom && dir < 0) return;
if (scale === 1 && dir > 0) steps = 20;
if (cancelZoomAnimation) cancelZoomAnimation();
cancelZoomAnimation = animateNumber({
from: dir,
@ -649,61 +647,61 @@ const MediaViewerSlides: FC<OwnProps> = ({
});
}, [zoomLevelChange, hasZoomChanged]);
if (!activeMessageId) return undefined;
if (activeMediaId === undefined) return undefined;
const nextMessageId = getMessageId(activeMessageId, 1);
const previousMessageId = getMessageId(activeMessageId, -1);
const nextMediaId = getMediaId(activeMediaId, 1);
const prevMediaId = getMediaId(activeMediaId, -1);
const hasPrev = prevMediaId !== undefined;
const hasNext = nextMediaId !== undefined;
const offsetX = transformRef.current.x;
const offsetY = transformRef.current.y;
const { scale } = transformRef.current;
return (
<div className="MediaViewerSlides" ref={containerRef}>
{previousMessageId && scale === 1 && !isResizing && (
{hasPrev && scale === 1 && !isResizing && (
<div className="MediaViewerSlide" style={getAnimationStyle(-windowWidth + offsetX - SLIDES_GAP)}>
<MediaViewerContent
/* eslint-disable-next-line react/jsx-props-no-spreading */
{...rest}
animationLevel={animationLevel}
isFooterHidden={isFooterHidden}
messageId={previousMessageId}
mediaId={prevMediaId}
/>
</div>
)}
{activeMessageId && (
<div
className={buildClassName(
'MediaViewerSlide',
isActive && 'MediaViewerSlide--active',
isMouseDown && scale > 1 && 'MediaViewerSlide--moving',
)}
onClick={handleToggleFooterVisibility}
ref={activeSlideRef}
style={getAnimationStyle(offsetX, offsetY, scale)}
>
<MediaViewerContent
/* eslint-disable-next-line react/jsx-props-no-spreading */
{...rest}
messageId={activeMessageId}
animationLevel={animationLevel}
isActive={isActive && isActiveRef.current}
setIsFooterHidden={setIsFooterHidden}
isFooterHidden={isFooterHidden || scale !== 1}
/>
</div>
)}
{nextMessageId && scale === 1 && !isResizing && (
<div
className={buildClassName(
'MediaViewerSlide',
'MediaViewerSlide--active',
isMouseDown && scale > 1 && 'MediaViewerSlide--moving',
)}
onClick={handleToggleFooterVisibility}
ref={activeSlideRef}
style={getAnimationStyle(offsetX, offsetY, scale)}
>
<MediaViewerContent
/* eslint-disable-next-line react/jsx-props-no-spreading */
{...rest}
mediaId={activeMediaId}
animationLevel={animationLevel}
isActive={isActiveRef.current}
setIsFooterHidden={setIsFooterHidden}
isFooterHidden={isFooterHidden || scale !== 1}
/>
</div>
{hasNext && scale === 1 && !isResizing && (
<div className="MediaViewerSlide" style={getAnimationStyle(windowWidth + offsetX + SLIDES_GAP)}>
<MediaViewerContent
/* eslint-disable-next-line react/jsx-props-no-spreading */
{...rest}
animationLevel={animationLevel}
isFooterHidden={isFooterHidden}
messageId={nextMessageId}
mediaId={nextMediaId}
/>
</div>
)}
{previousMessageId && scale === 1 && !IS_TOUCH_ENV && (
{hasPrev && scale === 1 && !IS_TOUCH_ENV && (
<button
type="button"
className={`navigation prev ${isVideo && !isGif && 'inline'}`}
@ -711,7 +709,7 @@ const MediaViewerSlides: FC<OwnProps> = ({
dir={lang.isRtl ? 'rtl' : undefined}
/>
)}
{nextMessageId && scale === 1 && !IS_TOUCH_ENV && (
{hasNext && scale === 1 && !IS_TOUCH_ENV && (
<button
type="button"
className={`navigation next ${isVideo && !isGif && 'inline'}`}

View File

@ -64,6 +64,7 @@
width: 3.25rem;
height: 3.25rem;
background-color: rgba(0, 0, 0, 0.5) !important;
z-index: 3;
body:not(.animation-level-0) & {
transition: opacity 0.3s ease !important;
}

View File

@ -12,14 +12,14 @@ import useShowTransition from '../../hooks/useShowTransition';
import useVideoCleanup from '../../hooks/useVideoCleanup';
import { IS_IOS, IS_SINGLE_COLUMN_LAYOUT, IS_TOUCH_ENV } from '../../util/environment';
import safePlay from '../../util/safePlay';
import stopEvent from '../../util/stopEvent';
import Button from '../ui/Button';
import ProgressSpinner from '../ui/ProgressSpinner';
import VideoPlayerControls from './VideoPlayerControls';
import './VideoPlayer.scss';
import VideoPlayerControls from './VideoPlayerControls';
type OwnProps = {
url?: string;
isGif?: boolean;
@ -33,6 +33,7 @@ type OwnProps = {
volume: number;
isMuted: boolean;
playbackRate: number;
isProtected?: boolean;
toggleControls: (isVisible: boolean) => void;
onClose: (e: React.MouseEvent<HTMLElement, MouseEvent>) => void;
};
@ -54,6 +55,7 @@ const VideoPlayer: FC<OwnProps> = ({
onClose,
toggleControls,
areControlsVisible,
isProtected,
}) => {
const {
setMediaViewerVolume,
@ -181,9 +183,18 @@ const VideoPlayer: FC<OwnProps> = ({
style={wrapperStyle}
>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
{isProtected && (
<div
onContextMenu={stopEvent}
onDoubleClick={!IS_TOUCH_ENV ? handleFullscreenChange : undefined}
onClick={!IS_SINGLE_COLUMN_LAYOUT ? togglePlayState : undefined}
className="protector"
/>
)}
<video
ref={videoRef}
autoPlay={IS_TOUCH_ENV}
controlsList={isProtected ? 'nodownload' : undefined}
playsInline
loop={isGif}
// This is to force auto playing on mobiles

View File

@ -9,6 +9,8 @@
font-size: 0.875rem;
background: linear-gradient(to top, #000 0%, rgba(0, 0, 0, 0) 100%);
transition: opacity 0.3s;
z-index: var(--z-video-player-controls);
opacity: 0;
pointer-events: none;
@ -20,7 +22,6 @@
position: fixed;
padding: 2.25rem 0.5rem 0.75rem;
background: none;
z-index: var(--z-media-viewer);
}
&.active {
@ -147,8 +148,16 @@
}
}
.playback-rate-menu .bubble {
min-width: 4rem;
margin-right: 4rem;
.playback-rate-menu {
.bubble {
min-width: 3.5rem;
margin-right: 3.5rem;
bottom: 4.1875rem
}
&.no-fullscreen {
.bubble {
margin-right: 0.8125rem;
}
}
}
}

View File

@ -218,7 +218,7 @@ const VideoPlayerControls: FC<OwnProps> = ({
</div>
<Menu
isOpen={isPlaybackMenuOpen}
className="playback-rate-menu"
className={buildClassName('playback-rate-menu', !isFullscreenSupported && 'no-fullscreen')}
positionX="right"
positionY="bottom"
autoClose

View File

@ -0,0 +1,155 @@
import type {
ApiMessage, ApiChat, ApiUser, ApiDimensions,
} from '../../../api/types';
import { ApiMediaFormat } from '../../../api/types';
import {
getVideoAvatarMediaHash,
getChatAvatarHash,
getMessageMediaHash,
getMessagePhoto,
getMessageVideo,
getMessageWebPagePhoto,
getMessageWebPageVideo,
isMessageDocumentPhoto,
isMessageDocumentVideo,
getMessageMediaFormat,
getMessageMediaThumbDataUri,
getMessageFileName,
getMessageDocument,
getPhotoFullDimensions,
getVideoDimensions,
getMessageFileSize,
} from '../../../global/helpers';
import { useMemo } from '../../../lib/teact/teact';
import useMedia from '../../../hooks/useMedia';
import useMediaWithLoadProgress from '../../../hooks/useMediaWithLoadProgress';
import useBlurSync from '../../../hooks/useBlurSync';
import { MediaViewerOrigin } from '../../../types';
import { VIDEO_AVATAR_FULL_DIMENSIONS, AVATAR_FULL_DIMENSIONS } from '../../common/helpers/mediaDimensions';
type UseMediaProps = {
mediaId?: number;
message?: ApiMessage;
avatarOwner?: ApiChat | ApiUser;
origin?: MediaViewerOrigin;
lastSyncTime?: number;
delay: number | false;
};
export const useMediaProps = ({
message,
mediaId = 0,
avatarOwner,
origin,
delay,
}: UseMediaProps) => {
const photo = message ? getMessagePhoto(message) : undefined;
const video = message ? getMessageVideo(message) : undefined;
const webPagePhoto = message ? getMessageWebPagePhoto(message) : undefined;
const webPageVideo = message ? getMessageWebPageVideo(message) : undefined;
const isDocumentPhoto = message ? isMessageDocumentPhoto(message) : false;
const isDocumentVideo = message ? isMessageDocumentVideo(message) : false;
const videoSize = message ? getMessageFileSize(message) : undefined;
const avatarMedia = avatarOwner?.photos?.[mediaId];
const isVideoAvatar = Boolean(avatarMedia?.isVideo);
const isVideo = Boolean(video || webPageVideo || isDocumentVideo);
const isPhoto = Boolean(!isVideo && (photo || webPagePhoto || isDocumentPhoto));
const { isGif } = video || webPageVideo || {};
const isFromSharedMedia = origin === MediaViewerOrigin.SharedMedia;
const isFromSearch = origin === MediaViewerOrigin.SearchResult;
const getMediaHash = useMemo(() => (isFull?: boolean) => {
if (avatarOwner) {
if (avatarMedia) {
if (avatarMedia.isVideo && isFull) {
return getVideoAvatarMediaHash(avatarMedia);
} else {
return `photo${avatarMedia.id}?size=c`;
}
} else {
return getChatAvatarHash(avatarOwner!, isFull ? 'big' : 'normal');
}
}
return message && getMessageMediaHash(message, isFull ? 'viewerFull' : 'viewerPreview');
}, [avatarOwner, avatarMedia, message]);
const pictogramBlobUrl = useMedia(
message && (isFromSharedMedia || isFromSearch) && getMessageMediaHash(message, 'pictogram'),
undefined,
ApiMediaFormat.BlobUrl,
undefined,
delay,
);
const previewMediaHash = getMediaHash();
const previewBlobUrl = useMedia(
previewMediaHash,
undefined,
ApiMediaFormat.BlobUrl,
undefined,
delay,
);
const {
mediaData: fullMediaBlobUrl,
loadProgress,
} = useMediaWithLoadProgress(
getMediaHash(true),
undefined,
message && getMessageMediaFormat(message, 'viewerFull'),
undefined,
delay,
);
const localBlobUrl = (photo || video) ? (photo || video)!.blobUrl : undefined;
let bestImageData = (!isVideo && (localBlobUrl || fullMediaBlobUrl)) || previewBlobUrl || pictogramBlobUrl;
const thumbDataUri = useBlurSync(!bestImageData && message && getMessageMediaThumbDataUri(message));
if (!bestImageData && origin !== MediaViewerOrigin.SearchResult) {
bestImageData = thumbDataUri;
}
if (isVideoAvatar && previewBlobUrl) {
bestImageData = previewBlobUrl;
}
const fileName = message
? getMessageFileName(message)
: avatarOwner
? `avatar${avatarOwner!.id}.${avatarOwner?.hasVideoAvatar ? 'mp4' : 'jpg'}`
: undefined;
let dimensions!: ApiDimensions;
if (message) {
if (isDocumentPhoto || isDocumentVideo) {
dimensions = getMessageDocument(message)!.mediaSize!;
} else if (photo || webPagePhoto) {
dimensions = getPhotoFullDimensions((photo || webPagePhoto)!)!;
} else if (video || webPageVideo) {
dimensions = getVideoDimensions((video || webPageVideo)!)!;
}
} else {
dimensions = isVideoAvatar ? VIDEO_AVATAR_FULL_DIMENSIONS : AVATAR_FULL_DIMENSIONS;
}
return {
getMediaHash,
photo,
video,
webPagePhoto,
webPageVideo,
isVideo,
isPhoto,
isGif,
isDocumentPhoto,
isDocumentVideo,
fileName,
bestImageData,
dimensions,
isFromSharedMedia,
avatarPhoto: avatarMedia,
isVideoAvatar,
localBlobUrl,
fullMediaBlobUrl,
previewBlobUrl,
pictogramBlobUrl,
loadProgress,
videoSize,
};
};

View File

@ -75,7 +75,10 @@ export default function useInnerHandlers(
const handleMediaClick = useCallback((): void => {
openMediaViewer({
chatId, threadId, messageId, origin: isScheduled ? MediaViewerOrigin.ScheduledInline : MediaViewerOrigin.Inline,
chatId,
threadId,
mediaId: messageId,
origin: isScheduled ? MediaViewerOrigin.ScheduledInline : MediaViewerOrigin.Inline,
});
}, [chatId, threadId, messageId, openMediaViewer, isScheduled]);
@ -87,7 +90,7 @@ export default function useInnerHandlers(
openMediaViewer({
chatId,
threadId,
messageId: albumMessageId,
mediaId: albumMessageId,
origin: isScheduled ? MediaViewerOrigin.ScheduledAlbum : MediaViewerOrigin.Album,
});
}, [chatId, threadId, openMediaViewer, isScheduled]);

View File

@ -225,11 +225,11 @@ const Profile: FC<OwnProps & StateProps> = ({
}
}, [loadProfilePhotos, profileId, lastSyncTime]);
const handleSelectMedia = useCallback((messageId: number) => {
const handleSelectMedia = useCallback((mediaId: number) => {
openMediaViewer({
chatId: profileId,
threadId: MAIN_THREAD_ID,
messageId,
mediaId,
origin: MediaViewerOrigin.SharedMedia,
});
}, [profileId, openMediaViewer]);

View File

@ -2,7 +2,7 @@ import { addActionHandler } from '../../index';
addActionHandler('openMediaViewer', (global, actions, payload) => {
const {
chatId, threadId, messageId, avatarOwnerId, profilePhotoIndex, origin, volume, playbackRate, isMuted,
chatId, threadId, mediaId, avatarOwnerId, profilePhotoIndex, origin, volume, playbackRate, isMuted,
} = payload;
return {
@ -11,7 +11,7 @@ addActionHandler('openMediaViewer', (global, actions, payload) => {
...global.mediaViewer,
chatId,
threadId,
messageId,
mediaId,
avatarOwnerId,
profilePhotoIndex,
origin,

View File

@ -10,7 +10,7 @@ import { selectCurrentManagement } from './management';
export function selectIsMediaViewerOpen(global: GlobalState) {
const { mediaViewer } = global;
return Boolean(mediaViewer.messageId || mediaViewer.avatarOwnerId);
return Boolean(mediaViewer.mediaId || mediaViewer.avatarOwnerId);
}
export function selectRightColumnContentKey(global: GlobalState) {

View File

@ -399,7 +399,7 @@ export type GlobalState = {
mediaViewer: {
chatId?: string;
threadId?: number;
messageId?: number;
mediaId?: number;
avatarOwnerId?: string;
profilePhotoIndex?: number;
origin?: MediaViewerOrigin;
@ -750,7 +750,7 @@ export interface ActionPayloads {
openMediaViewer: {
chatId?: string;
threadId?: number;
messageId?: number;
mediaId?: number;
avatarOwnerId?: string;
profilePhotoIndex?: number;
origin?: MediaViewerOrigin;

View File

@ -211,6 +211,7 @@ $color-message-reaction-own-hover: #b5e0a4;
--z-header-menu-backdrop: 980;
--z-modal: 1510;
--z-media-viewer: 1500;
--z-video-player-controls: 3;
--z-drop-area: 55;
--z-animation-fade: 50;
--z-menu-bubble: 21;

View File

@ -265,6 +265,8 @@ div[role="button"] {
right: 0;
bottom: 0;
z-index: 2;
user-select: none;
-webkit-touch-callout: none;
}
.for-ios-autocapitalization-fix {