From fc605350ea2ca7924a6f150752ef34d3ab995d7c Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Mon, 14 Aug 2023 11:17:40 +0200 Subject: [PATCH] Introduce Stories (#3154) --- src/api/gramjs/apiBuilders/appConfig.ts | 16 +- src/api/gramjs/apiBuilders/messages.ts | 218 +- src/api/gramjs/apiBuilders/misc.ts | 43 +- src/api/gramjs/apiBuilders/users.ts | 10 +- src/api/gramjs/gramjsBuilders/index.ts | 93 +- src/api/gramjs/helpers.ts | 31 + src/api/gramjs/localDb.ts | 14 +- src/api/gramjs/methods/bots.ts | 13 +- src/api/gramjs/methods/client.ts | 37 +- src/api/gramjs/methods/index.ts | 7 + src/api/gramjs/methods/media.ts | 21 +- src/api/gramjs/methods/messages.ts | 28 +- src/api/gramjs/methods/settings.ts | 5 +- src/api/gramjs/methods/stories.ts | 343 ++ src/api/gramjs/methods/users.ts | 8 + src/api/gramjs/updater.ts | 40 +- src/api/gramjs/worker/connector.ts | 1 - src/api/types/index.ts | 1 + src/api/types/messages.ts | 19 + src/api/types/misc.ts | 6 +- src/api/types/stories.ts | 59 + src/api/types/updates.ts | 22 +- src/api/types/users.ts | 7 + src/assets/fonts/icomoon.woff | Bin 58972 -> 62616 bytes src/assets/fonts/icomoon.woff2 | Bin 27132 -> 28636 bytes src/bundles/extra.ts | 1 + .../calls/phone/PhoneCall.module.scss | 2 + src/components/common/Avatar.scss | 79 +- src/components/common/Avatar.tsx | 41 +- src/components/common/AvatarStoryCircle.tsx | 199 + src/components/common/ChatExtra.tsx | 2 + src/components/common/ChatOrUserPicker.tsx | 5 +- .../{middle/composer => common}/Composer.scss | 115 +- .../{middle/composer => common}/Composer.tsx | 726 ++-- src/components/common/CustomEmojiPicker.tsx | 5 +- src/components/common/EmbeddedMessage.scss | 10 + src/components/common/EmbeddedStory.tsx | 118 + src/components/common/InviteLink.tsx | 4 +- src/components/common/MessageSummary.tsx | 2 +- src/components/common/MessageText.tsx | 12 +- src/components/common/Picker.tsx | 5 +- src/components/common/PickerSelectedItem.tsx | 18 +- src/components/common/PrivateChatInfo.tsx | 8 + src/components/common/RecipientPicker.tsx | 3 + src/components/common/ReportModal.tsx | 15 +- src/components/common/StickerSet.tsx | 7 +- src/components/common/spoiler/Spoiler.scss | 3 +- src/components/left/ArchivedChats.scss | 34 +- src/components/left/ArchivedChats.tsx | 38 +- src/components/left/LeftColumn.tsx | 7 + src/components/left/main/Chat.scss | 25 +- src/components/left/main/Chat.tsx | 5 +- src/components/left/main/ChatFolders.tsx | 46 +- src/components/left/main/ChatList.tsx | 26 +- src/components/left/main/ContactList.tsx | 5 +- src/components/left/main/LeftMain.scss | 17 + src/components/left/main/LeftMainHeader.tsx | 13 + .../left/main/StatusPickerMenu.module.scss | 2 +- .../left/main/hooks/useChatListEntry.tsx | 1 + src/components/left/search/LeftSearch.scss | 4 + .../left/search/LeftSearchResultChat.tsx | 2 +- .../main/ForwardRecipientPicker.tsx | 55 +- src/components/main/Main.scss | 17 +- src/components/main/Main.tsx | 15 +- src/components/mediaViewer/SeekLine.tsx | 2 +- src/components/mediaViewer/VideoPlayer.tsx | 2 +- .../mediaViewer/VideoPlayerControls.tsx | 2 +- src/components/middle/MessageList.tsx | 25 +- src/components/middle/MiddleColumn.scss | 4 +- src/components/middle/MiddleColumn.tsx | 8 +- src/components/middle/MiddleHeader.scss | 15 +- src/components/middle/MiddleHeader.tsx | 1 + src/components/middle/composer/AttachMenu.tsx | 38 +- .../middle/composer/AttachmentModal.tsx | 14 +- .../composer/ComposerEmbeddedMessage.tsx | 10 +- .../middle/composer/CustomSendMenu.tsx | 6 +- .../middle/composer/MessageInput.tsx | 18 +- .../middle/composer/StickerPicker.tsx | 9 +- .../middle/composer/SymbolMenu.scss | 5 +- src/components/middle/composer/SymbolMenu.tsx | 29 +- .../middle/composer/SymbolMenuButton.tsx | 18 +- .../middle/composer/SymbolMenuFooter.tsx | 5 +- .../composer/hooks/useCustomEmojiTooltip.ts | 4 +- .../middle/composer/hooks/useDraft.ts | 19 +- .../composer/hooks/useStickerTooltip.ts | 4 +- .../composer/hooks/useVoiceRecording.ts | 5 +- .../middle/message/BaseStory.module.scss | 53 + src/components/middle/message/BaseStory.tsx | 108 + .../middle/message/ContextMenuContainer.tsx | 12 +- src/components/middle/message/MentionLink.tsx | 2 + src/components/middle/message/Message.scss | 82 + src/components/middle/message/Message.tsx | 81 +- .../middle/message/MessageContextMenu.tsx | 4 +- .../middle/message/ReactionPicker.async.tsx | 10 +- .../middle/message/ReactionPicker.module.scss | 21 +- .../middle/message/ReactionPicker.tsx | 102 +- .../middle/message/ReactionSelector.scss | 2 +- src/components/middle/message/Story.tsx | 43 + .../middle/message/StoryMention.tsx | 102 + src/components/middle/message/WebPage.scss | 5 + src/components/middle/message/WebPage.tsx | 22 +- .../middle/message/_message-content.scss | 4 + .../message/helpers/buildContentClassName.ts | 8 +- .../middle/message/helpers/webpageType.ts | 2 + .../middle/message/hooks/useInnerHandlers.ts | 27 +- .../middle/message/hooks/useVideoAutoPause.ts | 12 +- src/components/right/Profile.scss | 2 + src/components/right/Profile.tsx | 94 +- src/components/right/RightColumn.tsx | 24 +- src/components/right/RightHeader.tsx | 11 +- src/components/right/hooks/useProfileState.ts | 8 +- .../right/hooks/useProfileViewportIds.ts | 30 +- src/components/story/MediaStory.module.scss | 31 + src/components/story/MediaStory.tsx | 156 + src/components/story/Story.tsx | 789 ++++ src/components/story/StoryCaption.tsx | 98 + .../story/StoryDeleteConfirmModal.tsx | 44 + src/components/story/StoryPreview.tsx | 92 + .../story/StoryProgress.module.scss | 43 + src/components/story/StoryProgress.tsx | 50 + src/components/story/StoryRibbon.module.scss | 76 + src/components/story/StoryRibbon.tsx | 78 + src/components/story/StoryRibbonButton.tsx | 156 + .../story/StorySettings.module.scss | 191 + src/components/story/StorySettings.tsx | 409 ++ src/components/story/StorySlides.tsx | 319 ++ src/components/story/StoryToggler.module.scss | 35 + src/components/story/StoryToggler.tsx | 94 + src/components/story/StoryViewer.async.tsx | 17 + src/components/story/StoryViewer.module.scss | 703 ++++ src/components/story/StoryViewer.tsx | 146 + src/components/story/StoryViewers.tsx | 146 + src/components/story/helpers/dimensions.ts | 57 + .../story/hooks/useStoryPreloader.ts | 101 + .../story/privacy/AllowDenyList.tsx | 53 + .../story/privacy/CloseFriends.module.scss | 13 + src/components/story/privacy/CloseFriends.tsx | 91 + src/components/ui/ConfirmDialog.tsx | 5 +- src/components/ui/InfiniteScroll.tsx | 6 + src/components/ui/ListItem.scss | 17 +- src/components/ui/Loading.tsx | 6 +- src/components/ui/Menu.scss | 1 + src/components/ui/Modal.scss | 1 + src/components/ui/Notification.scss | 2 +- src/components/ui/Notification.tsx | 11 +- src/components/ui/OptimizedVideo.tsx | 30 +- src/components/ui/SearchInput.scss | 36 +- src/components/ui/SearchInput.tsx | 20 +- src/components/ui/Transition.tsx | 3 + src/config.ts | 7 +- src/global/actions/all.ts | 2 + src/global/actions/api/bots.ts | 6 +- src/global/actions/api/chats.ts | 35 +- src/global/actions/api/localSearch.ts | 3 +- src/global/actions/api/messages.ts | 104 +- src/global/actions/api/stories.ts | 392 ++ src/global/actions/api/sync.ts | 6 +- src/global/actions/api/users.ts | 26 + src/global/actions/apiUpdaters/messages.ts | 3 +- src/global/actions/apiUpdaters/misc.ts | 26 +- src/global/actions/apiUpdaters/users.ts | 10 +- src/global/actions/ui/chats.ts | 3 +- src/global/actions/ui/messages.ts | 3 +- src/global/actions/ui/misc.ts | 11 + src/global/actions/ui/reactions.ts | 21 +- src/global/actions/ui/stories.ts | 354 ++ src/global/cache.ts | 10 +- src/global/helpers/chats.ts | 32 +- src/global/helpers/media.ts | 25 +- src/global/helpers/messageSummary.ts | 16 + src/global/helpers/messages.ts | 24 +- src/global/helpers/users.ts | 6 +- src/global/init.ts | 2 + src/global/initialState.ts | 15 + src/global/intervals.ts | 58 + src/global/reducers/index.ts | 1 + src/global/reducers/stories.ts | 408 ++ src/global/reducers/users.ts | 11 + src/global/selectors/index.ts | 1 + src/global/selectors/messages.ts | 31 +- src/global/selectors/stories.ts | 63 + src/global/types.ts | 156 +- src/hooks/polling/useUserStoriesPolling.ts | 58 + src/hooks/scroll/useTopOverscroll.tsx | 119 + .../hooks/useCurrentTimeSignal.ts | 4 +- src/hooks/useEnsureStory.ts | 32 + src/hooks/useLongPress.ts | 51 + src/hooks/useMediaTransition.ts | 2 +- src/hooks/useMenuPosition.ts | 4 +- src/hooks/useShowTransition.ts | 1 + src/lib/fasterdom/stricterdom.ts | 26 + src/lib/gramjs/client/TelegramClient.js | 12 +- src/lib/gramjs/tl/AllTLObjects.js | 2 +- src/lib/gramjs/tl/api.d.ts | 593 ++- src/lib/gramjs/tl/apiTl.js | 83 +- src/lib/gramjs/tl/static/api.json | 18 +- src/lib/gramjs/tl/static/api.tl | 104 +- src/styles/Telegram T.json | 3625 +++++++++-------- src/styles/_common.scss | 5 + src/styles/_variables.scss | 18 +- src/styles/icons.scss | 194 +- src/styles/index.scss | 65 + src/styles/themes.json | 3 +- src/types/index.ts | 8 +- src/util/animation.ts | 18 +- src/util/dateFormat.ts | 27 + src/util/focusEditableElement.ts | 3 +- src/util/iteratees.ts | 12 + src/util/resetScroll.ts | 3 +- src/util/windowEnvironment.ts | 5 +- src/util/windowSize.ts | 6 +- 211 files changed, 12304 insertions(+), 2642 deletions(-) create mode 100644 src/api/gramjs/methods/stories.ts create mode 100644 src/api/types/stories.ts create mode 100644 src/components/common/AvatarStoryCircle.tsx rename src/components/{middle/composer => common}/Composer.scss (84%) rename src/components/{middle/composer => common}/Composer.tsx (69%) create mode 100644 src/components/common/EmbeddedStory.tsx create mode 100644 src/components/middle/message/BaseStory.module.scss create mode 100644 src/components/middle/message/BaseStory.tsx create mode 100644 src/components/middle/message/Story.tsx create mode 100644 src/components/middle/message/StoryMention.tsx create mode 100644 src/components/story/MediaStory.module.scss create mode 100644 src/components/story/MediaStory.tsx create mode 100644 src/components/story/Story.tsx create mode 100644 src/components/story/StoryCaption.tsx create mode 100644 src/components/story/StoryDeleteConfirmModal.tsx create mode 100644 src/components/story/StoryPreview.tsx create mode 100644 src/components/story/StoryProgress.module.scss create mode 100644 src/components/story/StoryProgress.tsx create mode 100644 src/components/story/StoryRibbon.module.scss create mode 100644 src/components/story/StoryRibbon.tsx create mode 100644 src/components/story/StoryRibbonButton.tsx create mode 100644 src/components/story/StorySettings.module.scss create mode 100644 src/components/story/StorySettings.tsx create mode 100644 src/components/story/StorySlides.tsx create mode 100644 src/components/story/StoryToggler.module.scss create mode 100644 src/components/story/StoryToggler.tsx create mode 100644 src/components/story/StoryViewer.async.tsx create mode 100644 src/components/story/StoryViewer.module.scss create mode 100644 src/components/story/StoryViewer.tsx create mode 100644 src/components/story/StoryViewers.tsx create mode 100644 src/components/story/helpers/dimensions.ts create mode 100644 src/components/story/hooks/useStoryPreloader.ts create mode 100644 src/components/story/privacy/AllowDenyList.tsx create mode 100644 src/components/story/privacy/CloseFriends.module.scss create mode 100644 src/components/story/privacy/CloseFriends.tsx create mode 100644 src/global/actions/api/stories.ts create mode 100644 src/global/actions/ui/stories.ts create mode 100644 src/global/intervals.ts create mode 100644 src/global/reducers/stories.ts create mode 100644 src/global/selectors/stories.ts create mode 100644 src/hooks/polling/useUserStoriesPolling.ts create mode 100644 src/hooks/scroll/useTopOverscroll.tsx rename src/{components/mediaViewer => }/hooks/useCurrentTimeSignal.ts (69%) create mode 100644 src/hooks/useEnsureStory.ts create mode 100644 src/hooks/useLongPress.ts diff --git a/src/api/gramjs/apiBuilders/appConfig.ts b/src/api/gramjs/apiBuilders/appConfig.ts index 5edac2d27..343037000 100644 --- a/src/api/gramjs/apiBuilders/appConfig.ts +++ b/src/api/gramjs/apiBuilders/appConfig.ts @@ -5,7 +5,12 @@ import { Api as GramJs } from '../../../lib/gramjs'; import type { ApiAppConfig } from '../../types'; import type { ApiLimitType } from '../../../global/types'; import { buildJson } from './misc'; -import { DEFAULT_LIMITS } from '../../../config'; +import { + DEFAULT_LIMITS, + SERVICE_NOTIFICATIONS_USER_ID, + STORY_EXPIRE_PERIOD, + STORY_VIEWERS_EXPIRE_PERIOD, +} from '../../../config'; type LimitType = 'default' | 'premium'; type Limit = 'upload_max_fileparts' | 'stickers_faved_limit' | 'saved_gifs_limit' | 'dialog_filters_chats_limit' | @@ -39,6 +44,11 @@ export interface GramJsAppConfig extends LimitsConfig { autoarchive_setting_available: boolean; // Forums topics_pinned_limit: number; + // Stories + stories_all_hidden?: boolean; + story_expire_period: number; + story_viewers_expire_period: number; + stories_changelog_user_id?: number; } function buildEmojiSounds(appConfig: GramJsAppConfig) { @@ -100,5 +110,9 @@ export function buildAppConfig(json: GramJs.TypeJSONValue, hash: number): ApiApp chatlistJoined: getLimit(appConfig, 'chatlist_joined_limit', 'chatlistJoined'), }, hash, + areStoriesHidden: appConfig.stories_all_hidden, + storyExpirePeriod: appConfig.story_expire_period ?? STORY_EXPIRE_PERIOD, + storyViewersExpirePeriod: appConfig.story_viewers_expire_period ?? STORY_VIEWERS_EXPIRE_PERIOD, + storyChangelogUserId: appConfig.stories_changelog_user_id?.toString() ?? SERVICE_NOTIFICATIONS_USER_ID, }; } diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index 73dcb2359..b8beca76a 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -36,10 +36,17 @@ import type { ApiMessageExtendedMediaPreview, ApiReaction, ApiReactionEmoji, + ApiTypeReplyTo, + ApiStory, + ApiStorySkipped, + ApiWebPageStoryData, + ApiMessageStoryData, + ApiTypeStory, } from '../../types'; import { ApiMessageEntityTypes, } from '../../types'; +import type { ApiPrivacySettings, PrivacyVisibility } from '../../../types'; import { DELETED_COMMENTS_CHANNEL_ID, @@ -50,14 +57,19 @@ import { SUPPORTED_VIDEO_CONTENT_TYPES, VIDEO_WEBM_TYPE, } from '../../../config'; -import { pick } from '../../../util/iteratees'; +import { buildCollectionByCallback, pick } from '../../../util/iteratees'; import { buildStickerFromDocument } from './symbols'; import { buildApiPhoto, buildApiPhotoSize, buildApiThumbnailFromPath, buildApiThumbnailFromStripped, } from './common'; import { interpolateArray } from '../../../util/waveform'; import { buildPeer } from '../gramjsBuilders'; -import { addPhotoToLocalDb, resolveMessageApiChatId, serializeBytes } from '../helpers'; +import { + addPhotoToLocalDb, + addStoryToLocalDb, + resolveMessageApiChatId, + serializeBytes, +} from '../helpers'; import { buildApiPeerId, getApiChatIdFromMtpPeer, isPeerUser } from './peers'; import { buildApiCallDiscardReason } from './calls'; import { getEmojiOnlyCountForMessage } from '../../../global/helpers/getEmojiOnlyCountForMessage'; @@ -181,9 +193,23 @@ export function buildApiMessageWithChatId( const isInvoiceMedia = mtpMessage.media instanceof GramJs.MessageMediaInvoice && Boolean(mtpMessage.media.extendedMedia); - const { - replyToMsgId, replyToTopId, forumTopic, replyToPeerId, - } = mtpMessage.replyTo || {}; + let replyToMsgId: number | undefined; + let replyToTopId: number | undefined; + let replyToStoryUserId: string | undefined; + let replyToStoryId: number | undefined; + let forumTopic: boolean | undefined; + let replyToPeerId: GramJs.TypePeer | undefined; + if (mtpMessage.replyTo instanceof GramJs.MessageReplyHeader) { + replyToMsgId = mtpMessage.replyTo.replyToMsgId; + replyToTopId = mtpMessage.replyTo.replyToTopId; + forumTopic = mtpMessage.replyTo.forumTopic; + replyToPeerId = mtpMessage.replyTo.replyToPeerId; + } + if (mtpMessage.replyTo instanceof GramJs.MessageReplyStoryHeader) { + replyToStoryUserId = buildApiPeerId(mtpMessage.replyTo.userId, 'user'); + replyToStoryId = mtpMessage.replyTo.storyId; + } + const isEdited = mtpMessage.editDate && !mtpMessage.editHide; const { inlineButtons, keyboardButtons, keyboardPlaceholder, isKeyboardSingleUse, isKeyboardSelective, @@ -219,6 +245,7 @@ export function buildApiMessageWithChatId( ...(replyToPeerId && { replyToChatId: getApiChatIdFromMtpPeer(replyToPeerId) }), ...(replyToTopId && { replyToTopMessageId: replyToTopId }), ...(forwardInfo && { forwardInfo }), + ...(replyToStoryUserId && { replyToStoryUserId, replyToStoryId }), ...(isEdited && { isEdited }), ...(mtpMessage.editDate && { editDate: mtpMessage.editDate }), ...(isMediaUnread && { isMediaUnread }), @@ -399,7 +426,8 @@ export function buildMessageMediaContent(media: GramJs.TypeMessageMedia): ApiMes if (photo) return { photo }; const video = buildVideo(media); - if (video) return { video }; + const altVideo = buildAltVideo(media); + if (video) return { video, altVideo }; const audio = buildAudio(media); if (audio) return { audio }; @@ -428,6 +456,9 @@ export function buildMessageMediaContent(media: GramJs.TypeMessageMedia): ApiMes const game = buildGameFromMedia(media); if (game) return { game }; + const storyData = buildMessageStoryData(media); + if (storyData) return { storyData }; + return undefined; } @@ -499,6 +530,7 @@ export function buildVideoFromDocument(document: GramJs.Document, isSpoiler?: bo h: height, supportsStreaming = false, roundMessage: isRound = false, + nosound, } = videoAttr; return { @@ -514,6 +546,7 @@ export function buildVideoFromDocument(document: GramJs.Document, isSpoiler?: bo thumbnail: buildApiThumbnailFromStripped(thumbs), size: size.toJSNumber(), isSpoiler, + ...(nosound && { noSound: true }), }; } @@ -529,6 +562,18 @@ function buildVideo(media: GramJs.TypeMessageMedia): ApiVideo | undefined { return buildVideoFromDocument(media.document, media.spoiler); } +function buildAltVideo(media: GramJs.TypeMessageMedia): ApiVideo | undefined { + if ( + !(media instanceof GramJs.MessageMediaDocument) + || !(media.altDocument instanceof GramJs.Document) + || !media.altDocument.mimeType.startsWith('video') + ) { + return undefined; + } + + return buildVideoFromDocument(media.altDocument, media.spoiler); +} + function buildAudio(media: GramJs.TypeMessageMedia): ApiAudio | undefined { if ( !(media instanceof GramJs.MessageMediaDocument) @@ -856,12 +901,28 @@ export function buildWebPage(media: GramJs.TypeMessageMedia): ApiWebPage | undef return undefined; } - const { id, photo, document } = media.webpage; + const { + id, photo, document, attributes, + } = media.webpage; let video; if (document instanceof GramJs.Document && document.mimeType.startsWith('video/')) { video = buildVideoFromDocument(document); } + let story: ApiWebPageStoryData | undefined; + const attributeStory = attributes + ?.find((a: any): a is GramJs.WebPageAttributeStory => a instanceof GramJs.WebPageAttributeStory); + if (attributeStory) { + const userId = buildApiPeerId(attributeStory.userId, 'user'); + story = { + id: attributeStory.id, + userId, + }; + + if (attributeStory.story instanceof GramJs.StoryItem) { + addStoryToLocalDb(attributeStory.story, userId); + } + } return { id: Number(id), @@ -877,9 +938,20 @@ export function buildWebPage(media: GramJs.TypeMessageMedia): ApiWebPage | undef photo: photo instanceof GramJs.Photo ? buildApiPhoto(photo) : undefined, document: !video && document ? buildApiDocument(document) : undefined, video, + story, }; } +function buildMessageStoryData(media: GramJs.TypeMessageMedia): ApiMessageStoryData | undefined { + if (!(media instanceof GramJs.MessageMediaStory)) { + return undefined; + } + + const userId = buildApiPeerId(media.userId, 'user'); + + return { id: media.id, userId, ...(media.viaMention && { isMention: true }) }; +} + function buildAction( action: GramJs.TypeMessageAction, senderId: string | undefined, @@ -1284,8 +1356,7 @@ export function buildLocalMessage( chat: ApiChat, text?: string, entities?: ApiMessageEntity[], - replyingTo?: number, - replyingToTopId?: number, + replyingTo?: ApiTypeReplyTo, attachment?: ApiAttachment, sticker?: ApiSticker, gif?: ApiVideo, @@ -1294,12 +1365,27 @@ export function buildLocalMessage( groupedId?: string, scheduledAt?: number, sendAs?: ApiChat | ApiUser, + story?: ApiStory | ApiStorySkipped, ): ApiMessage { const localId = getNextLocalMessageId(chat.lastMessage?.id); const media = attachment && buildUploadingMedia(attachment); const isChannel = chat.type === 'chatTypeChannel'; const isForum = chat.isForum; + let replyToMessageId: number | undefined; + let replyingToTopId: number | undefined; + let replyToStoryUserId: string | undefined; + let replyToStoryId: number | undefined; + if (replyingTo) { + if ('replyingTo' in replyingTo) { + replyToMessageId = replyingTo.replyingTo; + replyingToTopId = replyingTo.replyingToTopId; + } else { + replyToStoryUserId = replyingTo.userId; + replyToStoryId = replyingTo.storyId; + } + } + const message = { id: localId, chatId: chat.id, @@ -1315,13 +1401,15 @@ export function buildLocalMessage( ...(gif && { video: gif }), ...(poll && buildNewPoll(poll, localId)), ...(contact && { contact }), + ...(story && { storyData: story }), }, date: scheduledAt || Math.round(Date.now() / 1000) + getServerTimeOffset(), isOutgoing: !isChannel, senderId: sendAs?.id || currentUserId, - ...(replyingTo && { replyToMessageId: replyingTo }), + ...(replyToMessageId && { replyToMessageId }), ...(replyingToTopId && { replyToTopMessageId: replyingToTopId }), - ...((replyingTo || replyingToTopId) && isForum && { isTopicReply: true }), + ...((replyToMessageId || replyingToTopId) && isForum && { isTopicReply: true }), + ...(replyToStoryUserId && { replyToStoryUserId, replyToStoryId }), ...(groupedId && { groupedId, ...(media && (media.photo || media.video) && { isInAlbum: true }), @@ -1627,3 +1715,111 @@ export function buildApiFormattedText(textWithEntities: GramJs.TextWithEntities) entities: entities.map(buildApiMessageEntity), }; } + +export function buildApiUsersStories(userStories: GramJs.UserStories) { + const userId = buildApiPeerId(userStories.userId, 'user'); + + return buildCollectionByCallback(userStories.stories, (story) => [story.id, buildApiStory(userId, story)]); +} + +export function buildApiStory(userId: string, story: GramJs.TypeStoryItem): ApiTypeStory { + if (story instanceof GramJs.StoryItemDeleted) { + return { + id: story.id, + userId, + isDeleted: true, + }; + } + + if (story instanceof GramJs.StoryItemSkipped) { + const { + id, date, expireDate, closeFriends, + } = story; + + return { + id, + userId, + ...(closeFriends && { isForCloseFriends: true }), + date, + expireDate, + }; + } + + const { + edited, pinned, expireDate, id, date, caption, + entities, media, privacy, views, + public: isPublic, noforwards, closeFriends, contacts, selectedContacts, + } = story; + + const content: ApiMessage['content'] = { + ...buildMessageMediaContent(media), + }; + + if (caption) { + content.text = buildMessageTextContent(caption, entities); + } + + return { + id, + userId, + date, + expireDate, + content, + ...(isPublic && { isPublic }), + ...(edited && { isEdited: true }), + ...(pinned && { isPinned: true }), + ...(contacts && { isForContacts: true }), + ...(selectedContacts && { isForSelectedContacts: true }), + ...(closeFriends && { isForCloseFriends: true }), + ...(noforwards && { noForwards: true }), + ...(views?.viewsCount && { viewsCount: views.viewsCount }), + ...(views?.recentViewers && { + recentViewerIds: views.recentViewers.map((viewerId) => buildApiPeerId(viewerId, 'user')), + }), + ...(privacy && { visibility: buildPrivacyRules(privacy) }), + }; +} + +export function buildPrivacyRules(rules: GramJs.TypePrivacyRule[]): ApiPrivacySettings { + let visibility: PrivacyVisibility | undefined; + let allowUserIds: string[] | undefined; + let allowChatIds: string[] | undefined; + let blockUserIds: string[] | undefined; + let blockChatIds: string[] | undefined; + + rules.forEach((rule) => { + if (rule instanceof GramJs.PrivacyValueAllowAll) { + visibility ||= 'everybody'; + } else if (rule instanceof GramJs.PrivacyValueAllowContacts) { + visibility ||= 'contacts'; + } else if (rule instanceof GramJs.PrivacyValueAllowCloseFriends) { + visibility ||= 'closeFriends'; + } else if (rule instanceof GramJs.PrivacyValueDisallowContacts) { + visibility ||= 'nonContacts'; + } else if (rule instanceof GramJs.PrivacyValueDisallowAll) { + visibility ||= 'nobody'; + } else if (rule instanceof GramJs.PrivacyValueAllowUsers) { + visibility ||= 'selectedContacts'; + allowUserIds = rule.users.map((chatId) => buildApiPeerId(chatId, 'user')); + } else if (rule instanceof GramJs.PrivacyValueDisallowUsers) { + blockUserIds = rule.users.map((chatId) => buildApiPeerId(chatId, 'user')); + } else if (rule instanceof GramJs.PrivacyValueAllowChatParticipants) { + allowChatIds = rule.chats.map((chatId) => buildApiPeerId(chatId, 'chat')); + } else if (rule instanceof GramJs.PrivacyValueDisallowChatParticipants) { + blockChatIds = rule.chats.map((chatId) => buildApiPeerId(chatId, 'chat')); + } + }); + + if (!visibility) { + // Disallow by default + visibility = 'nobody'; + } + + return { + visibility, + allowUserIds: allowUserIds || [], + allowChatIds: allowChatIds || [], + blockUserIds: blockUserIds || [], + blockChatIds: blockChatIds || [], + }; +} diff --git a/src/api/gramjs/apiBuilders/misc.ts b/src/api/gramjs/apiBuilders/misc.ts index 29987170d..6cc0665c4 100644 --- a/src/api/gramjs/apiBuilders/misc.ts +++ b/src/api/gramjs/apiBuilders/misc.ts @@ -3,7 +3,7 @@ import { Api as GramJs } from '../../../lib/gramjs'; import type { ApiConfig, ApiCountry, ApiSession, ApiUrlAuthResult, ApiWallpaper, ApiWebSession, ApiLangString, } from '../../types'; -import type { ApiPrivacySettings, ApiPrivacyKey, PrivacyVisibility } from '../../../types'; +import type { ApiPrivacyKey } from '../../../types'; import { buildApiDocument, buildApiReaction } from './messages'; import { buildApiPeerId, getApiChatIdFromMtpPeer } from './peers'; @@ -81,47 +81,6 @@ export function buildPrivacyKey(key: GramJs.TypePrivacyKey): ApiPrivacyKey | und return undefined; } -export function buildPrivacyRules(rules: GramJs.TypePrivacyRule[]): ApiPrivacySettings { - let visibility: PrivacyVisibility | undefined; - let allowUserIds: string[] | undefined; - let allowChatIds: string[] | undefined; - let blockUserIds: string[] | undefined; - let blockChatIds: string[] | undefined; - - rules.forEach((rule) => { - if (rule instanceof GramJs.PrivacyValueAllowAll) { - visibility = visibility || 'everybody'; - } else if (rule instanceof GramJs.PrivacyValueAllowContacts) { - visibility = visibility || 'contacts'; - } else if (rule instanceof GramJs.PrivacyValueDisallowContacts) { - visibility = visibility || 'nonContacts'; - } else if (rule instanceof GramJs.PrivacyValueDisallowAll) { - visibility = visibility || 'nobody'; - } else if (rule instanceof GramJs.PrivacyValueAllowUsers) { - allowUserIds = rule.users.map((chatId) => buildApiPeerId(chatId, 'user')); - } else if (rule instanceof GramJs.PrivacyValueDisallowUsers) { - blockUserIds = rule.users.map((chatId) => buildApiPeerId(chatId, 'user')); - } else if (rule instanceof GramJs.PrivacyValueAllowChatParticipants) { - allowChatIds = rule.chats.map((chatId) => buildApiPeerId(chatId, 'chat')); - } else if (rule instanceof GramJs.PrivacyValueDisallowChatParticipants) { - blockChatIds = rule.chats.map((chatId) => buildApiPeerId(chatId, 'chat')); - } - }); - - if (!visibility) { - // disallow by default. - visibility = 'nobody'; - } - - return { - visibility, - allowUserIds: allowUserIds || [], - allowChatIds: allowChatIds || [], - blockUserIds: blockUserIds || [], - blockChatIds: blockChatIds || [], - }; -} - export function buildApiNotifyException( notifySettings: GramJs.TypePeerNotifySettings, peer: GramJs.TypePeer, ) { diff --git a/src/api/gramjs/apiBuilders/users.ts b/src/api/gramjs/apiBuilders/users.ts index 1ac80ce80..696e84e74 100644 --- a/src/api/gramjs/apiBuilders/users.ts +++ b/src/api/gramjs/apiBuilders/users.ts @@ -16,7 +16,7 @@ export function buildApiUserFullInfo(mtpUserFull: GramJs.users.UserFull): ApiUse fullUser: { about, commonChatsCount, pinnedMsgId, botInfo, blocked, profilePhoto, voiceMessagesForbidden, premiumGifts, - fallbackPhoto, personalPhoto, translationsDisabled, + fallbackPhoto, personalPhoto, translationsDisabled, storiesPinnedAvailable, }, users, } = mtpUserFull; @@ -29,6 +29,7 @@ export function buildApiUserFullInfo(mtpUserFull: GramJs.users.UserFull): ApiUse pinnedMessageId: pinnedMsgId, isBlocked: Boolean(blocked), noVoiceMessages: voiceMessagesForbidden, + hasPinnedStories: Boolean(storiesPinnedAvailable), isTranslationDisabled: translationsDisabled, ...(profilePhoto instanceof GramJs.Photo && { profilePhoto: buildApiPhoto(profilePhoto) }), ...(fallbackPhoto instanceof GramJs.Photo && { fallbackPhoto: buildApiPhoto(fallbackPhoto) }), @@ -44,7 +45,7 @@ export function buildApiUser(mtpUser: GramJs.TypeUser): ApiUser | undefined { } const { - id, firstName, lastName, fake, scam, + id, firstName, lastName, fake, scam, support, closeFriend, storiesUnavailable, storiesMaxId, } = mtpUser; const hasVideoAvatar = mtpUser.photo instanceof GramJs.UserProfilePhoto ? Boolean(mtpUser.photo.hasVideo) @@ -63,6 +64,8 @@ export function buildApiUser(mtpUser: GramJs.TypeUser): ApiUser | undefined { ...(mtpUser.self && { isSelf: true }), isPremium: Boolean(mtpUser.premium), ...(mtpUser.verified && { isVerified: true }), + ...(closeFriend && { isCloseFriend: true }), + ...(support && { isSupport: true }), ...((mtpUser.contact || mtpUser.mutualContact) && { isContact: true }), type: userType, firstName, @@ -75,6 +78,9 @@ export function buildApiUser(mtpUser: GramJs.TypeUser): ApiUser | undefined { ...(avatarHash && { avatarHash }), emojiStatus, hasVideoAvatar, + areStoriesHidden: Boolean(mtpUser.storiesHidden), + maxStoryId: storiesMaxId, + hasStories: Boolean(storiesMaxId) && !storiesUnavailable, ...(mtpUser.bot && mtpUser.botInlinePlaceholder && { botPlaceholder: mtpUser.botInlinePlaceholder }), ...(mtpUser.bot && mtpUser.botAttachMenu && { isAttachBot: mtpUser.botAttachMenu }), }; diff --git a/src/api/gramjs/gramjsBuilders/index.ts b/src/api/gramjs/gramjsBuilders/index.ts index 91ec522c3..2e82b12ac 100644 --- a/src/api/gramjs/gramjsBuilders/index.ts +++ b/src/api/gramjs/gramjsBuilders/index.ts @@ -1,7 +1,7 @@ import BigInt from 'big-integer'; import { Api as GramJs } from '../../../lib/gramjs'; -import type { ApiPrivacyKey } from '../../../types'; +import type { ApiPrivacyKey, PrivacyVisibility } from '../../../types'; import { generateRandomBytes, readBigIntFromBuffer } from '../../../lib/gramjs/Helpers'; import type { @@ -24,6 +24,10 @@ import type { ApiReaction, ApiFormattedText, ApiBotApp, + ApiStory, + ApiStorySkipped, + ApiUser, + ApiTypeReplyTo, } from '../../types'; import { ApiMessageEntityTypes, @@ -282,6 +286,14 @@ export function buildFilterFromApiFolder(folder: ApiChatFolder): GramJs.DialogFi }); } +export function buildInputStory(story: ApiStory | ApiStorySkipped) { + const user = localDb.users[story.userId]; + return new GramJs.InputMediaStory({ + userId: new GramJs.InputUser({ userId: BigInt(user!.id), accessHash: user!.accessHash! }), + id: story.id, + }); +} + export function generateRandomBigInt() { return readBigIntFromBuffer(generateRandomBytes(8), true, true); } @@ -625,3 +637,82 @@ export function buildInputBotApp(app: ApiBotApp) { accessHash: BigInt(app.accessHash), }); } + +export function buildInputReplyToMessage(replyToMsgId: number, topMsgId?: number) { + return new GramJs.InputReplyToMessage({ + replyToMsgId, + topMsgId, + }); +} + +export function buildInputReplyToStory(userId: string, storyId: number) { + return new GramJs.InputReplyToStory({ + userId: buildInputPeerFromLocalDb(userId)!, + storyId, + }); +} + +export function buildInputReplyTo(replyingTo: ApiTypeReplyTo) { + return 'replyingTo' in replyingTo + ? buildInputReplyToMessage(replyingTo.replyingTo, replyingTo.replyingToTopId) + : buildInputReplyToStory(replyingTo.userId, replyingTo.storyId); +} + +export function buildInputPrivacyRules( + visibility: PrivacyVisibility, + allowedUserList?: ApiUser[], + deniedUserList?: ApiUser[], +) { + const rules: GramJs.TypeInputPrivacyRule[] = []; + + switch (visibility) { + case 'everybody': + rules.push(new GramJs.InputPrivacyValueAllowAll()); + break; + + case 'contacts': { + rules.push(new GramJs.InputPrivacyValueAllowContacts()); + + const users = deniedUserList?.reduce((acc, { id, accessHash }) => { + acc.push(new GramJs.InputPeerUser({ + userId: buildMtpPeerId(id, 'user'), + accessHash: BigInt(accessHash!), + })); + return acc; + }, []); + + if (users?.length) { + rules.push(new GramJs.InputPrivacyValueDisallowUsers({ users })); + } + break; + } + + case 'closeFriends': + rules.push(new GramJs.InputPrivacyValueAllowCloseFriends()); + break; + + case 'nonContacts': + rules.push(new GramJs.InputPrivacyValueDisallowContacts()); + break; + + case 'selectedContacts': { + const users = (allowedUserList || []).reduce((acc, { id, accessHash }) => { + acc.push(new GramJs.InputPeerUser({ + userId: buildMtpPeerId(id, 'user'), + accessHash: BigInt(accessHash!), + })); + + return acc; + }, []); + + rules.push(new GramJs.InputPrivacyValueAllowUsers({ users })); + break; + } + + case 'nobody': + rules.push(new GramJs.InputPrivacyValueDisallowAll()); + break; + } + + return rules; +} diff --git a/src/api/gramjs/helpers.ts b/src/api/gramjs/helpers.ts index 089a36924..5f6d54ca0 100644 --- a/src/api/gramjs/helpers.ts +++ b/src/api/gramjs/helpers.ts @@ -1,5 +1,6 @@ import { Api as GramJs } from '../../lib/gramjs'; import localDb from './localDb'; +import type { StoryRepairInfo } from './localDb'; import { buildApiPeerId, getApiChatIdFromMtpPeer } from './apiBuilders/peers'; const LOG_BACKGROUND = '#111111DD'; @@ -78,6 +79,36 @@ export function addMessageToLocalDb(message: GramJs.Message | GramJs.MessageServ } } +export function addStoryToLocalDb(story: GramJs.TypeStoryItem, userId: string) { + if (!(story instanceof GramJs.StoryItem)) { + return; + } + + const storyData = { + id: story.id, + userId, + }; + + if (story.media instanceof GramJs.MessageMediaPhoto) { + const photo = story.media.photo as GramJs.Photo & StoryRepairInfo; + photo.storyData = storyData; + addPhotoToLocalDb(photo); + } + if (story.media instanceof GramJs.MessageMediaDocument) { + if (story.media.document instanceof GramJs.Document) { + const doc = story.media.document as GramJs.Document & StoryRepairInfo; + doc.storyData = storyData; + localDb.documents[String(story.media.document.id)] = doc; + } + + if (story.media.altDocument instanceof GramJs.Document) { + const doc = story.media.altDocument as GramJs.Document & StoryRepairInfo; + doc.storyData = storyData; + localDb.documents[String(story.media.altDocument.id)] = doc; + } + } +} + export function addPhotoToLocalDb(photo: GramJs.TypePhoto) { if (photo instanceof GramJs.Photo) { localDb.photos[String(photo.id)] = photo; diff --git a/src/api/gramjs/localDb.ts b/src/api/gramjs/localDb.ts index 0c95f5c12..eae764fa3 100644 --- a/src/api/gramjs/localDb.ts +++ b/src/api/gramjs/localDb.ts @@ -8,16 +8,22 @@ import { throttle } from '../../util/schedulers'; // eslint-disable-next-line no-restricted-globals const IS_MULTITAB_SUPPORTED = 'BroadcastChannel' in self; +export type StoryRepairInfo = { + storyData?: { + userId: string; + id: number; + }; +}; + export interface LocalDb { // Used for loading avatars and media through in-memory Gram JS instances. chats: Record; users: Record; messages: Record; - documents: Record; + documents: Record; stickerSets: Record; - photos: Record; + photos: Record; webDocuments: Record; - commonBoxState: Record; channelPtsById: Record; } @@ -79,7 +85,7 @@ function convertToVirtualClass(value: any): any { function createLocalDbInitial(initial?: LocalDb): LocalDb { return [ - 'localMessages', 'chats', 'users', 'messages', 'documents', 'stickerSets', 'photos', 'webDocuments', + 'localMessages', 'chats', 'users', 'messages', 'documents', 'stickerSets', 'photos', 'webDocuments', 'stories', 'commonBoxState', 'channelPtsById', ] .reduce((acc: Record, key) => { diff --git a/src/api/gramjs/methods/bots.ts b/src/api/gramjs/methods/bots.ts index bdbb812e0..545e60c17 100644 --- a/src/api/gramjs/methods/bots.ts +++ b/src/api/gramjs/methods/bots.ts @@ -10,7 +10,12 @@ import localDb from '../localDb'; import { WEB_APP_PLATFORM } from '../../../config'; import { invokeRequest } from './client'; import { - buildInputBotApp, buildInputEntity, buildInputPeer, buildInputThemeParams, generateRandomBigInt, + buildInputBotApp, + buildInputEntity, + buildInputPeer, + buildInputReplyToMessage, + buildInputThemeParams, + generateRandomBigInt, } from '../gramjsBuilders'; import { buildApiUser } from '../apiBuilders/users'; import { @@ -189,13 +194,12 @@ export async function requestWebView({ silent: isSilent || undefined, peer: buildInputPeer(peer.id, peer.accessHash), bot: buildInputPeer(bot.id, bot.accessHash), - replyToMsgId: replyToMessageId, url, startParam, themeParams: theme ? buildInputThemeParams(theme) : undefined, fromBotMenu: isFromBotMenu || undefined, platform: WEB_APP_PLATFORM, - ...(threadId && { topMsgId: threadId }), + ...(replyToMessageId && { replyTo: buildInputReplyToMessage(replyToMessageId, threadId) }), ...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }), })); @@ -294,8 +298,7 @@ export function prolongWebView({ peer: buildInputPeer(peer.id, peer.accessHash), bot: buildInputPeer(bot.id, bot.accessHash), queryId: BigInt(queryId), - replyToMsgId: replyToMessageId, - ...(threadId && { topMsgId: threadId }), + ...(replyToMessageId && { replyTo: buildInputReplyToMessage(replyToMessageId, threadId) }), ...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }), })); } diff --git a/src/api/gramjs/methods/client.ts b/src/api/gramjs/methods/client.ts index 4e4d86dfe..164d7e700 100644 --- a/src/api/gramjs/methods/client.ts +++ b/src/api/gramjs/methods/client.ts @@ -1,5 +1,5 @@ import { - sessions, Api as GramJs, connection, + sessions, Api as GramJs, } from '../../../lib/gramjs'; import TelegramClient from '../../../lib/gramjs/client/TelegramClient'; @@ -26,7 +26,8 @@ import { buildApiUser, buildApiUserFullInfo } from '../apiBuilders/users'; import localDb, { clearLocalDb } from '../localDb'; import { buildApiPeerId } from '../apiBuilders/peers'; import { - addMessageToLocalDb, addUserToLocalDb, isResponseUpdate, log, + addEntitiesToLocalDb, + addMessageToLocalDb, addStoryToLocalDb, addUserToLocalDb, isResponseUpdate, log, } from '../helpers'; import { ChatAbortController } from '../ChatAbortController'; import { @@ -52,7 +53,6 @@ const ABORT_CONTROLLERS = new Map(); let onUpdate: OnApiUpdate; let client: TelegramClient; -let isConnected = false; let currentUserId: string | undefined; export async function init(_onUpdate: OnApiUpdate, initialArgs: ApiInitialArgs) { @@ -197,9 +197,7 @@ type UpdateConfig = GramJs.UpdateConfig & { _entities?: (GramJs.TypeUser | GramJ export function handleGramJsUpdate(update: any) { processUpdate(update); - if (update instanceof connection.UpdateConnectionState) { - isConnected = update.state === connection.UpdateConnectionState.connected; - } else if (update instanceof GramJs.UpdatesTooLong) { + if (update instanceof GramJs.UpdatesTooLong) { void handleTerminatedSession(); } else { const updates = 'updates' in update ? update.updates : [update]; @@ -314,16 +312,18 @@ export function invokeRequestBeacon( } export async function downloadMedia( - args: { url: string; mediaFormat: ApiMediaFormat; start?: number; end?: number; isHtmlAllowed?: boolean }, + args: { + url: string; mediaFormat: ApiMediaFormat; start?: number; end?: number; isHtmlAllowed?: boolean; + }, onProgress?: ApiOnProgress, ) { try { - return (await downloadMediaWithClient(args, client, isConnected, onProgress)); + return (await downloadMediaWithClient(args, client, onProgress)); } catch (err: any) { if (err.message.startsWith('FILE_REFERENCE')) { const isFileReferenceRepaired = await repairFileReference({ url: args.url }); if (isFileReferenceRepaired) { - return downloadMediaWithClient(args, client, isConnected, onProgress); + return downloadMediaWithClient(args, client, onProgress); } if (DEBUG) { @@ -439,8 +439,21 @@ export async function repairFileReference({ entityType, entityId, mediaMatchType, } = parsed; - if (mediaMatchType === 'file') { - return false; + if (mediaMatchType === 'document' || mediaMatchType === 'photo') { + const entity = mediaMatchType === 'document' ? localDb.documents[entityId] : localDb.photos[entityId]; + if (!entity.storyData) return false; + const user = localDb.users[entity.storyData.userId]; + if (!user?.accessHash) return false; + + const result = await invokeRequest(new GramJs.stories.GetStoriesByID({ + userId: new GramJs.InputUser({ userId: user.id, accessHash: user.accessHash }), + id: [entity.storyData.id], + })); + if (!result) return false; + + addEntitiesToLocalDb(result.users); + result.stories.forEach((story) => addStoryToLocalDb(story, entity.storyData!.userId)); + return true; } if (entityType === 'msg') { @@ -470,6 +483,8 @@ export async function repairFileReference({ const message = result.messages[0]; if (message instanceof GramJs.MessageEmpty) return false; + addEntitiesToLocalDb(result.users); + addEntitiesToLocalDb(result.chats); addMessageToLocalDb(message); return true; } diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index 1661f1459..46b712c60 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -39,6 +39,7 @@ export { export { fetchFullUser, fetchNearestCountry, fetchTopUsers, fetchContactList, fetchUsers, updateContact, importContact, deleteContact, fetchProfilePhotos, fetchCommonChats, reportSpam, updateEmojiStatus, + saveCloseFriends, } from './users'; export { @@ -107,3 +108,9 @@ export { export { broadcastLocalDbUpdateFull, } from '../localDb'; + +export { + fetchAllStories, fetchUserStories, fetchUserPinnedStories, fetchUserStoriesByIds, viewStory, markStoryRead, + deleteStory, toggleStoryPinned, fetchStorySeenBy, fetchStoryLink, fetchStoriesArchive, reportStory, editStoryPrivacy, + toggleStoriesHidden, fetchStoriesMaxIds, +} from './stories'; diff --git a/src/api/gramjs/methods/media.ts b/src/api/gramjs/methods/media.ts index 47b9e98f4..44e8772f9 100644 --- a/src/api/gramjs/methods/media.ts +++ b/src/api/gramjs/methods/media.ts @@ -27,12 +27,11 @@ export default async function downloadMedia( url: string; mediaFormat: ApiMediaFormat; start?: number; end?: number; isHtmlAllowed?: boolean; }, client: TelegramClient, - isConnected: boolean, onProgress?: ApiOnProgress, ) { const { data, mimeType, fullSize, - } = await download(url, client, isConnected, onProgress, start, end, mediaFormat, isHtmlAllowed) || {}; + } = await download(url, client, onProgress, start, end, mediaFormat, isHtmlAllowed) || {}; if (!data) { return undefined; @@ -71,7 +70,6 @@ export type EntityType = ( async function download( url: string, client: TelegramClient, - isConnected: boolean, onProgress?: ApiOnProgress, start?: number, end?: number, @@ -218,14 +216,17 @@ function getMessageMediaMimeType(message: GramJs.Message, sizeType?: string) { return 'image/png'; } - if (message.media instanceof GramJs.MessageMediaDocument && message.media.document instanceof GramJs.Document) { - if (sizeType) { - return message.media.document!.attributes.some((a) => a instanceof GramJs.DocumentAttributeSticker) - ? 'image/webp' - : 'image/jpeg'; - } + if (message.media instanceof GramJs.MessageMediaDocument) { + const document = message.media.document; + if (document instanceof GramJs.Document) { + if (sizeType) { + return document.attributes.some((a) => a instanceof GramJs.DocumentAttributeSticker) + ? 'image/webp' + : 'image/jpeg'; + } - return message.media.document!.mimeType; + return document.mimeType; + } } if (message.media instanceof GramJs.MessageMediaWebPage diff --git a/src/api/gramjs/methods/messages.ts b/src/api/gramjs/methods/messages.ts index c41d9f2da..713dc77ba 100644 --- a/src/api/gramjs/methods/messages.ts +++ b/src/api/gramjs/methods/messages.ts @@ -17,6 +17,9 @@ import type { ApiContact, ApiPoll, ApiFormattedText, + ApiTypeReplyTo, + ApiStory, + ApiStorySkipped, } from '../../types'; import { MAIN_THREAD_ID, @@ -57,6 +60,8 @@ import { buildInputPollFromExisting, buildInputTextWithEntities, buildMessageFromUpdate, + buildInputStory, + buildInputReplyTo, } from '../gramjsBuilders'; import { buildApiChatFromPreview, buildApiSendAsPeerId } from '../apiBuilders/chats'; import { fetchFile } from '../../../util/files'; @@ -233,9 +238,9 @@ export function sendMessage( text, entities, replyingTo, - replyingToTopId, attachment, sticker, + story, gif, poll, contact, @@ -250,10 +255,10 @@ export function sendMessage( lastMessageId?: number; text?: string; entities?: ApiMessageEntity[]; - replyingTo?: number; - replyingToTopId?: number; + replyingTo?: ApiTypeReplyTo; attachment?: ApiAttachment; sticker?: ApiSticker; + story?: ApiStory | ApiStorySkipped; gif?: ApiVideo; poll?: ApiNewPoll; contact?: ApiContact; @@ -271,7 +276,6 @@ export function sendMessage( text, entities, replyingTo, - replyingToTopId, attachment, sticker, gif, @@ -280,6 +284,7 @@ export function sendMessage( groupedId, scheduledAt, sendAs, + story, ); onUpdate({ @@ -310,7 +315,6 @@ export function sendMessage( text, entities, replyingTo, - replyingToTopId, attachment: attachment!, groupedId, isSilent, @@ -339,6 +343,8 @@ export function sendMessage( media = buildInputMediaDocument(gif); } else if (poll) { media = buildInputPoll(poll, randomId); + } else if (story) { + media = buildInputStory(story); } else if (contact) { media = new GramJs.InputMediaContact({ phoneNumber: contact.phoneNumber, @@ -349,6 +355,7 @@ export function sendMessage( } const RequestClass = media ? GramJs.messages.SendMedia : GramJs.messages.SendMessage; + const replyTo = replyingTo ? buildInputReplyTo(replyingTo) : undefined; try { const update = await invokeRequest(new RequestClass({ @@ -359,8 +366,7 @@ export function sendMessage( randomId, ...(isSilent && { silent: isSilent }), ...(scheduledAt && { scheduleDate: scheduledAt }), - ...(replyingTo && { replyToMsgId: replyingTo }), - ...(replyingToTopId && { topMsgId: replyingToTopId }), + ...(replyTo && { replyTo }), ...(media && { media }), ...(noWebPage && { noWebpage: noWebPage }), ...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }), @@ -396,7 +402,6 @@ function sendGroupedMedia( text, entities, replyingTo, - replyingToTopId, attachment, groupedId, isSilent, @@ -406,8 +411,7 @@ function sendGroupedMedia( chat: ApiChat; text?: string; entities?: ApiMessageEntity[]; - replyingTo?: number; - replyingToTopId?: number; + replyingTo?: ApiTypeReplyTo; attachment: ApiAttachment; groupedId: string; isSilent?: boolean; @@ -479,13 +483,13 @@ function sendGroupedMedia( const { singleMediaByIndex, localMessages } = groupedUploads[groupedId]; delete groupedUploads[groupedId]; + const replyTo = replyingTo ? buildInputReplyTo(replyingTo) : undefined; const update = await invokeRequest(new GramJs.messages.SendMultiMedia({ clearDraft: true, peer: buildInputPeer(chat.id, chat.accessHash), multiMedia: Object.values(singleMediaByIndex), // Object keys are usually ordered - replyToMsgId: replyingTo, - ...(replyingToTopId && { topMsgId: replyingToTopId }), + ...(replyingTo && { replyTo }), ...(isSilent && { silent: isSilent }), ...(scheduledAt && { scheduleDate: scheduledAt }), ...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }), diff --git a/src/api/gramjs/methods/settings.ts b/src/api/gramjs/methods/settings.ts index 44d6d1193..316471a15 100644 --- a/src/api/gramjs/methods/settings.ts +++ b/src/api/gramjs/methods/settings.ts @@ -22,9 +22,8 @@ import { buildApiSession, buildApiWallpaper, buildApiWebSession, buildLangPack, buildLangPackString, - buildPrivacyRules, } from '../apiBuilders/misc'; - +import { buildPrivacyRules } from '../apiBuilders/messages'; import { buildApiPhoto } from '../apiBuilders/common'; import { buildApiUser } from '../apiBuilders/users'; import { buildApiChatFromPreview } from '../apiBuilders/chats'; @@ -620,7 +619,7 @@ export async function updateGlobalPrivacySettings({ shouldArchiveAndMuteNewNonCo }) { const result = await invokeRequest(new GramJs.account.SetGlobalPrivacySettings({ settings: new GramJs.GlobalPrivacySettings({ - archiveAndMuteNewNoncontactPeers: shouldArchiveAndMuteNewNonContact, + ...(shouldArchiveAndMuteNewNonContact && { archiveAndMuteNewNoncontactPeers: true }), }), })); diff --git a/src/api/gramjs/methods/stories.ts b/src/api/gramjs/methods/stories.ts new file mode 100644 index 000000000..6074e5eaa --- /dev/null +++ b/src/api/gramjs/methods/stories.ts @@ -0,0 +1,343 @@ +import BigInt from 'big-integer'; +import { invokeRequest } from './client'; +import type { + ApiUser, ApiUserStories, ApiReportReason, ApiTypeStory, +} from '../../types'; +import type { PrivacyVisibility } from '../../../types'; +import { Api as GramJs } from '../../../lib/gramjs'; +import { addEntitiesToLocalDb, addStoryToLocalDb } from '../helpers'; +import { buildApiUser } from '../apiBuilders/users'; +import { buildApiStory, buildApiUsersStories } from '../apiBuilders/messages'; +import { buildApiPeerId } from '../apiBuilders/peers'; +import { + buildInputPeer, + buildInputPeerFromLocalDb, + buildInputPrivacyRules, + buildInputReportReason, +} from '../gramjsBuilders'; +import { STORY_LIST_LIMIT } from '../../../config'; +import { buildCollectionByCallback } from '../../../util/iteratees'; + +export async function fetchAllStories({ + stateHash, + isFirstRequest = false, + isHidden = false, +}: { + isFirstRequest?: boolean; + stateHash?: string; + isHidden?: boolean; +}): Promise< + undefined + | { state: string } + | { users: ApiUser[]; userStories: Record; hasMore?: true; state: string }> { + const params: ConstructorParameters[0] = isFirstRequest + ? (isHidden ? { hidden: true } : {}) + : { state: stateHash, next: true, ...(isHidden && { hidden: true }) }; + const result = await invokeRequest(new GramJs.stories.GetAllStories(params)); + + if (!result) { + return undefined; + } + + if (result instanceof GramJs.stories.AllStoriesNotModified) { + return { + state: result.state, + }; + } + + addEntitiesToLocalDb(result.users); + result.userStories.forEach((userStories) => ( + userStories.stories.forEach((story) => addStoryToLocalDb(story, buildApiPeerId(userStories.userId, 'user'))) + )); + + const allUserStories = result.userStories.reduce>((acc, userStories) => { + const userId = buildApiPeerId(userStories.userId, 'user'); + const stories = buildApiUsersStories(userStories); + const { pinnedIds, orderedIds, lastUpdatedAt } = Object.values(stories).reduce< + { + pinnedIds: number[]; + orderedIds: number[]; + lastUpdatedAt?: number; + } + >((dataAcc, story) => { + if ('isPinned' in story && story.isPinned) { + dataAcc.pinnedIds.push(story.id); + } + if (!('isDeleted' in story)) { + dataAcc.orderedIds.push(story.id); + dataAcc.lastUpdatedAt = Math.max(story.date, dataAcc.lastUpdatedAt || 0); + } + + return dataAcc; + }, { + pinnedIds: [], + orderedIds: [], + lastUpdatedAt: undefined, + }); + + if (orderedIds.length === 0) { + return acc; + } + + acc[userId] = { + byId: stories, + orderedIds, + pinnedIds, + lastUpdatedAt, + lastReadId: userStories.maxReadId, + }; + + return acc; + }, {}); + + return { + users: result.users.map(buildApiUser).filter(Boolean), + userStories: allUserStories, + hasMore: result.hasMore, + state: result.state, + }; +} + +export async function fetchUserStories({ + user, +}: { + user: ApiUser; +}) { + const result = await invokeRequest(new GramJs.stories.GetUserStories({ + userId: buildInputPeer(user.id, user.accessHash), + })); + + if (!result) { + return undefined; + } + + addEntitiesToLocalDb(result.users); + result.stories.stories.forEach((story) => addStoryToLocalDb(story, user.id)); + + const users = result.users.map(buildApiUser).filter(Boolean); + const stories = buildCollectionByCallback(result.stories.stories, (story) => ( + [story.id, buildApiStory(user.id, story)] + )); + + return { + users, + stories, + lastReadStoryId: result.stories.maxReadId, + }; +} + +export function fetchUserPinnedStories({ + user, offsetId, +}: { + user: ApiUser; + offsetId?: number; +}) { + return fetchCommonStoriesRequest({ + method: new GramJs.stories.GetPinnedStories({ + userId: buildInputPeer(user.id, user.accessHash), + offsetId, + limit: STORY_LIST_LIMIT, + }), + userId: user.id, + }); +} + +export function fetchStoriesArchive({ + currentUserId, + offsetId, +}: { + currentUserId: string; + offsetId?: number; +}) { + return fetchCommonStoriesRequest({ + method: new GramJs.stories.GetStoriesArchive({ + offsetId, + limit: STORY_LIST_LIMIT, + }), + userId: currentUserId, + }); +} + +export async function fetchUserStoriesByIds({ user, ids }: { user: ApiUser; ids: number[] }) { + const result = await invokeRequest(new GramJs.stories.GetStoriesByID({ + userId: buildInputPeer(user.id, user.accessHash), + id: ids, + })); + + if (!result) { + return undefined; + } + + addEntitiesToLocalDb(result.users); + result.stories.forEach((story) => addStoryToLocalDb(story, user.id)); + + const users = result.users.map(buildApiUser).filter(Boolean); + const stories = ids.reduce>((acc, id) => { + const story = result.stories.find(({ id: currentId }) => currentId === id); + if (story) { + acc[id] = buildApiStory(user.id, story); + } else { + acc[id] = { + id, + userId: user.id, + isDeleted: true, + }; + } + + return acc; + }, {}); + + return { + users, + stories, + }; +} + +export function viewStory({ user, storyId }: { user: ApiUser; storyId: number }) { + return invokeRequest(new GramJs.stories.IncrementStoryViews({ + userId: buildInputPeer(user.id, user.accessHash), + id: [storyId], + })); +} + +export function markStoryRead({ user, storyId }: { user: ApiUser; storyId: number }) { + return invokeRequest(new GramJs.stories.ReadStories({ + userId: buildInputPeer(user.id, user.accessHash), + maxId: storyId, + })); +} + +export function deleteStory({ storyId }: { storyId: number }) { + return invokeRequest(new GramJs.stories.DeleteStories({ id: [storyId] })); +} + +export function toggleStoryPinned({ storyId, isPinned }: { storyId: number; isPinned?: boolean }) { + return invokeRequest(new GramJs.stories.TogglePinned({ id: [storyId], pinned: isPinned })); +} + +export async function fetchStorySeenBy({ + storyId, limit = STORY_LIST_LIMIT, offsetDate = 0, offsetId = 0, +}: { + storyId: number; + limit?: number; + offsetDate?: number; + offsetId?: number; +}) { + const result = await invokeRequest(new GramJs.stories.GetStoryViewsList({ + id: storyId, + limit, + offsetDate, + offsetId: BigInt(offsetId), + })); + + if (!result) { + return undefined; + } + + addEntitiesToLocalDb(result.users); + const users = result.users.map(buildApiUser).filter(Boolean); + const seenByDates = result.views.reduce>((acc, view) => { + acc[buildApiPeerId(view.userId, 'user')] = view.date; + + return acc; + }, {}); + + return { users, seenByDates, count: result.count }; +} + +export async function fetchStoryLink({ userId, storyId }: { userId: string; storyId: number }) { + const inputUser = buildInputPeerFromLocalDb(userId); + if (!inputUser) { + return undefined; + } + + const result = await invokeRequest(new GramJs.stories.ExportStoryLink({ + userId: inputUser, + id: storyId, + })); + + if (!result) { + return undefined; + } + + return result.link; +} + +export function reportStory({ + user, + storyId, + reason, + description, +}: { + user: ApiUser; storyId: number; reason: ApiReportReason; description?: string; +}) { + return invokeRequest(new GramJs.stories.Report({ + userId: buildInputPeer(user.id, user.accessHash), + id: [storyId], + reason: buildInputReportReason(reason), + message: description, + })); +} + +export function editStoryPrivacy({ + id, visibility, allowedUserList, deniedUserList, +}: { + id: number; + visibility: PrivacyVisibility; + allowedUserList?: ApiUser[]; + deniedUserList?: ApiUser[]; +}) { + return invokeRequest(new GramJs.stories.EditStory({ + id, + privacyRules: buildInputPrivacyRules(visibility, allowedUserList, deniedUserList), + }), { + shouldReturnTrue: true, + }); +} + +export function toggleStoriesHidden({ + user, + isHidden, +}: { + user: ApiUser; + isHidden: boolean; +}) { + return invokeRequest(new GramJs.contacts.ToggleStoriesHidden({ + id: buildInputPeer(user.id, user.accessHash), + hidden: isHidden, + })); +} + +export function fetchStoriesMaxIds({ + users, +}: { + users: ApiUser[]; +}) { + return invokeRequest(new GramJs.users.GetStoriesMaxIDs({ + id: users.map((user) => buildInputPeer(user.id, user.accessHash)), + })); +} + +async function fetchCommonStoriesRequest({ method, userId }: { + method: GramJs.stories.GetPinnedStories | GramJs.stories.GetStoriesArchive; + userId: string; +}) { + const result = await invokeRequest(method); + + if (!result) { + return undefined; + } + + addEntitiesToLocalDb(result.users); + result.stories.forEach((story) => addStoryToLocalDb(story, userId)); + + const users = result.users.map(buildApiUser).filter(Boolean); + const stories = buildCollectionByCallback(result.stories, (story) => ( + [story.id, buildApiStory(userId, story)] + )); + + return { + users, + stories, + }; +} diff --git a/src/api/gramjs/methods/users.ts b/src/api/gramjs/methods/users.ts index 5afcc6ac8..dcfbf9718 100644 --- a/src/api/gramjs/methods/users.ts +++ b/src/api/gramjs/methods/users.ts @@ -301,6 +301,14 @@ export function updateEmojiStatus(emojiStatus: ApiSticker, expires?: number) { }); } +export function saveCloseFriends(userIds: string[]) { + const id = userIds.map((userId) => buildMtpPeerId(userId, 'user')); + + return invokeRequest(new GramJs.contacts.EditCloseFriends({ id }), { + shouldReturnTrue: true, + }); +} + function updateLocalDb(result: (GramJs.photos.Photos | GramJs.photos.PhotosSlice | GramJs.messages.Chats)) { if ('chats' in result) { addEntitiesToLocalDb(result.chats); diff --git a/src/api/gramjs/updater.ts b/src/api/gramjs/updater.ts index 544d52079..9a2d76d9a 100644 --- a/src/api/gramjs/updater.ts +++ b/src/api/gramjs/updater.ts @@ -2,6 +2,7 @@ import type { GroupCallConnectionData } from '../../lib/secret-sauce'; import { Api as GramJs, connection } from '../../lib/gramjs'; import type { ApiMessage, ApiMessageExtendedMediaPreview, ApiUpdate, ApiUpdateConnectionStateType, OnApiUpdate, + ApiStory, ApiStorySkipped, } from '../types'; import { DEBUG, GENERAL_TOPIC_ID } from '../../config'; @@ -18,6 +19,8 @@ import { buildMessageDraft, buildMessageReactions, buildApiMessageExtendedMediaPreview, + buildApiStory, + buildPrivacyRules, } from './apiBuilders/messages'; import { buildChatMember, @@ -49,12 +52,12 @@ import { log, swapLocalInvoiceMedia, isChatFolder, + addStoryToLocalDb, } from './helpers'; import { buildApiNotifyException, buildApiNotifyExceptionTopic, buildPrivacyKey, - buildPrivacyRules, } from './apiBuilders/misc'; import { buildApiPhoto, buildApiUsernames } from './apiBuilders/common'; import { @@ -300,7 +303,9 @@ export function updater(update: Update) { }); } } else if (action instanceof GramJs.MessageActionTopicEdit) { - const { replyTo } = update.message; + const replyTo = update.message.replyTo instanceof GramJs.MessageReplyHeader + ? update.message.replyTo + : undefined; const { replyToMsgId, replyToTopId, forumTopic: isTopicReply, } = replyTo || {}; @@ -1050,6 +1055,37 @@ export function updater(update: Update) { }); } else if (update instanceof GramJs.UpdateRecentEmojiStatuses) { onUpdate({ '@type': 'updateRecentEmojiStatuses' }); + } else if (update instanceof GramJs.UpdateStory) { + // eslint-disable-next-line no-underscore-dangle + const entities = update._entities; + if (entities) { + addEntitiesToLocalDb(entities); + dispatchUserAndChatUpdates(entities); + } + + const { story } = update; + const userId = buildApiPeerId(update.userId, 'user'); + addStoryToLocalDb(story, userId); + + if (story instanceof GramJs.StoryItemDeleted) { + onUpdate({ + '@type': 'deleteStory', + userId, + storyId: story.id, + }); + } else { + onUpdate({ + '@type': 'updateStory', + userId, + story: buildApiStory(userId, story) as ApiStory | ApiStorySkipped, + }); + } + } else if (update instanceof GramJs.UpdateReadStories) { + onUpdate({ + '@type': 'updateReadStories', + userId: buildApiPeerId(update.userId, 'user'), + lastReadId: update.maxId, + }); } else if (DEBUG) { const params = typeof update === 'object' && 'className' in update ? update.className : update; log('UNEXPECTED UPDATE', params); diff --git a/src/api/gramjs/worker/connector.ts b/src/api/gramjs/worker/connector.ts index a5c013bf1..6ca7e396d 100644 --- a/src/api/gramjs/worker/connector.ts +++ b/src/api/gramjs/worker/connector.ts @@ -35,7 +35,6 @@ const savedLocalDb: LocalDb = { stickerSets: {}, photos: {}, webDocuments: {}, - commonBoxState: {}, channelPtsById: {}, }; diff --git a/src/api/types/index.ts b/src/api/types/index.ts index af86b6a7d..ad51f3492 100644 --- a/src/api/types/index.ts +++ b/src/api/types/index.ts @@ -9,3 +9,4 @@ export * from './bots'; export * from './misc'; export * from './calls'; export * from './statistics'; +export * from './stories'; diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index e728ce0c7..ab799c0ae 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -1,6 +1,7 @@ import type { ApiWebDocument } from './bots'; import type { ApiGroupCall, PhoneCallAction } from './calls'; import type { ApiChat } from './chats'; +import type { ApiMessageStoryData, ApiWebPageStoryData } from './stories'; export interface ApiDimensions { width: number; @@ -95,6 +96,7 @@ export interface ApiVideo { blobUrl?: string; previewBlobUrl?: string; size: number; + noSound?: boolean; } export interface ApiAudio { @@ -293,6 +295,19 @@ export interface ApiWebPage { duration?: number; document?: ApiDocument; video?: ApiVideo; + story?: ApiWebPageStoryData; +} + +export type ApiTypeReplyTo = ApiMessageReplyTo | ApiStoryReplyTo; + +export interface ApiMessageReplyTo { + replyingTo: number; + replyingToTopId?: number; +} + +export interface ApiStoryReplyTo { + userId: string; + storyId: number; } export interface ApiMessageForwardInfo { @@ -383,6 +398,7 @@ export interface ApiMessage { text?: ApiFormattedText; photo?: ApiPhoto; video?: ApiVideo; + altVideo?: ApiVideo; document?: ApiDocument; sticker?: ApiSticker; contact?: ApiContact; @@ -394,6 +410,7 @@ export interface ApiMessage { invoice?: ApiInvoice; location?: ApiLocation; game?: ApiGame; + storyData?: ApiMessageStoryData; }; date: number; isOutgoing: boolean; @@ -402,6 +419,8 @@ export interface ApiMessage { replyToMessageId?: number; replyToTopMessageId?: number; isTopicReply?: true; + replyToStoryUserId?: string; + replyToStoryId?: number; sendingState?: 'messageSendingStatePending' | 'messageSendingStateFailed'; forwardInfo?: ApiMessageForwardInfo; isDeleting?: boolean; diff --git a/src/api/types/misc.ts b/src/api/types/misc.ts index 13773a736..dcabf558b 100644 --- a/src/api/types/misc.ts +++ b/src/api/types/misc.ts @@ -109,7 +109,7 @@ export type ApiNotification = { title?: string; message: string; actionText?: string; - action?: CallbackAction; + action?: CallbackAction | CallbackAction[]; className?: string; duration?: number; }; @@ -191,6 +191,10 @@ export interface ApiAppConfig { hiddenMembersMinCount: number; limits: Record; canDisplayAutoarchiveSetting: boolean; + areStoriesHidden?: boolean; + storyExpirePeriod: number; + storyViewersExpirePeriod: number; + storyChangelogUserId: string; } export interface ApiConfig { diff --git a/src/api/types/stories.ts b/src/api/types/stories.ts new file mode 100644 index 000000000..687fe5b8c --- /dev/null +++ b/src/api/types/stories.ts @@ -0,0 +1,59 @@ +import type { ApiMessage } from './messages'; +import type { ApiPrivacySettings } from '../../types'; + +export interface ApiStory { + '@type'?: 'story'; + id: number; + userId: string; + date: number; + expireDate: number; + content: ApiMessage['content']; + isPinned?: boolean; + isEdited?: boolean; + isForCloseFriends?: boolean; + isForContacts?: boolean; + isForSelectedContacts?: boolean; + isPublic?: boolean; + noForwards?: boolean; + viewsCount?: number; + recentViewerIds?: string[]; + visibility?: ApiPrivacySettings; +} + +export interface ApiStorySkipped { + '@type'?: 'storySkipped'; + id: number; + userId: string; + isForCloseFriends?: boolean; + date: number; + expireDate: number; +} + +export interface ApiStoryDeleted { + '@type'?: 'storyDeleted'; + id: number; + userId: string; + isDeleted: true; +} + +export type ApiTypeStory = ApiStory | ApiStorySkipped | ApiStoryDeleted; + +export type ApiUserStories = { + byId: Record; + orderedIds: number[]; // Actual user stories + pinnedIds: number[]; // Profile Shared Media: Pinned Stories tab + archiveIds?: number[]; // Profile Shared Media: Archive Stories tab + lastUpdatedAt?: number; + lastReadId?: number; +}; + +export type ApiMessageStoryData = { + id: number; + userId: string; + isMention?: boolean; +}; + +export type ApiWebPageStoryData = { + id: number; + userId: string; +}; diff --git a/src/api/types/updates.ts b/src/api/types/updates.ts index 5de73537d..8f3f8c69a 100644 --- a/src/api/types/updates.ts +++ b/src/api/types/updates.ts @@ -33,6 +33,7 @@ import type { } from './calls'; import type { ApiBotMenuButton } from './bots'; import type { ApiPrivacyKey, PrivacyVisibility } from '../../types'; +import type { ApiStory, ApiStorySkipped } from './stories'; export type ApiUpdateReady = { '@type': 'updateApiReady'; @@ -624,6 +625,24 @@ export type ApiRequestReconnectApi = { '@type': 'requestReconnectApi'; }; +export type ApiUpdateStory = { + '@type': 'updateStory'; + userId: string; + story: ApiStory | ApiStorySkipped; +}; + +export type ApiUpdateDeleteStory = { + '@type': 'deleteStory'; + userId: string; + storyId: number; +}; + +export type ApiUpdateReadStories = { + '@type': 'updateReadStories'; + userId: string; + lastReadId: number; +}; + export type ApiRequestSync = { '@type': 'requestSync'; }; @@ -654,7 +673,8 @@ export type ApiUpdate = ( ApiUpdatePhoneCallConnectionState | ApiUpdateBotMenuButton | ApiUpdateTranscribedAudio | ApiUpdateUserEmojiStatus | ApiUpdateMessageExtendedMedia | ApiUpdateConfig | ApiUpdateTopicNotifyExceptions | ApiUpdatePinnedTopic | ApiUpdatePinnedTopicsOrder | ApiUpdateTopic | ApiUpdateTopics | ApiUpdateRecentEmojiStatuses | - ApiUpdateRecentReactions | ApiRequestReconnectApi | ApiRequestSync | ApiUpdateFetchingDifference + ApiUpdateRecentReactions | ApiUpdateStory | ApiUpdateReadStories | ApiUpdateDeleteStory | + ApiRequestReconnectApi | ApiRequestSync | ApiUpdateFetchingDifference | ApiUpdateChannelMessages ); export type OnApiUpdate = (update: ApiUpdate) => void; diff --git a/src/api/types/users.ts b/src/api/types/users.ts index 51f923271..6e905df3a 100644 --- a/src/api/types/users.ts +++ b/src/api/types/users.ts @@ -8,7 +8,9 @@ export interface ApiUser { isSelf?: true; isVerified?: true; isPremium?: boolean; + isCloseFriend?: boolean; isContact?: true; + isSupport?: true; type: ApiUserType; firstName?: string; lastName?: string; @@ -29,6 +31,10 @@ export interface ApiUser { fakeType?: ApiFakeType; isAttachBot?: boolean; emojiStatus?: ApiEmojiStatus; + areStoriesHidden?: boolean; + hasStories?: boolean; + hasUnreadStories?: boolean; + maxStoryId?: number; } export interface ApiUserFullInfo { @@ -43,6 +49,7 @@ export interface ApiUserFullInfo { noVoiceMessages?: boolean; premiumGifts?: ApiPremiumGiftOption[]; isTranslationDisabled?: true; + hasPinnedStories?: boolean; } export type ApiFakeType = 'fake' | 'scam'; diff --git a/src/assets/fonts/icomoon.woff b/src/assets/fonts/icomoon.woff index 90484108750a520fe9514ae7aea135107254c849..41442d299056238cf8e0fa32137c947ba255984f 100644 GIT binary patch delta 3816 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 delta 392 zcmbR7lKIXVX0dXAH#Y`G1|WDA!@v!u1sE77H+~VDs3TneCOJ2;fPsNA11J#!!s20< zebN()L1KG=d=4lUNYANE1BxACV7R*lgsU#8N@t`drZ6zvuK=ns17VeAfq@x7L7>94`E&Akp7H2pA4YG+qnl?i zCf?fY`?P^s6Xbg!KB%<&eLTO-R|aktpa28IgTrj~F#7+$|EpP;C$D=eA;rP~xjbMv2@bc|Li!fGR+?oMQaUAOKd)1Po!}&7VIl GWds028wZd_8)e0Hh7@cZ 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 DW!| .inner { + overflow: hidden; + border-radius: var(--radius); + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background-image: linear-gradient(var(--color-white) -125%, var(--color-user)); + } &__media { width: 100%; @@ -120,6 +133,68 @@ cursor: var(--custom-cursor, pointer); } + &.with-story-circle { + z-index: 1; + + > .inner { + width: calc(100% - 0.375rem); + height: calc(100% - 0.375rem); + left: 0.1875rem; + top: 0.1875rem; + } + } + + &.with-story-solid { + width: 3rem; + height: 3rem; + margin: 0.1875rem; + + &::before { + content: ""; + position: absolute; + width: 3.5rem; + height: 3.5rem; + left: -0.25rem; + top: -0.25rem; + border-radius: 50%; + padding: 0.125rem; + + background: var(--color-borders-read-story); + mask: linear-gradient(to bottom, #fff 0%, #fff 100%) content-box, linear-gradient(to bottom, #fff 0%, #fff 100%); + mask-composite: exclude; + box-shadow: none; + } + + &.size-tiny { + width: 2rem; + height: 2rem; + + &::before { + width: 2.25rem; + height: 2.25rem; + } + } + + &.size-medium { + width: 2.75rem; + height: 2.75rem; + + &::before { + width: 3rem; + height: 3rem; + } + } + + &.online::after { + bottom: -0.125rem; + right: -0.125rem; + } + } + + &.has-unread-story::before { + background-image: linear-gradient(215.87deg, var(--color-avatar-story-unread-from) -1.61%, var(--color-avatar-story-unread-to) 97.44%); + } + .poster { position: absolute; left: 0; diff --git a/src/components/common/Avatar.tsx b/src/components/common/Avatar.tsx index 6bc9ae051..2a84801db 100644 --- a/src/components/common/Avatar.tsx +++ b/src/components/common/Avatar.tsx @@ -1,12 +1,9 @@ import type { MouseEvent as ReactMouseEvent } from 'react'; -import React, { - memo, useRef, -} from '../../lib/teact/teact'; +import React, { memo, useRef } from '../../lib/teact/teact'; +import { getActions } from '../../global'; import type { FC, TeactNode } from '../../lib/teact/teact'; -import type { - ApiChat, ApiPhoto, ApiUser, -} from '../../api/types'; +import type { ApiChat, ApiPhoto, ApiUser } from '../../api/types'; import type { ObserveFn } from '../../hooks/useIntersectionObserver'; import { ApiMediaFormat } from '../../api/types'; @@ -27,10 +24,11 @@ import renderText from './helpers/renderText'; import useMedia from '../../hooks/useMedia'; import useMediaTransition from '../../hooks/useMediaTransition'; import useLang from '../../hooks/useLang'; -import { useFastClick } from '../../hooks/useFastClick'; import useLastCallback from '../../hooks/useLastCallback'; +import { useFastClick } from '../../hooks/useFastClick'; import OptimizedVideo from '../ui/OptimizedVideo'; +import AvatarStoryCircle from './AvatarStoryCircle'; import './Avatar.scss'; @@ -50,6 +48,10 @@ type OwnProps = { text?: string; isSavedMessages?: boolean; withVideo?: boolean; + withStory?: boolean; + withStoryGap?: boolean; + withStorySolid?: boolean; + storyViewerMode?: 'full' | 'single-user' | 'disabled'; loopIndefinitely?: boolean; noPersonalPhoto?: boolean; observeIntersection?: ObserveFn; @@ -64,10 +66,16 @@ const Avatar: FC = ({ text, isSavedMessages, withVideo, + withStory, + withStoryGap, + withStorySolid, + storyViewerMode = 'single-user', loopIndefinitely, noPersonalPhoto, onClick, }) => { + const { openStoryViewer } = getActions(); + // eslint-disable-next-line no-null/no-null const ref = useRef(null); const videoLoopCountRef = useRef(0); @@ -163,6 +171,7 @@ const Avatar: FC = ({ className={buildClassName(cn.media, 'avatar-media', transitionClassNames, videoBlobUrl && 'poster')} alt={author} decoding="async" + draggable={false} /> {shouldPlayVideo && ( = ({ autoPlay disablePictureInPicture playsInline + draggable={false} onEnded={handleVideoEnded} /> )} @@ -197,6 +207,9 @@ const Avatar: FC = ({ isDeleted && 'deleted-account', isReplies && 'replies-bot-account', isForum && 'forum', + withStory && user?.hasStories && 'with-story-circle', + withStorySolid && user?.hasStories && 'with-story-solid', + withStorySolid && user?.hasUnreadStories && 'has-unread-story', onClick && 'interactive', (!isSavedMessages && !imgBlobUrl) && 'no-photo', ); @@ -204,6 +217,13 @@ const Avatar: FC = ({ const hasMedia = Boolean(isSavedMessages || imgBlobUrl); const { handleClick, handleMouseDown } = useFastClick((e: ReactMouseEvent) => { + if (withStory && storyViewerMode !== 'disabled' && user?.hasStories) { + e.stopPropagation(); + + openStoryViewer({ userId: user.id, isSingleUser: storyViewerMode === 'single-user' }); + return; + } + if (onClick) { onClick(e, hasMedia); } @@ -218,7 +238,12 @@ const Avatar: FC = ({ onClick={handleClick} onMouseDown={handleMouseDown} > - {typeof content === 'string' ? renderText(content, [size === 'jumbo' ? 'hq_emoji' : 'emoji']) : content} +
+ {typeof content === 'string' ? renderText(content, [size === 'jumbo' ? 'hq_emoji' : 'emoji']) : content} +
+ {withStory && user?.hasStories && ( + + )} ); }; diff --git a/src/components/common/AvatarStoryCircle.tsx b/src/components/common/AvatarStoryCircle.tsx new file mode 100644 index 000000000..049849aad --- /dev/null +++ b/src/components/common/AvatarStoryCircle.tsx @@ -0,0 +1,199 @@ +import React, { + memo, useLayoutEffect, useMemo, useRef, +} from '../../lib/teact/teact'; +import { withGlobal } from '../../global'; + +import type { AvatarSize } from './Avatar'; +import type { ThemeKey } from '../../types'; + +import { REM } from './helpers/mediaDimensions'; +import { DPR } from '../../util/windowEnvironment'; +import { selectTheme, selectUser, selectUserStories } from '../../global/selectors'; +import buildClassName from '../../util/buildClassName'; + +interface OwnProps { + // eslint-disable-next-line react/no-unused-prop-types + userId: string; + className?: string; + size: AvatarSize; + withExtraGap?: boolean; +} + +interface StateProps { + isCloseFriend?: boolean; + storyIds?: number[]; + lastReadId?: number; + appTheme: ThemeKey; +} + +const SIZES: Record = { + micro: 1.125 * DPR * REM, + tiny: 2.125 * DPR * REM, + mini: 1.625 * DPR * REM, + small: 2.25 * DPR * REM, + 'small-mobile': 2.625 * DPR * REM, + medium: 2.875 * DPR * REM, + large: 3.5 * DPR * REM, + jumbo: 7.625 * DPR * REM, +}; + +const BLUE = ['#34C578', '#3CA3F3']; +const GREEN = ['#C9EB38', '#09C167']; +const GRAY = '#C4C9CC'; +const DARK_GRAY = '#737373'; +const STROKE_WIDTH = 0.125 * DPR * REM; +const STROKE_WIDTH_READ = 0.0625 * DPR * REM; +const GAP_PERCENT = 2; +const SEGMENTS_MAX = 45; // More than this breaks rendering in Safari and Chrome + +const GAP_PERCENT_EXTRA = 10; +const EXTRA_GAP_ANGLE = Math.PI / 4; +const EXTRA_GAP_SIZE = (GAP_PERCENT_EXTRA / 100) * (2 * Math.PI); +const EXTRA_GAP_START = EXTRA_GAP_ANGLE - EXTRA_GAP_SIZE / 2; +const EXTRA_GAP_END = EXTRA_GAP_ANGLE + EXTRA_GAP_SIZE / 2; + +function AvatarStoryCircle({ + size = 'large', + className, + isCloseFriend, + storyIds, + lastReadId, + withExtraGap, + appTheme, +}: OwnProps & StateProps) { + // eslint-disable-next-line no-null/no-null + const ref = useRef(null); + + const values = useMemo(() => { + return (storyIds || []).reduce((acc, id) => { + acc.total += 1; + if (lastReadId && id <= lastReadId) { + acc.read += 1; + } + + return acc; + }, { total: 0, read: 0 }); + }, [lastReadId, storyIds]); + + useLayoutEffect(() => { + if (!ref.current) { + return; + } + + drawGradientCircle({ + canvas: ref.current, + size: SIZES[size], + segmentsCount: values.total, + color: isCloseFriend ? 'green' : 'blue', + readSegmentsCount: values.read, + withExtraGap, + readSegmentColor: appTheme === 'dark' ? DARK_GRAY : GRAY, + }); + }, [appTheme, isCloseFriend, size, values.read, values.total, withExtraGap]); + + if (!values.total) { + return undefined; + } + + const maxSize = SIZES[size] / DPR; + + return ( + + ); +} + +export default memo(withGlobal((global, { userId }): StateProps => { + const user = selectUser(global, userId); + const userStories = selectUserStories(global, userId); + const appTheme = selectTheme(global); + + return { + isCloseFriend: user?.isCloseFriend, + storyIds: userStories?.orderedIds, + lastReadId: userStories?.lastReadId, + appTheme, + }; +})(AvatarStoryCircle)); + +function drawGradientCircle({ + canvas, + size, + color, + segmentsCount, + readSegmentsCount = 0, + withExtraGap = false, + readSegmentColor, +}: { + canvas: HTMLCanvasElement; + size: number; + color: string; + segmentsCount: number; + readSegmentsCount?: number; + withExtraGap?: boolean; + readSegmentColor: string; +}) { + if (segmentsCount > SEGMENTS_MAX) { + readSegmentsCount = Math.round(readSegmentsCount * (SEGMENTS_MAX / segmentsCount)); + + segmentsCount = SEGMENTS_MAX; + } + + const ctx = canvas.getContext('2d'); + if (!ctx) { + return; + } + + canvas.width = size; + canvas.height = size; + const centerCoordinate = size / 2; + const radius = (size - STROKE_WIDTH) / 2; + const segmentAngle = (2 * Math.PI) / segmentsCount; + const gapSize = (GAP_PERCENT / 100) * (2 * Math.PI); + const gradient = ctx.createLinearGradient( + 0, + 0, + Math.ceil(size * Math.cos(Math.PI / 2)), + Math.ceil(size * Math.sin(Math.PI / 2)), + ); + + const colorStops = color === 'green' ? GREEN : BLUE; + colorStops.forEach((colorStop, index) => { + gradient.addColorStop(index / (colorStops.length - 1), colorStop); + }); + + ctx.lineCap = 'round'; + ctx.clearRect(0, 0, size, size); + + Array.from({ length: segmentsCount }).forEach((_, i) => { + const isRead = i < readSegmentsCount; + let startAngle = i * segmentAngle - Math.PI / 2 + gapSize / 2; + let endAngle = startAngle + segmentAngle - (segmentsCount > 1 ? gapSize : 0); + + ctx.strokeStyle = isRead ? readSegmentColor : gradient; + ctx.lineWidth = isRead ? STROKE_WIDTH_READ : STROKE_WIDTH; + + if (withExtraGap) { + if (startAngle >= EXTRA_GAP_START && endAngle <= EXTRA_GAP_END) { // Segment is inside extra gap + return; + } else if (startAngle < EXTRA_GAP_START && endAngle > EXTRA_GAP_END) { // Extra gap is inside segment + ctx.beginPath(); + ctx.arc(centerCoordinate, centerCoordinate, radius, EXTRA_GAP_END, endAngle); + ctx.stroke(); + + endAngle = EXTRA_GAP_START; + } else if (startAngle < EXTRA_GAP_START && endAngle > EXTRA_GAP_START) { // Segment ends in extra gap + endAngle = EXTRA_GAP_START; + } else if (startAngle < EXTRA_GAP_END && endAngle > EXTRA_GAP_END) { // Segment starts in extra gap + startAngle = EXTRA_GAP_END; + } + } + + ctx.beginPath(); + ctx.arc(centerCoordinate, centerCoordinate, radius, startAngle, endAngle); + ctx.stroke(); + }); +} diff --git a/src/components/common/ChatExtra.tsx b/src/components/common/ChatExtra.tsx index 4ee44d4cb..2a663f598 100644 --- a/src/components/common/ChatExtra.tsx +++ b/src/components/common/ChatExtra.tsx @@ -75,6 +75,7 @@ const ChatExtra: FC = ({ showNotification, updateChatMutedState, updateTopicMutedState, + loadUserStories, } = getActions(); const { @@ -95,6 +96,7 @@ const ChatExtra: FC = ({ useEffect(() => { if (!userId) return; loadFullUser({ userId }); + loadUserStories({ userId }); }, [userId]); const isTopicInfo = Boolean(topicId && topicId !== MAIN_THREAD_ID); diff --git a/src/components/common/ChatOrUserPicker.tsx b/src/components/common/ChatOrUserPicker.tsx index ec1291cb1..8e4f275b0 100644 --- a/src/components/common/ChatOrUserPicker.tsx +++ b/src/components/common/ChatOrUserPicker.tsx @@ -10,6 +10,7 @@ import { REM } from './helpers/mediaDimensions'; import { CHAT_HEIGHT_PX } from '../../config'; import renderText from './helpers/renderText'; import { getCanPostInChat, isUserId } from '../../global/helpers'; +import buildClassName from '../../util/buildClassName'; import useLastCallback from '../../hooks/useLastCallback'; import useInfiniteScroll from '../../hooks/useInfiniteScroll'; @@ -37,6 +38,7 @@ export type OwnProps = { isOpen: boolean; searchPlaceholder: string; search: string; + className?: string; loadMore?: NoneToVoidFunction; onSearchChange: (search: string) => void; onSelectChatOrUser: (chatOrUserId: string, threadId?: number) => void; @@ -55,6 +57,7 @@ const ChatOrUserPicker: FC = ({ chatsById, search, searchPlaceholder, + className, loadMore, onSearchChange, onSelectChatOrUser, @@ -264,7 +267,7 @@ const ChatOrUserPicker: FC = ({ return ( diff --git a/src/components/middle/composer/Composer.scss b/src/components/common/Composer.scss similarity index 84% rename from src/components/middle/composer/Composer.scss rename to src/components/common/Composer.scss index f6135bd31..bfbfdd9cf 100644 --- a/src/components/middle/composer/Composer.scss +++ b/src/components/common/Composer.scss @@ -1,4 +1,6 @@ .Composer { + --base-height: 3.5rem; + align-items: flex-end; .select-mode-active + .middle-column-footer & { @@ -40,7 +42,7 @@ to { /* stylelint-disable-next-line plugin/no-low-performance-animation-properties */ - width: 3.5rem; + width: var(--base-height); transform: scale(1); } } @@ -48,6 +50,8 @@ > .Button { flex-shrink: 0; margin-left: 0.5rem; + width: var(--base-height); + height: var(--base-height); &:not(.danger) { color: var(--color-composer-button); @@ -60,6 +64,7 @@ .icon-send, .icon-schedule, + .icon-forward, .icon-microphone-alt, .icon-check { position: absolute; @@ -69,6 +74,7 @@ &:not(:active):not(:focus):not(:hover) { .icon-send, .icon-schedule, + .icon-forward, .icon-check { color: var(--color-primary); } @@ -79,6 +85,7 @@ &:not(:active):not(:focus) { .icon-send, .icon-schedule, + .icon-forward, .icon-check { color: var(--color-primary); } @@ -107,6 +114,7 @@ } .icon-microphone-alt, + .icon-forward, .icon-check, .icon-schedule { animation: hide-icon 0.4s forwards ease-out; @@ -120,6 +128,7 @@ .icon-microphone-alt, .icon-check, + .icon-forward, .icon-send { animation: hide-icon 0.4s forwards ease-out; } @@ -132,6 +141,7 @@ .icon-send, .icon-check, + .icon-forward, .icon-schedule { animation: hide-icon 0.4s forwards ease-out; } @@ -143,6 +153,24 @@ } .icon-send, + .icon-forward, + .icon-microphone-alt, + .icon-schedule { + animation: hide-icon 0.4s forwards ease-out; + } + } + + &.forward { + --color-primary: #212121; + + .icon-forward { + --color-primary: #707478; + + animation: grow-icon 0.4s ease-out; + } + + .icon-send, + .icon-check, .icon-microphone-alt, .icon-schedule { animation: hide-icon 0.4s forwards ease-out; @@ -172,6 +200,32 @@ animation: 0.25s ease-in-out forwards show-send-as-button; transform-origin: right; } + + > .ReactionSelector { + --color-background-compact-menu: rgba(0, 0, 0, 0.3); + --color-interactive-element-hover: rgba(255, 255, 255, 0.1); + --color-text: #fff; + + left: 50%; + right: auto; + top: -3.875rem; + transform: translateX(-50%); + z-index: 1; + + @media (max-width: 600px) { + top: -4.25rem; + } + + .ReactionSelector__bubble-small, + .ReactionSelector__bubble-big { + display: none; + } + + .ReactionSelector__show-more { + transform: scaleY(-1); + color: #fff; + } + } } .mobile-symbol-menu-button { @@ -225,17 +279,20 @@ } } - -#message-compose { +.composer-wrapper { flex-grow: 1; max-width: calc(100% - 4rem); background: var(--color-background); - border-radius: var(--border-radius-messages); - border-bottom-right-radius: 0; - box-shadow: 0 1px 2px var(--color-default-shadow); + border-radius: var(--border-radius-default-small); position: relative; z-index: 1; + &.full-featured { + box-shadow: 0 1px 2px var(--color-default-shadow); + border-radius: var(--border-radius-messages); + border-bottom-right-radius: 0; + } + .svg-appendix { position: absolute; bottom: -0.1875rem; @@ -274,8 +331,8 @@ > .Button { flex-shrink: 0; background: none !important; - width: 3.5rem; - height: 3.5rem; + width: var(--base-height, 3.5rem); + height: var(--base-height, 3.5rem); margin: 0; padding: 0; align-self: flex-end; @@ -380,8 +437,8 @@ .recording-state { display: inline-block; position: relative; - line-height: 3.5rem; - height: 3.5rem; + line-height: var(--base-height); + height: var(--base-height); padding: 0 3.125rem 0 1rem; font-family: var(--font-family); font-variant-numeric: tabular-nums; @@ -424,7 +481,7 @@ } .input-scroller { - min-height: 3.5rem; + min-height: var(--base-height, 3.5rem); max-height: 26rem; overflow: hidden; @@ -474,12 +531,13 @@ } #message-input-text, +#story-input-text, #caption-input-text { position: relative; flex-grow: 1; .form-control { - padding: calc((3.5rem - var(--composer-text-size, 1rem) * 1.375) / 2) 0.875rem; + padding: calc((var(--base-height, 3.5rem) - var(--composer-text-size, 1rem) * 1.375) / 2) 0.875rem; overflow: hidden; height: auto; line-height: 1.375; @@ -495,8 +553,9 @@ caret-color: var(--color-text); &.touched { - & + .placeholder-text { - display: none; + & ~ .placeholder-text { + opacity: 0; + transform: translateX(1rem); } } @@ -520,6 +579,7 @@ overflow: hidden; text-overflow: ellipsis; max-width: 100%; + transition: opacity 200ms ease-out, transform 200ms ease-out; &.with-icon { display: inline-flex; @@ -544,8 +604,14 @@ } } - &[dir="rtl"] .placeholder-text { - right: 0; + &[dir="rtl"] { + .placeholder-text { + right: 0; + } + + .touched ~ .placeholder-text { + transform: translateX(-1rem); + } } .text-entity-link { @@ -561,7 +627,7 @@ } .spoiler { - background-image: url("../../../assets/spoiler-dots-black.png"); + background-image: url("../../assets/spoiler-dots-black.png"); background-size: auto min(100%, 1.125rem); border-radius: 0.5rem; padding: 0 0.3125rem 0.125rem 0.3125rem; @@ -570,7 +636,7 @@ } html.theme-dark & .spoiler { - background-image: url("../../../assets/spoiler-dots-white.png"); + background-image: url("../../assets/spoiler-dots-white.png"); } .clone { @@ -590,7 +656,7 @@ .form-control { margin-bottom: 0; line-height: 1.3125; - padding: calc((3.5rem - var(--composer-text-size, 1rem) * 1.3125) / 2) 0; + padding: calc((var(--base-height, 3.5rem) - var(--composer-text-size, 1rem) * 1.3125) / 2) 0; white-space: pre-wrap; height: auto; @@ -601,7 +667,7 @@ .forced-placeholder, .placeholder-text { - top: calc((3.5rem - var(--composer-text-size, 1rem) * 1.3125) / 2); + top: calc((var(--base-height, 3.5rem) - var(--composer-text-size, 1rem) * 1.3125) / 2); @media (max-width: 600px) { top: calc((2.875rem - var(--composer-text-size, 1rem) * 1.3125) / 2); @@ -627,10 +693,11 @@ } } +#story-input-text, #caption-input-text { - --margin-for-scrollbar: 5rem; + --margin-for-scrollbar: 2rem; .input-scroller { - min-height: 3.5rem; + min-height: var(--base-height, 3.5rem); max-height: 10rem; margin-right: calc((var(--margin-for-scrollbar) + 1rem) * -1); @@ -646,8 +713,8 @@ .placeholder-text { top: auto; - bottom: 1.125rem; - left: 0.9375rem; + bottom: 0.875rem; + left: 0.875rem; } } diff --git a/src/components/middle/composer/Composer.tsx b/src/components/common/Composer.tsx similarity index 69% rename from src/components/middle/composer/Composer.tsx rename to src/components/common/Composer.tsx index eccd5c060..2a26cb725 100644 --- a/src/components/middle/composer/Composer.tsx +++ b/src/components/common/Composer.tsx @@ -1,48 +1,50 @@ import React, { memo, useEffect, useMemo, useRef, useState, -} from '../../../lib/teact/teact'; -import { requestMeasure, requestNextMutation } from '../../../lib/fasterdom/fasterdom'; -import { getActions, withGlobal } from '../../../global'; +} from '../../lib/teact/teact'; +import { requestMeasure, requestNextMutation } from '../../lib/fasterdom/fasterdom'; +import { getActions, getGlobal, withGlobal } from '../../global'; -import type { FC } from '../../../lib/teact/teact'; +import type { FC } from '../../lib/teact/teact'; import type { TabState, MessageListType, GlobalState, ApiDraft, MessageList, -} from '../../../global/types'; +} from '../../global/types'; import type { - ApiAttachment, - ApiBotInlineResult, - ApiBotInlineMediaResult, - ApiSticker, - ApiVideo, - ApiNewPoll, - ApiMessage, - ApiFormattedText, - ApiChat, - ApiChatMember, - ApiUser, - ApiBotCommand, - ApiBotMenuButton, ApiAttachMenuPeerType, + ApiAttachment, + ApiAvailableReaction, + ApiBotCommand, + ApiBotInlineMediaResult, + ApiBotInlineResult, + ApiBotMenuButton, + ApiChat, ApiChatFullInfo, -} from '../../../api/types'; -import type { InlineBotSettings, ISettings } from '../../../types'; + ApiChatMember, + ApiFormattedText, + ApiMessage, + ApiMessageEntity, + ApiNewPoll, + ApiReaction, + ApiSticker, + ApiUser, + ApiVideo, +} from '../../api/types'; +import type { InlineBotSettings, ISettings, IAnchorPosition } from '../../types'; import { BASE_EMOJI_KEYWORD_LANG, - EDITABLE_INPUT_ID, - REPLIES_USER_ID, - SEND_MESSAGE_ACTION_INTERVAL, - EDITABLE_INPUT_CSS_SELECTOR, - MAX_UPLOAD_FILEPART_SIZE, EDITABLE_INPUT_MODAL_ID, + MAX_UPLOAD_FILEPART_SIZE, + REPLIES_USER_ID, SCHEDULED_WHEN_ONLINE, -} from '../../../config'; -import { IS_VOICE_RECORDING_SUPPORTED, IS_IOS } from '../../../util/windowEnvironment'; -import { MEMO_EMPTY_ARRAY } from '../../../util/memo'; + SEND_MESSAGE_ACTION_INTERVAL, +} from '../../config'; +import { IS_VOICE_RECORDING_SUPPORTED, IS_IOS } from '../../util/windowEnvironment'; +import { MEMO_EMPTY_ARRAY } from '../../util/memo'; import { + selectBot, + selectCanPlayAnimatedEmojis, selectCanScheduleUntilOnline, selectChat, - selectBot, selectChatFullInfo, selectChatMessage, selectChatType, @@ -54,6 +56,8 @@ import { selectIsChatWithSelf, selectIsCurrentUserPremium, selectIsInSelectMode, + selectIsPremiumPurchaseBlocked, + selectIsReactionPickerOpen, selectIsRightColumnShown, selectNewestMessageWithBotKeyboardButtons, selectReplyingToId, @@ -64,93 +68,107 @@ import { selectTheme, selectUser, selectUserFullInfo, -} from '../../../global/selectors'; +} from '../../global/selectors'; import { getAllowedAttachmentOptions, isChatAdmin, isChatChannel, isChatSuperGroup, isUserId, -} from '../../../global/helpers'; -import { formatMediaDuration, formatVoiceRecordDuration } from '../../../util/dateFormat'; -import focusEditableElement from '../../../util/focusEditableElement'; -import parseMessageInput from '../../../util/parseMessageInput'; -import buildAttachment, { prepareAttachmentsToSend } from './helpers/buildAttachment'; -import renderText from '../../common/helpers/renderText'; -import { insertHtmlInSelection } from '../../../util/selection'; -import deleteLastCharacterOutsideSelection from '../../../util/deleteLastCharacterOutsideSelection'; -import buildClassName from '../../../util/buildClassName'; -import windowSize from '../../../util/windowSize'; -import { isSelectionInsideInput } from './helpers/selection'; -import applyIosAutoCapitalizationFix from './helpers/applyIosAutoCapitalizationFix'; -import { getServerTime } from '../../../util/serverTime'; -import { selectCurrentLimit } from '../../../global/selectors/limits'; -import { buildCustomEmojiHtml } from './helpers/customEmoji'; -import { processMessageInputForCustomEmoji } from '../../../util/customEmojiManager'; -import { getTextWithEntitiesAsHtml } from '../../common/helpers/renderTextWithEntities'; +} from '../../global/helpers'; +import { formatMediaDuration, formatVoiceRecordDuration } from '../../util/dateFormat'; +import focusEditableElement from '../../util/focusEditableElement'; +import parseMessageInput from '../../util/parseMessageInput'; +import { insertHtmlInSelection } from '../../util/selection'; +import deleteLastCharacterOutsideSelection from '../../util/deleteLastCharacterOutsideSelection'; +import buildClassName from '../../util/buildClassName'; +import windowSize from '../../util/windowSize'; +import { getServerTime } from '../../util/serverTime'; +import { selectCurrentLimit } from '../../global/selectors/limits'; +import { processMessageInputForCustomEmoji } from '../../util/customEmojiManager'; +import { isSelectionInsideInput } from '../middle/composer/helpers/selection'; +import { getTextWithEntitiesAsHtml } from './helpers/renderTextWithEntities'; +import { buildCustomEmojiHtml } from '../middle/composer/helpers/customEmoji'; +import buildAttachment, { prepareAttachmentsToSend } from '../middle/composer/helpers/buildAttachment'; +import applyIosAutoCapitalizationFix from '../middle/composer/helpers/applyIosAutoCapitalizationFix'; +import renderText from './helpers/renderText'; -import useLastCallback from '../../../hooks/useLastCallback'; -import useSignal from '../../../hooks/useSignal'; -import useFlag from '../../../hooks/useFlag'; -import usePrevious from '../../../hooks/usePrevious'; -import useStickerTooltip from './hooks/useStickerTooltip'; -import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers'; -import useLang from '../../../hooks/useLang'; -import useSendMessageAction from '../../../hooks/useSendMessageAction'; -import useInterval from '../../../hooks/useInterval'; -import useSyncEffect from '../../../hooks/useSyncEffect'; -import useVoiceRecording from './hooks/useVoiceRecording'; -import useClipboardPaste from './hooks/useClipboardPaste'; -import useEditing from './hooks/useEditing'; -import useEmojiTooltip from './hooks/useEmojiTooltip'; -import useMentionTooltip from './hooks/useMentionTooltip'; -import useInlineBotTooltip from './hooks/useInlineBotTooltip'; -import useBotCommandTooltip from './hooks/useBotCommandTooltip'; -import useSchedule from '../../../hooks/useSchedule'; -import useCustomEmojiTooltip from './hooks/useCustomEmojiTooltip'; -import useAttachmentModal from './hooks/useAttachmentModal'; -import useGetSelectionRange from '../../../hooks/useGetSelectionRange'; -import useDerivedState from '../../../hooks/useDerivedState'; -import { useStateRef } from '../../../hooks/useStateRef'; -import useEffectWithPrevDeps from '../../../hooks/useEffectWithPrevDeps'; -import useDraft from './hooks/useDraft'; -import useTimeout from '../../../hooks/useTimeout'; +import useLastCallback from '../../hooks/useLastCallback'; +import useSignal from '../../hooks/useSignal'; +import useFlag from '../../hooks/useFlag'; +import usePrevious from '../../hooks/usePrevious'; +import useContextMenuHandlers from '../../hooks/useContextMenuHandlers'; +import useLang from '../../hooks/useLang'; +import useSendMessageAction from '../../hooks/useSendMessageAction'; +import useInterval from '../../hooks/useInterval'; +import useSyncEffect from '../../hooks/useSyncEffect'; +import useGetSelectionRange from '../../hooks/useGetSelectionRange'; +import useDerivedState from '../../hooks/useDerivedState'; +import { useStateRef } from '../../hooks/useStateRef'; +import useEffectWithPrevDeps from '../../hooks/useEffectWithPrevDeps'; +import useTimeout from '../../hooks/useTimeout'; +import useSchedule from '../../hooks/useSchedule'; +import useAttachmentModal from '../middle/composer/hooks/useAttachmentModal'; +import useVoiceRecording from '../middle/composer/hooks/useVoiceRecording'; +import useEmojiTooltip from '../middle/composer/hooks/useEmojiTooltip'; +import useCustomEmojiTooltip from '../middle/composer/hooks/useCustomEmojiTooltip'; +import useStickerTooltip from '../middle/composer/hooks/useStickerTooltip'; +import useMentionTooltip from '../middle/composer/hooks/useMentionTooltip'; +import useInlineBotTooltip from '../middle/composer/hooks/useInlineBotTooltip'; +import useBotCommandTooltip from '../middle/composer/hooks/useBotCommandTooltip'; +import useDraft from '../middle/composer/hooks/useDraft'; +import useEditing from '../middle/composer/hooks/useEditing'; +import useClipboardPaste from '../middle/composer/hooks/useClipboardPaste'; +import useShowTransition from '../../hooks/useShowTransition'; -import DeleteMessageModal from '../../common/DeleteMessageModal.async'; -import Button from '../../ui/Button'; -import ResponsiveHoverButton from '../../ui/ResponsiveHoverButton'; -import Spinner from '../../ui/Spinner'; -import AttachMenu from './AttachMenu'; -import Avatar from '../../common/Avatar'; -import InlineBotTooltip from './InlineBotTooltip.async'; -import MentionTooltip from './MentionTooltip.async'; -import CustomSendMenu from './CustomSendMenu.async'; -import StickerTooltip from './StickerTooltip.async'; -import CustomEmojiTooltip from './CustomEmojiTooltip.async'; -import EmojiTooltip from './EmojiTooltip.async'; -import BotCommandTooltip from './BotCommandTooltip.async'; -import BotKeyboardMenu from './BotKeyboardMenu'; -import MessageInput from './MessageInput'; -import ComposerEmbeddedMessage from './ComposerEmbeddedMessage'; -import AttachmentModal from './AttachmentModal.async'; -import BotCommandMenu from './BotCommandMenu.async'; -import PollModal from './PollModal.async'; -import DropArea, { DropAreaState } from './DropArea.async'; -import WebPagePreview from './WebPagePreview'; -import SendAsMenu from './SendAsMenu.async'; -import BotMenuButton from './BotMenuButton'; -import SymbolMenuButton from './SymbolMenuButton'; +import DropArea, { DropAreaState } from '../middle/composer/DropArea.async'; +import AttachmentModal from '../middle/composer/AttachmentModal.async'; +import PollModal from '../middle/composer/PollModal.async'; +import DeleteMessageModal from './DeleteMessageModal.async'; +import SendAsMenu from '../middle/composer/SendAsMenu.async'; +import MentionTooltip from '../middle/composer/MentionTooltip.async'; +import BotCommandTooltip from '../middle/composer/BotCommandTooltip.async'; +import InlineBotTooltip from '../middle/composer/InlineBotTooltip.async'; +import ComposerEmbeddedMessage from '../middle/composer/ComposerEmbeddedMessage'; +import WebPagePreview from '../middle/composer/WebPagePreview'; +import BotMenuButton from '../middle/composer/BotMenuButton'; +import ResponsiveHoverButton from '../ui/ResponsiveHoverButton'; +import Button from '../ui/Button'; +import Avatar from './Avatar'; +import SymbolMenuButton from '../middle/composer/SymbolMenuButton'; +import MessageInput from '../middle/composer/MessageInput'; +import Spinner from '../ui/Spinner'; +import AttachMenu from '../middle/composer/AttachMenu'; +import BotKeyboardMenu from '../middle/composer/BotKeyboardMenu'; +import BotCommandMenu from '../middle/composer/BotCommandMenu.async'; +import CustomEmojiTooltip from '../middle/composer/CustomEmojiTooltip.async'; +import StickerTooltip from '../middle/composer/StickerTooltip.async'; +import EmojiTooltip from '../middle/composer/EmojiTooltip.async'; +import CustomSendMenu from '../middle/composer/CustomSendMenu.async'; +import ReactionSelector from '../middle/message/ReactionSelector'; import './Composer.scss'; +type ComposerType = 'messageList' | 'story'; + type OwnProps = { + type: ComposerType; chatId: string; threadId: number; + storyId?: number; messageListType: MessageListType; - dropAreaState: string; + dropAreaState?: string; isReady: boolean; isMobile?: boolean; - onDropHide: NoneToVoidFunction; + onDropHide?: NoneToVoidFunction; + inputId: string; + editableInputCssSelector: string; + editableInputId: string; + className?: string; + inputPlaceholder?: string; + onForward?: NoneToVoidFunction; + onFocus?: NoneToVoidFunction; + onBlur?: NoneToVoidFunction; }; type StateProps = @@ -167,6 +185,7 @@ type StateProps = isForCurrentMessageList: boolean; isRightColumnShown?: boolean; isSelectModeActive?: boolean; + isReactionPickerOpen?: boolean; isForwarding?: boolean; pollModal: TabState['pollModal']; botKeyboardMessageId?: number; @@ -206,6 +225,10 @@ type StateProps = attachmentSettings: GlobalState['attachmentSettings']; slowMode?: ApiChatFullInfo['slowMode']; shouldUpdateStickerSetOrder?: boolean; + availableReactions?: ApiAvailableReaction[]; + topReactions?: ApiReaction[]; + canPlayAnimatedEmojis?: boolean; + canBuyPremium?: boolean; shouldCollectDebugLogs?: boolean; }; @@ -214,6 +237,7 @@ enum MainButtonState { Record = 'record', Edit = 'edit', Schedule = 'schedule', + Forward = 'forward', } type ScheduledMessageArgs = TabState['contentToBeScheduled'] | { @@ -231,6 +255,7 @@ const SENDING_ANIMATION_DURATION = 350; const MOUNT_ANIMATION_DURATION = 430; const Composer: FC = ({ + type, isOnActiveTab, dropAreaState, shouldSchedule, @@ -238,9 +263,12 @@ const Composer: FC = ({ isReady, isMobile, onDropHide, + onFocus, + onBlur, editingMessage, chatId, threadId, + storyId, currentMessageList, messageListType, draft, @@ -254,10 +282,12 @@ const Composer: FC = ({ fileSizeLimit, isRightColumnShown, isSelectModeActive, + isReactionPickerOpen, isForwarding, pollModal, botKeyboardMessageId, botKeyboardPlaceholder, + inputPlaceholder, withScheduledButton, stickersForEmoji, customEmojiForEmoji, @@ -289,7 +319,16 @@ const Composer: FC = ({ theme, slowMode, shouldUpdateStickerSetOrder, + editableInputCssSelector, + editableInputId, + inputId, + className, + availableReactions, + topReactions, + canBuyPremium, + canPlayAnimatedEmojis, shouldCollectDebugLogs, + onForward, }) => { const { sendMessage, @@ -308,6 +347,8 @@ const Composer: FC = ({ addRecentCustomEmoji, showNotification, showAllowedMessageTypesNotification, + openStoryReactionPicker, + closeReactionPicker, } = getActions(); const lang = useLang(); @@ -317,27 +358,41 @@ const Composer: FC = ({ const [getHtml, setHtml] = useSignal(''); const [isMounted, setIsMounted] = useState(false); - const getSelectionRange = useGetSelectionRange(EDITABLE_INPUT_CSS_SELECTOR); + const getSelectionRange = useGetSelectionRange(editableInputCssSelector); const lastMessageSendTimeSeconds = useRef(); const prevDropAreaState = usePrevious(dropAreaState); const { width: windowWidth } = windowSize.get(); - const sendAsPeerIds = chat?.sendAsPeerIds; + + const isInMessageList = type === 'messageList'; + const isInStoryViewer = type === 'story'; + const sendAsPeerIds = isInMessageList ? chat?.sendAsPeerIds : undefined; const canShowSendAs = sendAsPeerIds && (sendAsPeerIds.length > 1 || !sendAsPeerIds.some((peer) => peer.id === currentUserId!)); // Prevent Symbol Menu from closing when calendar is open const [isSymbolMenuForced, forceShowSymbolMenu, cancelForceShowSymbolMenu] = useFlag(); const sendMessageAction = useSendMessageAction(chatId, threadId); + const [isInputHasFocus, markInputHasFocus, unmarkInputHasFocus] = useFlag(); + const [isAttachMenuOpen, onAttachMenuOpen, onAttachMenuClose] = useFlag(); useEffect(processMessageInputForCustomEmoji, [getHtml]); const customEmojiNotificationNumber = useRef(0); - const [requestCalendar, calendar] = useSchedule(canScheduleUntilOnline, cancelForceShowSymbolMenu); + const [requestCalendar, calendar] = useSchedule( + isInMessageList && canScheduleUntilOnline, + cancelForceShowSymbolMenu, + ); useTimeout(() => { setIsMounted(true); }, MOUNT_ANIMATION_DURATION); + useEffect(() => { + if (isInMessageList) return; + + closeReactionPicker(); + }, [isInMessageList, storyId]); + useEffect(() => { lastMessageSendTimeSeconds.current = undefined; }, [chatId]); @@ -346,7 +401,7 @@ const Composer: FC = ({ if (chatId && isReady) { loadScheduledHistory({ chatId }); } - }, [isReady, chatId, loadScheduledHistory, threadId]); + }, [isReady, chatId, threadId]); useEffect(() => { if (chatId && chat && !sendAsPeerIds && isReady && isChatSuperGroup(chat)) { @@ -367,23 +422,26 @@ const Composer: FC = ({ const { canSendStickers, canSendGifs, canAttachMedia, canAttachPolls, canAttachEmbedLinks, canSendVoices, canSendPlainText, canSendAudios, canSendVideos, canSendPhotos, canSendDocuments, - } = useMemo(() => getAllowedAttachmentOptions(chat, isChatWithBot), [chat, isChatWithBot]); + } = useMemo( + () => getAllowedAttachmentOptions(chat, isChatWithBot, isInStoryViewer), + [chat, isChatWithBot, isInStoryViewer], + ); const isComposerBlocked = !canSendPlainText && !editingMessage; - const insertHtmlAndUpdateCursor = useLastCallback((newHtml: string, inputId: string = EDITABLE_INPUT_ID) => { - if (inputId === EDITABLE_INPUT_ID && isComposerBlocked) return; + const insertHtmlAndUpdateCursor = useLastCallback((newHtml: string, inInputId: string = editableInputId) => { + if (inInputId === editableInputId && isComposerBlocked) return; const selection = window.getSelection()!; let messageInput: HTMLDivElement; - if (inputId === EDITABLE_INPUT_ID) { - messageInput = document.querySelector(EDITABLE_INPUT_CSS_SELECTOR)!; + if (inInputId === editableInputId) { + messageInput = document.querySelector(editableInputCssSelector)!; } else { - messageInput = document.getElementById(inputId) as HTMLDivElement; + messageInput = document.getElementById(editableInputId) as HTMLDivElement; } if (selection.rangeCount) { const selectionRange = selection.getRangeAt(0); - if (isSelectionInsideInput(selectionRange, inputId)) { + if (isSelectionInsideInput(selectionRange, editableInputId)) { insertHtmlInSelection(newHtml); messageInput.dispatchEvent(new Event('input', { bubbles: true })); return; @@ -398,27 +456,29 @@ const Composer: FC = ({ }); }); - const insertTextAndUpdateCursor = useLastCallback((text: string, inputId: string = EDITABLE_INPUT_ID) => { + const insertTextAndUpdateCursor = useLastCallback(( + text: string, inInputId: string = editableInputId, + ) => { const newHtml = renderText(text, ['escape_html', 'emoji_html', 'br_html']) .join('') .replace(/\u200b+/g, '\u200b'); - insertHtmlAndUpdateCursor(newHtml, inputId); + insertHtmlAndUpdateCursor(newHtml, inInputId); }); const insertFormattedTextAndUpdateCursor = useLastCallback(( - text: ApiFormattedText, inputId: string = EDITABLE_INPUT_ID, + text: ApiFormattedText, inInputId: string = editableInputId, ) => { const newHtml = getTextWithEntitiesAsHtml(text); - insertHtmlAndUpdateCursor(newHtml, inputId); + insertHtmlAndUpdateCursor(newHtml, inInputId); }); - const insertCustomEmojiAndUpdateCursor = useLastCallback((emoji: ApiSticker, inputId: string = EDITABLE_INPUT_ID) => { - insertHtmlAndUpdateCursor(buildCustomEmojiHtml(emoji), inputId); + const insertCustomEmojiAndUpdateCursor = useLastCallback((emoji: ApiSticker, inInputId: string = editableInputId) => { + insertHtmlAndUpdateCursor(buildCustomEmojiHtml(emoji), inInputId); }); const insertNextText = useLastCallback(() => { if (!nextText) return; - insertFormattedTextAndUpdateCursor(nextText, EDITABLE_INPUT_ID); + insertFormattedTextAndUpdateCursor(nextText, editableInputId); setNextText(undefined); }); @@ -487,7 +547,8 @@ const Composer: FC = ({ filteredCustomEmojis, insertEmoji, } = useEmojiTooltip( - Boolean(isReady && isOnActiveTab && isForCurrentMessageList && shouldSuggestStickers && !hasAttachments), + Boolean(isReady && isOnActiveTab && (isInStoryViewer || isForCurrentMessageList) + && shouldSuggestStickers && !hasAttachments), getHtml, setHtml, undefined, @@ -501,7 +562,8 @@ const Composer: FC = ({ closeCustomEmojiTooltip, insertCustomEmoji, } = useCustomEmojiTooltip( - Boolean(isReady && isOnActiveTab && isForCurrentMessageList && shouldSuggestCustomEmoji && !hasAttachments), + Boolean(isReady && isOnActiveTab && (isInStoryViewer || isForCurrentMessageList) + && shouldSuggestCustomEmoji && !hasAttachments), getHtml, setHtml, getSelectionRange, @@ -515,7 +577,7 @@ const Composer: FC = ({ } = useStickerTooltip( Boolean(isReady && isOnActiveTab - && isForCurrentMessageList + && (isInStoryViewer || isForCurrentMessageList) && shouldSuggestStickers && canSendStickers && !hasAttachments), @@ -529,7 +591,7 @@ const Composer: FC = ({ insertMention, mentionFilteredUsers, } = useMentionTooltip( - Boolean(isReady && isForCurrentMessageList && !hasAttachments), + Boolean(isInMessageList && isReady && isForCurrentMessageList && !hasAttachments), getHtml, setHtml, getSelectionRange, @@ -550,7 +612,7 @@ const Composer: FC = ({ help: inlineBotHelp, loadMore: loadMoreForInlineBot, } = useInlineBotTooltip( - Boolean(isReady && isForCurrentMessageList && !hasAttachments), + Boolean(isInMessageList && isReady && isForCurrentMessageList && !hasAttachments), chatId, getHtml, inlineBots, @@ -561,13 +623,16 @@ const Composer: FC = ({ close: closeBotCommandTooltip, filteredBotCommands: botTooltipCommands, } = useBotCommandTooltip( - Boolean(isReady && isForCurrentMessageList && ((botCommands && botCommands?.length) || chatBotCommands?.length)), + Boolean(isInMessageList + && isReady + && isForCurrentMessageList + && ((botCommands && botCommands?.length) || chatBotCommands?.length)), getHtml, botCommands, chatBotCommands, ); - useDraft(draft, chatId, threadId, getHtml, setHtml, editingMessage); + useDraft(draft, chatId, threadId, getHtml, setHtml, editingMessage, isInStoryViewer); const resetComposer = useLastCallback((shouldPreserveInput = false) => { if (!shouldPreserveInput) { @@ -641,6 +706,10 @@ const Composer: FC = ({ }); const mainButtonState = useDerivedState(() => { + if (!isInputHasFocus && onForward && !(getHtml() && !hasAttachments)) { + return MainButtonState.Forward; + } + if (editingMessage && shouldForceShowEditing) { return MainButtonState.Edit; } @@ -655,7 +724,8 @@ const Composer: FC = ({ return MainButtonState.Send; }, [ - activeVoiceRecording, editingMessage, getHtml, hasAttachments, isForwarding, shouldForceShowEditing, shouldSchedule, + activeVoiceRecording, editingMessage, getHtml, hasAttachments, isForwarding, isInputHasFocus, onForward, + shouldForceShowEditing, shouldSchedule, ]); const canShowCustomSendMenu = !shouldSchedule; @@ -704,7 +774,7 @@ const Composer: FC = ({ const checkSlowMode = useLastCallback(() => { if (slowMode && !isAdmin) { - const messageInput = document.querySelector(EDITABLE_INPUT_CSS_SELECTOR); + const messageInput = document.querySelector(editableInputCssSelector); const nowSeconds = getServerTime(); const secondsSinceLastMessage = lastMessageSendTimeSeconds.current @@ -747,7 +817,7 @@ const Composer: FC = ({ isSilent?: boolean; scheduledAt?: number; }) => { - if (!currentMessageList) { + if (!currentMessageList && !storyId) { return; } @@ -795,7 +865,7 @@ const Composer: FC = ({ }); const handleSend = useLastCallback(async (isSilent = false, scheduledAt?: number) => { - if (!currentMessageList) { + if (!currentMessageList && !storyId) { return; } @@ -828,7 +898,7 @@ const Composer: FC = ({ if (!validateTextLength(text)) return; - const messageInput = document.querySelector(EDITABLE_INPUT_CSS_SELECTOR); + const messageInput = document.querySelector(editableInputCssSelector); if (text) { if (!checkSlowMode()) return; @@ -924,11 +994,11 @@ const Composer: FC = ({ resetOpenChatWithDraft(); requestNextMutation(() => { - const messageInput = document.getElementById(EDITABLE_INPUT_ID)!; + const messageInput = document.getElementById(editableInputId)!; focusEditableElement(messageInput, true); }); } - }, [requestedDraftText, resetOpenChatWithDraft, setHtml]); + }, [editableInputId, requestedDraftText, resetOpenChatWithDraft, setHtml]); useEffect(() => { if (requestedDraftFiles?.length) { @@ -937,13 +1007,13 @@ const Composer: FC = ({ } }, [handleFileSelect, requestedDraftFiles, resetOpenChatWithDraft]); - const handleCustomEmojiSelect = useLastCallback((emoji: ApiSticker, inputId?: string) => { + const handleCustomEmojiSelect = useLastCallback((emoji: ApiSticker, inInputId?: string) => { if (!emoji.isFree && !isCurrentUserPremium && !isChatWithSelf) { showCustomEmojiPremiumNotification(); return; } - insertCustomEmojiAndUpdateCursor(emoji, inputId); + insertCustomEmojiAndUpdateCursor(emoji, inInputId); }); const handleCustomEmojiSelectAttachmentModal = useLastCallback((emoji: ApiSticker) => { @@ -951,7 +1021,7 @@ const Composer: FC = ({ }); const handleGifSelect = useLastCallback((gif: ApiVideo, isSilent?: boolean, isScheduleRequested?: boolean) => { - if (!currentMessageList) { + if (!currentMessageList && !storyId) { return; } @@ -959,7 +1029,7 @@ const Composer: FC = ({ forceShowSymbolMenu(); requestCalendar((scheduledAt) => { cancelForceShowSymbolMenu(); - handleMessageSchedule({ gif, isSilent }, scheduledAt, currentMessageList); + handleMessageSchedule({ gif, isSilent }, scheduledAt, currentMessageList!); requestMeasure(() => { resetComposer(true); }); @@ -979,7 +1049,7 @@ const Composer: FC = ({ shouldPreserveInput = false, canUpdateStickerSetsOrder?: boolean, ) => { - if (!currentMessageList) { + if (!currentMessageList && !storyId) { return; } @@ -992,7 +1062,7 @@ const Composer: FC = ({ forceShowSymbolMenu(); requestCalendar((scheduledAt) => { cancelForceShowSymbolMenu(); - handleMessageSchedule({ sticker, isSilent }, scheduledAt, currentMessageList); + handleMessageSchedule({ sticker, isSilent }, scheduledAt, currentMessageList!); requestMeasure(() => { resetComposer(shouldPreserveInput); }); @@ -1013,7 +1083,7 @@ const Composer: FC = ({ const handleInlineBotSelect = useLastCallback(( inlineResult: ApiBotInlineResult | ApiBotInlineMediaResult, isSilent?: boolean, isScheduleRequested?: boolean, ) => { - if (!currentMessageList) { + if (!currentMessageList && !storyId) { return; } @@ -1023,18 +1093,18 @@ const Composer: FC = ({ id: inlineResult.id, queryId: inlineResult.queryId, isSilent, - }, scheduledAt, currentMessageList); + }, scheduledAt, currentMessageList!); }); } else { sendInlineBotResult({ id: inlineResult.id, queryId: inlineResult.queryId, isSilent, - messageList: currentMessageList, + messageList: currentMessageList!, }); } - const messageInput = document.querySelector(EDITABLE_INPUT_CSS_SELECTOR); + const messageInput = document.querySelector(editableInputCssSelector); if (IS_IOS && messageInput && messageInput === document.activeElement) { applyIosAutoCapitalizationFix(messageInput); } @@ -1082,7 +1152,7 @@ const Composer: FC = ({ }); const handleSendAsMenuOpen = useLastCallback(() => { - const messageInput = document.querySelector(EDITABLE_INPUT_CSS_SELECTOR); + const messageInput = document.querySelector(editableInputCssSelector); if (!isMobile || messageInput !== document.activeElement) { closeBotCommandMenu(); @@ -1109,12 +1179,12 @@ const Composer: FC = ({ insertTextAndUpdateCursor(text, EDITABLE_INPUT_MODAL_ID); }); - const removeSymbol = useLastCallback((inputId = EDITABLE_INPUT_ID) => { + const removeSymbol = useLastCallback((inInputId = editableInputId) => { const selection = window.getSelection()!; if (selection.rangeCount) { const selectionRange = selection.getRangeAt(0); - if (isSelectionInsideInput(selectionRange, inputId)) { + if (isSelectionInsideInput(selectionRange, inInputId)) { document.execCommand('delete', false); return; } @@ -1151,11 +1221,38 @@ const Composer: FC = ({ } }, [isSelectModeActive, enableHover, disableHover, isReady]); + const withBotMenuButton = isChatWithBot && botMenuButton?.type === 'webApp' && !editingMessage; + const isBotMenuButtonOpen = useDerivedState(() => { + return withBotMenuButton && !getHtml() && !activeVoiceRecording; + }, [withBotMenuButton, getHtml, activeVoiceRecording]); + + const isComposerHasFocus = isBotKeyboardOpen || isSymbolMenuOpen || isEmojiTooltipOpen || isSendAsMenuOpen + || isMentionTooltipOpen || isInlineBotTooltipOpen || isDeleteModalOpen || isBotCommandMenuOpen || isAttachMenuOpen + || isStickerTooltipOpen || isBotCommandTooltipOpen || isCustomEmojiTooltipOpen || isBotMenuButtonOpen + || isCustomSendMenuOpen || Boolean(activeVoiceRecording) || attachments.length > 0 || isInputHasFocus; + const isReactionSelectorOpen = (isComposerHasFocus || isReactionPickerOpen) + && isInStoryViewer && !isAttachMenuOpen && !isSymbolMenuOpen; + + useEffect(() => { + if (isComposerHasFocus) { + onFocus?.(); + } else { + onBlur?.(); + } + }, [isComposerHasFocus, onBlur, onFocus]); + + const { + shouldRender: shouldRenderReactionSelector, + transitionClassNames: reactionSelectorTransitonClassNames, + } = useShowTransition(isReactionSelectorOpen); const areVoiceMessagesNotAllowed = mainButtonState === MainButtonState.Record && (!canAttachMedia || !canSendVoiceByPrivacy || !canSendVoices); const mainButtonHandler = useLastCallback(() => { switch (mainButtonState) { + case MainButtonState.Forward: + onForward?.(); + break; case MainButtonState.Send: void handleSend(); break; @@ -1205,6 +1302,9 @@ const Composer: FC = ({ let sendButtonAriaLabel = 'SendMessage'; switch (mainButtonState) { + case MainButtonState.Forward: + sendButtonAriaLabel = 'Forward'; + break; case MainButtonState.Edit: sendButtonAriaLabel = 'Save edited message'; break; @@ -1214,13 +1314,43 @@ const Composer: FC = ({ : 'AccDescrVoiceMessage'; } - const className = buildClassName( + const fullClassName = buildClassName( 'Composer', !isSelectModeActive && 'shown', isHoverDisabled && 'hover-disabled', isMounted && 'mounted', + className, ); + const handleToggleReaction = useLastCallback((reaction: ApiReaction) => { + let text: string | undefined; + let entities: ApiMessageEntity[] | undefined; + + if ('emoticon' in reaction) { + text = reaction.emoticon; + } else { + const sticker = getGlobal().customEmojis.byId[reaction.documentId]; + if (!sticker) { + return; + } + + if (!sticker.isFree && !isCurrentUserPremium && !isChatWithSelf) { + showCustomEmojiPremiumNotification(); + return; + } + const customEmojiMessage = parseMessageInput(buildCustomEmojiHtml(sticker)); + text = customEmojiMessage.text; + entities = customEmojiMessage.entities; + } + + sendMessage({ text, entities, isReaction: true }); + closeReactionPicker(); + }); + + const handleReactionPickerOpen = useLastCallback((position: IAnchorPosition) => { + openStoryReactionPicker({ storyUserId: chatId, storyId: storyId!, position }); + }); + const handleSendScheduled = useLastCallback(() => { requestCalendar((scheduledAt) => { handleMessageSchedule({}, scheduledAt, currentMessageList!); @@ -1250,24 +1380,33 @@ const Composer: FC = ({ : mainButtonState === MainButtonState.Schedule ? handleSendScheduled : handleSend; - const withBotMenuButton = isChatWithBot && botMenuButton?.type === 'webApp' && !editingMessage; - const isBotMenuButtonOpen = useDerivedState(() => { - return withBotMenuButton && !getHtml() && !activeVoiceRecording; - }, [withBotMenuButton, getHtml, activeVoiceRecording]); - const withBotCommands = isChatWithBot && botMenuButton?.type === 'commands' && !editingMessage && botCommands !== false && !activeVoiceRecording; return ( -
- {canAttachMedia && isReady && ( +
+ {isInMessageList && canAttachMedia && isReady && ( )} + {shouldRenderReactionSelector && ( + + )} = ({ shouldForceCompression={shouldForceCompression} shouldForceAsFile={shouldForceAsFile} isForCurrentMessageList={isForCurrentMessageList} + isForMessage={isInMessageList} shouldSchedule={shouldSchedule} + forceDarkTheme={isInStoryViewer} onCaptionUpdate={onCaptionUpdate} onSendSilent={handleSendSilentAttachments} onSend={handleSendAttachments} @@ -1328,89 +1469,99 @@ const Composer: FC = ({ onClick={handleBotCommandSelect} onClose={closeBotCommandTooltip} /> -
- - - - - - - - - - - - - - - - - -
- {withBotMenuButton && ( - + {isInMessageList && ( + <> + + + + + + + + + + + + + + - )} - {withBotCommands && ( - - - - )} - {canShowSendAs && (sendAsUser || sendAsChat) && ( - + + + + )} +
+ {isInMessageList && ( + <> + {withBotMenuButton && ( + + )} + {withBotCommands && ( + + + + )} + {canShowSendAs && (sendAsUser || sendAsChat) && ( + + )} + )} {(!isComposerBlocked || canSendGifs || canSendStickers) && ( = ({ closeSymbolMenu={closeSymbolMenu} canSendStickers={canSendStickers} canSendGifs={canSendGifs} + isMessageComposer={isInMessageList} onGifSelect={handleGifSelect} onStickerSelect={handleStickerSelect} onCustomEmojiSelect={handleCustomEmojiSelect} @@ -1432,12 +1584,16 @@ const Composer: FC = ({ closeSendAsMenu={closeSendAsMenu} isSymbolMenuForced={isSymbolMenuForced} canSendPlainText={!isComposerBlocked} + inputCssSelector={editableInputCssSelector} + idPrefix={type} /> )} = ({ activeVoiceRecording && windowWidth <= SCREEN_WIDTH_TO_HIDE_PLACEHOLDER ? '' : (!isComposerBlocked - ? (botKeyboardPlaceholder || lang('Message')) + ? (botKeyboardPlaceholder || inputPlaceholder || lang('Message')) : lang('Chat.PlaceholderTextNotAllowed')) } forcedPlaceholder={inlineBotHelp} - canAutoFocus={isReady && isForCurrentMessageList && !hasAttachments} + canAutoFocus={isReady && isForCurrentMessageList && !hasAttachments && isInMessageList} noFocusInterception={hasAttachments} shouldSuppressFocus={isMobile && isSymbolMenuOpen} shouldSuppressTextFormatter={isEmojiTooltipOpen || isMentionTooltipOpen || isInlineBotTooltipOpen} onUpdate={setHtml} onSend={onSend} onSuppressedFocus={closeSymbolMenu} + onFocus={markInputHasFocus} + onBlur={unmarkInputHasFocus} /> - {isInlineBotLoading && Boolean(inlineBotId) && ( - - )} - {withScheduledButton && ( - - )} - {Boolean(botKeyboardMessageId) && !activeVoiceRecording && !editingMessage && ( - - - + {isInMessageList && ( + <> + {isInlineBotLoading && Boolean(inlineBotId) && ( + + )} + {withScheduledButton && ( + + )} + {Boolean(botKeyboardMessageId) && !activeVoiceRecording && !editingMessage && ( + + + + )} + )} {activeVoiceRecording && Boolean(currentRecordTime) && ( @@ -1504,19 +1666,21 @@ const Composer: FC = ({ onFileSelect={handleFileSelect} onPollCreate={openPollModal} isScheduled={shouldSchedule} - attachBots={attachBots} + attachBots={isInMessageList ? attachBots : undefined} peerType={attachMenuPeerType} shouldCollectDebugLogs={shouldCollectDebugLogs} theme={theme} + onMenuOpen={onAttachMenuOpen} + onMenuClose={onAttachMenuClose} /> - {Boolean(botKeyboardMessageId) && ( + {isInMessageList && Boolean(botKeyboardMessageId) && ( )} - {botCommands && ( + {isInMessageList && botCommands && ( = ({ /> )} = ({ onClose={closeCustomEmojiTooltip} /> = ({ onClose={closeStickerTooltip} /> = ({ {canShowCustomSendMenu && ( = ({ export default memo(withGlobal( (global, { - chatId, threadId, messageListType, isMobile, + chatId, threadId, messageListType, isMobile, type, }): StateProps => { const chat = selectChat(global, chatId); const chatBot = chatId !== REPLIES_USER_ID ? selectBot(global, chatId) : undefined; @@ -1635,6 +1809,7 @@ export default memo(withGlobal( const user = selectUser(global, chatId); const canSendVoiceByPrivacy = (user && !selectUserFullInfo(global, user.id)?.noVoiceMessages) ?? true; const slowMode = chatFullInfo?.slowMode; + const isCurrentUserPremium = selectIsCurrentUserPremium(global); const editingDraft = messageListType === 'scheduled' ? selectEditingScheduledDraft(global, chatId) @@ -1645,6 +1820,8 @@ export default memo(withGlobal( const tabState = selectTabState(global); return { + availableReactions: type === 'story' ? global.availableReactions : undefined, + topReactions: type === 'story' ? global.topReactions : undefined, isOnActiveTab: !tabState.isBlurred, editingMessage: selectEditingMessage(global, chatId, threadId, messageListType), replyingToId, @@ -1694,11 +1871,14 @@ export default memo(withGlobal( theme: selectTheme(global), fileSizeLimit: selectCurrentLimit(global, 'uploadMaxFileparts') * MAX_UPLOAD_FILEPART_SIZE, captionLimit: selectCurrentLimit(global, 'captionLength'), - isCurrentUserPremium: selectIsCurrentUserPremium(global), + isCurrentUserPremium, canSendVoiceByPrivacy, attachmentSettings: global.attachmentSettings, slowMode, currentMessageList, + isReactionPickerOpen: selectIsReactionPickerOpen(global), + canBuyPremium: !isCurrentUserPremium && !selectIsPremiumPurchaseBlocked(global), + canPlayAnimatedEmojis: selectCanPlayAnimatedEmojis(global), shouldCollectDebugLogs: global.settings.byKey.shouldCollectDebugLogs, }; }, diff --git a/src/components/common/CustomEmojiPicker.tsx b/src/components/common/CustomEmojiPicker.tsx index 124fe9a1f..5be66c61b 100644 --- a/src/components/common/CustomEmojiPicker.tsx +++ b/src/components/common/CustomEmojiPicker.tsx @@ -153,6 +153,7 @@ const CustomEmojiPicker: FC = ({ : Object.values(pickTruthy(customEmojisById!, recentCustomEmojiIds!)); }, [customEmojisById, isStatusPicker, recentCustomEmojiIds, recentStatusEmojis]); + const prefix = `${idPrefix}-custom-emoji`; const { activeSetIndex, observeIntersectionForSet, @@ -160,7 +161,7 @@ const CustomEmojiPicker: FC = ({ observeIntersectionForShowingItems, observeIntersectionForCovers, selectStickerSet, - } = useStickerPickerObservers(containerRef, headerRef, idPrefix, isHidden); + } = useStickerPickerObservers(containerRef, headerRef, prefix, isHidden); const lang = useLang(); @@ -401,7 +402,7 @@ const CustomEmojiPicker: FC = ({ stickerSet={stickerSet} loadAndPlay={Boolean(canAnimate && loadAndPlay)} index={i} - idPrefix={idPrefix} + idPrefix={prefix} observeIntersection={observeIntersectionForSet} observeIntersectionForPlayingItems={observeIntersectionForPlayingItems} observeIntersectionForShowingItems={observeIntersectionForShowingItems} diff --git a/src/components/common/EmbeddedMessage.scss b/src/components/common/EmbeddedMessage.scss index 3dbbb407a..c5c61c34a 100644 --- a/src/components/common/EmbeddedMessage.scss +++ b/src/components/common/EmbeddedMessage.scss @@ -69,6 +69,12 @@ font-size: calc(var(--message-text-size, 1rem) - 0.125rem); } + .icon { + font-size: 1.25rem; + line-height: 0.9375rem; + vertical-align: -0.1875rem; + } + .message-text { overflow: hidden; margin-inline-start: 0.5rem; @@ -113,6 +119,10 @@ border-radius: 0; } } + + &.with-message-color { + color: var(--accent-color); + } } .embedded-action-message { diff --git a/src/components/common/EmbeddedStory.tsx b/src/components/common/EmbeddedStory.tsx new file mode 100644 index 000000000..72009697b --- /dev/null +++ b/src/components/common/EmbeddedStory.tsx @@ -0,0 +1,118 @@ +import React, { useRef } from '../../lib/teact/teact'; +import { getActions } from '../../global'; + +import type { FC } from '../../lib/teact/teact'; +import type { ApiUser, ApiChat, ApiTypeStory } from '../../api/types'; +import type { ObserveFn } from '../../hooks/useIntersectionObserver'; + +import { + getSenderTitle, + getUserColorKey, + getStoryMediaHash, +} from '../../global/helpers'; +import renderText from './helpers/renderText'; +import { getPictogramDimensions } from './helpers/mediaDimensions'; +import buildClassName from '../../util/buildClassName'; + +import { useIsIntersecting } from '../../hooks/useIntersectionObserver'; +import useMedia from '../../hooks/useMedia'; +import useLang from '../../hooks/useLang'; +import { useFastClick } from '../../hooks/useFastClick'; +import useLastCallback from '../../hooks/useLastCallback'; + +import './EmbeddedMessage.scss'; + +type OwnProps = { + story?: ApiTypeStory; + sender?: ApiUser | ApiChat; + noUserColors?: boolean; + isProtected?: boolean; + observeIntersectionForLoading?: ObserveFn; + onClick: NoneToVoidFunction; +}; + +const NBSP = '\u00A0'; + +const EmbeddedStory: FC = ({ + story, + sender, + noUserColors, + isProtected, + observeIntersectionForLoading, + onClick, +}) => { + const { showNotification } = getActions(); + + const lang = useLang(); + + // eslint-disable-next-line no-null/no-null + const ref = useRef(null); + const isIntersecting = useIsIntersecting(ref, observeIntersectionForLoading); + const isFullStory = story && 'content' in story; + const isExpiredStory = story && 'isDeleted' in story; + const isVideoStory = isFullStory && Boolean(story.content.video); + const title = isFullStory ? 'Story' : (isExpiredStory ? 'ExpiredStory' : 'Loading'); + + const mediaBlobUrl = useMedia(isFullStory && getStoryMediaHash(story, 'pictogram'), !isIntersecting); + const mediaThumbnail = isVideoStory ? story.content.video!.thumbnail?.dataUri : undefined; + const pictogramUrl = mediaBlobUrl || mediaThumbnail; + + const senderTitle = sender ? getSenderTitle(lang, sender) : undefined; + const handleFastClick = useLastCallback(() => { + if (story && !isExpiredStory) { + onClick(); + } else { + showNotification({ + message: lang('StoryNotFound'), + }); + } + }); + + const { handleClick, handleMouseDown } = useFastClick(handleFastClick); + + return ( +
+ {pictogramUrl && renderPictogram(pictogramUrl, isProtected)} +
+

+ {isExpiredStory && ( + + )} + {lang(title)} +

+
{renderText(senderTitle || NBSP)}
+
+
+ ); +}; + +function renderPictogram( + srcUrl: string, + isProtected?: boolean, +) { + const { width, height } = getPictogramDimensions(); + + return ( +
+ + {isProtected && } +
+ ); +} + +export default EmbeddedStory; diff --git a/src/components/common/InviteLink.tsx b/src/components/common/InviteLink.tsx index e324a8111..188b2007d 100644 --- a/src/components/common/InviteLink.tsx +++ b/src/components/common/InviteLink.tsx @@ -58,12 +58,12 @@ const InviteLink: FC = ({ color="translucent" className={isOpen ? 'active' : ''} onClick={onTrigger} - ariaLabel="Actions" + ariaLabel={lang('AccDescrOpenMenu2')} > ); - }, [isMobile]); + }, [isMobile, lang]); return (
diff --git a/src/components/common/MessageSummary.tsx b/src/components/common/MessageSummary.tsx index c6a75983f..142c56516 100644 --- a/src/components/common/MessageSummary.tsx +++ b/src/components/common/MessageSummary.tsx @@ -66,7 +66,7 @@ function MessageSummary({ function renderMessageText() { return ( void; onFilterChange?: (value: string) => void; onDisabledClick?: (id: string) => void; @@ -61,6 +62,7 @@ const Picker: FC = ({ isSearchable, isRoundCheckbox, lockedIds, + forceShowSelf, onSelectedIdsChange, onFilterChange, onDisabledClick, @@ -134,6 +136,7 @@ const Picker: FC = ({ @@ -189,7 +192,7 @@ const Picker: FC = ({ > {!isRoundCheckbox ? renderCheckbox() : undefined} {isUserId(id) ? ( - + ) : ( )} diff --git a/src/components/common/PickerSelectedItem.tsx b/src/components/common/PickerSelectedItem.tsx index 24a4048c6..f2b24d88d 100644 --- a/src/components/common/PickerSelectedItem.tsx +++ b/src/components/common/PickerSelectedItem.tsx @@ -20,15 +20,16 @@ type OwnProps = { title?: string; isMinimized?: boolean; canClose?: boolean; - onClick: (arg: any) => void; + forceShowSelf?: boolean; clickArg: any; className?: string; + onClick: (arg: any) => void; }; type StateProps = { chat?: ApiChat; user?: ApiUser; - currentUserId?: string; + isSavedMessages?: boolean; }; const PickerSelectedItem: FC = ({ @@ -40,7 +41,7 @@ const PickerSelectedItem: FC = ({ chat, user, className, - currentUserId, + isSavedMessages, onClick, }) => { const lang = useLang(); @@ -61,13 +62,13 @@ const PickerSelectedItem: FC = ({ ); - const name = !chat || (user && !user.isSelf) + const name = !chat || (user && !isSavedMessages) ? getUserFirstOrLastName(user) - : getChatTitle(lang, chat, chat.id === currentUserId); + : getChatTitle(lang, chat, isSavedMessages); titleText = name ? renderText(name) : undefined; } @@ -103,18 +104,19 @@ const PickerSelectedItem: FC = ({ }; export default memo(withGlobal( - (global, { chatOrUserId }): StateProps => { + (global, { chatOrUserId, forceShowSelf }): StateProps => { if (!chatOrUserId) { return {}; } const chat = chatOrUserId ? selectChat(global, chatOrUserId) : undefined; const user = isUserId(chatOrUserId) ? selectUser(global, chatOrUserId) : undefined; + const isSavedMessages = !forceShowSelf && user && user.isSelf; return { chat, user, - currentUserId: global.currentUserId, + isSavedMessages, }; }, )(PickerSelectedItem)); diff --git a/src/components/common/PrivateChatInfo.tsx b/src/components/common/PrivateChatInfo.tsx index 812821677..b85b7baf5 100644 --- a/src/components/common/PrivateChatInfo.tsx +++ b/src/components/common/PrivateChatInfo.tsx @@ -23,6 +23,7 @@ import Avatar from './Avatar'; import TypingStatus from './TypingStatus'; import DotAnimation from './DotAnimation'; import FullNameTitle from './FullNameTitle'; +import RippleEffect from '../ui/RippleEffect'; type OwnProps = { userId: string; @@ -31,9 +32,11 @@ type OwnProps = { forceShowSelf?: boolean; status?: string; statusIcon?: string; + ripple?: boolean; withDots?: boolean; withMediaViewer?: boolean; withUsername?: boolean; + withStory?: boolean; withFullInfo?: boolean; withUpdatingStatus?: boolean; noEmojiStatus?: boolean; @@ -59,6 +62,7 @@ const PrivateChatInfo: FC = ({ withDots, withMediaViewer, withUsername, + withStory, withFullInfo, withUpdatingStatus, emojiStatusSize, @@ -70,6 +74,7 @@ const PrivateChatInfo: FC = ({ isSavedMessages, areMessagesLoaded, adminMember, + ripple, }) => { const { loadFullUser, @@ -177,12 +182,15 @@ const PrivateChatInfo: FC = ({ size={avatarSize} peer={user} isSavedMessages={isSavedMessages} + withStory={withStory} + storyViewerMode="single-user" onClick={withMediaViewer ? handleAvatarViewerOpen : undefined} />
{renderNameTitle()} {(status || (!isSavedMessages && !noStatusOrTyping)) && renderStatusOrTyping()}
+ {ripple && }
); }; diff --git a/src/components/common/RecipientPicker.tsx b/src/components/common/RecipientPicker.tsx index e0c89e561..70cddd70c 100644 --- a/src/components/common/RecipientPicker.tsx +++ b/src/components/common/RecipientPicker.tsx @@ -24,6 +24,7 @@ import useCurrentOrPrev from '../../hooks/useCurrentOrPrev'; export type OwnProps = { isOpen: boolean; searchPlaceholder: string; + className?: string; filter?: ApiChatType[]; loadMore?: NoneToVoidFunction; onSelectRecipient: (peerId: string, threadId?: number) => void; @@ -49,6 +50,7 @@ const RecipientPicker: FC = ({ pinnedIds, contactIds, filter = API_CHAT_TYPES, + className, searchPlaceholder, loadMore, onSelectRecipient, @@ -93,6 +95,7 @@ const RecipientPicker: FC = ({ return ( void; onCloseAnimationEnd?: () => void; }; @@ -28,8 +31,10 @@ const ReportModal: FC = ({ isOpen, subject = 'messages', chatId, + userId, photo, messageIds, + storyId, onClose, onCloseAnimationEnd, }) => { @@ -37,6 +42,7 @@ const ReportModal: FC = ({ reportMessages, reportPeer, reportProfilePhoto, + reportStory, exitMessageSelectMode, } = getActions(); @@ -57,6 +63,10 @@ const ReportModal: FC = ({ chatId, photo, reason: selectedReason, description, }); break; + case 'story': + reportStory({ + userId: userId!, storyId: storyId!, reason: selectedReason, description, + }); } onClose(); }); @@ -86,6 +96,7 @@ const ReportModal: FC = ({ (subject === 'messages' && !messageIds) || (subject === 'peer' && !chatId) || (subject === 'media' && (!chatId || !photo)) + || (subject === 'story' && (!storyId || !userId)) ) { return undefined; } @@ -100,7 +111,7 @@ const ReportModal: FC = ({ onClose={onClose} onEnter={isOpen ? handleReport : undefined} onCloseAnimationEnd={onCloseAnimationEnd} - className="narrow" + className={buildClassName('narrow', subject === 'story' && 'component-theme-dark')} title={title} > = ({ selectedReactionIds, withDefaultStatusIcon, isTranslucent, + noContextMenus, observeIntersection, observeIntersectionForPlayingItems, observeIntersectionForShowingItems, @@ -243,7 +245,7 @@ const StickerSet: FC = ({
= ({ isSavedMessages={isSavedMessages} isStatusPicker={isStatusPicker} canViewSet + noContextMenu={noContextMenus} isCurrentUserPremium={isCurrentUserPremium} sharedCanvasRef={canvasRef} withTranslucentThumb={isTranslucent} diff --git a/src/components/common/spoiler/Spoiler.scss b/src/components/common/spoiler/Spoiler.scss index 13d7d8a1d..4066a51a0 100644 --- a/src/components/common/spoiler/Spoiler.scss +++ b/src/components/common/spoiler/Spoiler.scss @@ -8,7 +8,8 @@ html.theme-dark &, html.theme-light .ListItem.selected &, .ActionMessage &, - .MediaViewerFooter & { + .MediaViewerFooter &, + #StoryViewer & { background-image: url('../../../assets/spoiler-dots-white.png'); } diff --git a/src/components/left/ArchivedChats.scss b/src/components/left/ArchivedChats.scss index 70d811ed7..a58b81436 100644 --- a/src/components/left/ArchivedChats.scss +++ b/src/components/left/ArchivedChats.scss @@ -2,8 +2,13 @@ height: 100%; overflow: hidden; - .chat-list { - height: calc(100% - var(--header-height)); + .left-header { + position: relative; + z-index: var(--z-left-header); + } + + .left-header-shadow { + box-shadow: 0 2px 2px var(--color-light-shadow); } .DropdownMenuFiller { @@ -32,4 +37,29 @@ .archived-chats-more-menu { margin-left: auto !important; } + + .story-toggler-wrapper { + flex-grow: 1; + position: relative; + } + + .chat-list-wrapper { + --story-ribbon-height: 5.5rem; + height: calc(100% - var(--header-height)); + position: relative; + + &.shown { + transform: translateY(calc(var(--story-ribbon-height) * -1)); + height: calc(100% - var(--header-height) + var(--story-ribbon-height)); + transition: none; + } + + &.open, &.closing { + transition: transform 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94); + } + + &.open { + transform: translateY(0); + } + } } diff --git a/src/components/left/ArchivedChats.tsx b/src/components/left/ArchivedChats.tsx index 440457896..d8235a712 100644 --- a/src/components/left/ArchivedChats.tsx +++ b/src/components/left/ArchivedChats.tsx @@ -20,6 +20,8 @@ import ChatList from './main/ChatList'; import ForumPanel from './main/ForumPanel'; import DropdownMenu from '../ui/DropdownMenu'; import MenuItem from '../ui/MenuItem'; +import StoryRibbon from '../story/StoryRibbon'; +import StoryToggler from '../story/StoryToggler'; import './ArchivedChats.scss'; @@ -27,6 +29,7 @@ export type OwnProps = { isActive: boolean; isForumPanelOpen?: boolean; archiveSettings: GlobalState['archiveSettings']; + isStoryRibbonShown?: boolean; onReset: () => void; onTopicSearch: NoneToVoidFunction; onSettingsScreenSelect: (screen: SettingsScreens) => void; @@ -38,6 +41,7 @@ const ArchivedChats: FC = ({ isActive, isForumPanelOpen, archiveSettings, + isStoryRibbonShown, onReset, onTopicSearch, onSettingsScreenSelect, @@ -72,9 +76,15 @@ const ArchivedChats: FC = ({ } = useForumPanelRender(isForumPanelOpen); const isForumPanelVisible = isForumPanelOpen && isAnimationStarted; + const { + shouldRender: shouldRenderStoryRibbon, + transitionClassNames: storyRibbonClassNames, + isClosing: isStoryRibbonClosing, + } = useShowTransition(isStoryRibbonShown, undefined, undefined, ''); + return (
-
+
{lang.isRtl &&
} {shouldRenderTitle &&

{lang('ArchivedChats')}

} +
+ +
{archiveSettings.isHidden && ( = ({ )}
- +
+ {shouldRenderStoryRibbon && ( + + )} + +
{shouldRenderForumPanel && ( ); case ContentType.Settings: @@ -525,6 +528,9 @@ export default memo(withGlobal( activeChatFolder, nextSettingsScreen, nextFoldersAction, + storyViewer: { + isArchivedRibbonShown, + }, } = tabState; const { currentUserId, @@ -555,6 +561,7 @@ export default memo(withGlobal( forumPanelChatId, isClosingSearch: tabState.globalSearch.isClosing, archiveSettings, + isArchivedStoryRibbonShown: isArchivedRibbonShown, }; }, )(LeftColumn)); diff --git a/src/components/left/main/Chat.scss b/src/components/left/main/Chat.scss index 81eb29b1a..95976285c 100644 --- a/src/components/left/main/Chat.scss +++ b/src/components/left/main/Chat.scss @@ -2,6 +2,11 @@ --background-color: var(--color-background); --thumbs-background: var(--background-color); + --z-forum-indicator: 2; + --z-badge: 4; + --z-ripple: 6; + --z-status: 8; // Avatar stories require a higher z-index than the ripple to work + body.is-ios &, body.is-macos & { --color-text-meta: var(--color-text-meta-apple); @@ -168,7 +173,7 @@ } background: var(--color-primary); - z-index: 1; + z-index: var(--z-forum-indicator); border-start-end-radius: var(--border-radius-default); border-end-end-radius: var(--border-radius-default); @@ -191,7 +196,7 @@ } .ripple-container { - z-index: 2; + z-index: var(--z-ripple); } .status { @@ -199,15 +204,15 @@ align-self: stretch; display: flex; align-items: center; - z-index: 1; - background: var(--background-color); + z-index: var(--z-status); + background-color: var(--background-color); } .avatar-badge-wrapper { position: absolute; bottom: 0; right: 0.5rem; - z-index: 2; + z-index: var(--z-badge); --outline-color: var(--color-background); @@ -312,8 +317,14 @@ } } - .colon { - margin-inline-end: 0.25rem; + .colon, .forward { + margin-inline-end: 0.1875rem; + } + + .forward { + font-size: 0.875rem; + display: inline-block; + transform: translateY(1px); } .media-preview-spoiler { diff --git a/src/components/left/main/Chat.tsx b/src/components/left/main/Chat.tsx index 333359b84..f6b517f52 100644 --- a/src/components/left/main/Chat.tsx +++ b/src/components/left/main/Chat.tsx @@ -259,10 +259,13 @@ const Chat: FC = ({ onDragEnter={handleDragEnter} withPortalForMenu > -
+
diff --git a/src/components/left/main/ChatFolders.tsx b/src/components/left/main/ChatFolders.tsx index 4192989aa..bd237bdca 100644 --- a/src/components/left/main/ChatFolders.tsx +++ b/src/components/left/main/ChatFolders.tsx @@ -1,6 +1,6 @@ import type { FC } from '../../../lib/teact/teact'; import React, { - memo, useEffect, useMemo, useRef, + memo, useEffect, useLayoutEffect, useMemo, useRef, useState, } from '../../../lib/teact/teact'; import { getActions, getGlobal, withGlobal } from '../../../global'; @@ -10,7 +10,7 @@ import type { FolderEditDispatch } from '../../../hooks/reducers/useFoldersReduc import type { GlobalState } from '../../../global/types'; import type { TabWithProperties } from '../../ui/TabList'; -import { ALL_FOLDER_ID } from '../../../config'; +import { ALL_FOLDER_ID, ANIMATION_END_DELAY } from '../../../config'; import { IS_TOUCH_ENV } from '../../../util/windowEnvironment'; import { MEMO_EMPTY_ARRAY } from '../../../util/memo'; import { captureEvents, SwipeDirection } from '../../../util/captureEvents'; @@ -28,6 +28,7 @@ import { useFolderManagerForUnreadCounters } from '../../../hooks/useFolderManag import Transition from '../../ui/Transition'; import TabList from '../../ui/TabList'; import ChatList from './ChatList'; +import StoryRibbon from '../../story/StoryRibbon'; type OwnProps = { onSettingsScreenSelect: (screen: SettingsScreens) => void; @@ -48,11 +49,14 @@ type StateProps = { maxChatLists: number; maxFolderInvites: number; hasArchivedChats?: boolean; + hasArchivedStories?: boolean; archiveSettings: GlobalState['archiveSettings']; + isStoryRibbonShown?: boolean; }; const SAVED_MESSAGES_HOTKEY = '0'; const FIRST_FOLDER_INDEX = 0; +const STORY_RIBBON_APPEARANCE_DURATION_MS = 200 + ANIMATION_END_DELAY; const ChatFolders: FC = ({ foldersDispatch, @@ -70,7 +74,9 @@ const ChatFolders: FC = ({ folderInvitesById, maxFolderInvites, hasArchivedChats, + hasArchivedStories, archiveSettings, + isStoryRibbonShown, }) => { const { loadChatFolders, @@ -86,11 +92,34 @@ const ChatFolders: FC = ({ const transitionRef = useRef(null); const lang = useLang(); + const [isStoryRibbonAnimated, setIsStoryRibbonAnimated] = useState(false); useEffect(() => { loadChatFolders(); }, []); + useLayoutEffect(() => { + let timeoutId: number; + + if (isStoryRibbonShown) { + timeoutId = window.setTimeout(() => { + setIsStoryRibbonAnimated(true); + }, STORY_RIBBON_APPEARANCE_DURATION_MS); + } else { + setIsStoryRibbonAnimated(false); + } + + return () => { + window.clearTimeout(timeoutId); + }; + }, [isStoryRibbonShown]); + + const { + shouldRender: shouldRenderStoryRibbon, + transitionClassNames: storyRibbonClassNames, + isClosing: isStoryRibbonClosing, + } = useShowTransition(isStoryRibbonShown, undefined, undefined, ''); + const allChatsFolder: ApiChatFolder = useMemo(() => { return { id: ALL_FOLDER_ID, @@ -285,7 +314,7 @@ const ChatFolders: FC = ({ foldersDispatch={foldersDispatch} onSettingsScreenSelect={onSettingsScreenSelect} onLeftColumnContentChange={onLeftColumnContentChange} - canDisplayArchive={hasArchivedChats && !archiveSettings.isHidden} + canDisplayArchive={(hasArchivedChats || hasArchivedStories) && !archiveSettings.isHidden} archiveSettings={archiveSettings} /> ); @@ -298,8 +327,11 @@ const ChatFolders: FC = ({ className={buildClassName( 'ChatFolders', shouldRenderFolders && shouldHideFolderTabs && 'ChatFolders--tabs-hidden', + shouldRenderStoryRibbon && !isStoryRibbonAnimated && 'withStoryRibbon', + storyRibbonClassNames, )} > + {shouldRenderStoryRibbon && } {shouldRenderFolders ? ( ( archived, }, }, + stories: { + orderedUserIds: { + archived: archivedStories, + }, + }, currentUserId, archiveSettings, } = global; const { shouldSkipHistoryAnimations, activeChatFolder } = selectTabState(global); + const { storyViewer: { isRibbonShown: isStoryRibbonShown } } = selectTabState(global); return { chatFoldersById, @@ -349,10 +387,12 @@ export default memo(withGlobal( currentUserId, shouldSkipHistoryAnimations, hasArchivedChats: Boolean(archived?.length), + hasArchivedStories: Boolean(archivedStories?.length), maxFolders: selectCurrentLimit(global, 'dialogFilters'), maxFolderInvites: selectCurrentLimit(global, 'chatlistInvites'), maxChatLists: selectCurrentLimit(global, 'chatlistJoined'), archiveSettings, + isStoryRibbonShown, }; }, )(ChatFolders)); diff --git a/src/components/left/main/ChatList.tsx b/src/components/left/main/ChatList.tsx index 3eeb7c2f5..f49a8ac6c 100644 --- a/src/components/left/main/ChatList.tsx +++ b/src/components/left/main/ChatList.tsx @@ -27,12 +27,14 @@ import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver' import { useHotkeys } from '../../../hooks/useHotkeys'; import useDebouncedCallback from '../../../hooks/useDebouncedCallback'; import useOrderDiff from './hooks/useOrderDiff'; +import useUserStoriesPolling from '../../../hooks/polling/useUserStoriesPolling'; import InfiniteScroll from '../../ui/InfiniteScroll'; import Loading from '../../ui/Loading'; import Chat from './Chat'; import EmptyFolder from './EmptyFolder'; import Archive from './Archive'; +import useTopOverscroll from '../../../hooks/scroll/useTopOverscroll'; type OwnProps = { folderType: 'all' | 'archived' | 'folder'; @@ -41,6 +43,8 @@ type OwnProps = { canDisplayArchive?: boolean; archiveSettings: GlobalState['archiveSettings']; isForumPanelOpen?: boolean; + isStoryRibbonShown?: boolean; + className?: string; foldersDispatch: FolderEditDispatch; onSettingsScreenSelect: (screen: SettingsScreens) => void; onLeftColumnContentChange: (content: LeftColumnContent) => void; @@ -61,18 +65,25 @@ const ChatList: FC = ({ onSettingsScreenSelect, onLeftColumnContentChange, }) => { - const { openChat, openNextChat, closeForumPanel } = getActions(); + const { + openChat, + openNextChat, + closeForumPanel, + toggleStoryRibbon, + } = getActions(); // eslint-disable-next-line no-null/no-null const containerRef = useRef(null); const shouldIgnoreDragRef = useRef(false); + const isArchived = folderType === 'archived'; const resolvedFolderId = ( - folderType === 'all' ? ALL_FOLDER_ID : folderType === 'archived' ? ARCHIVED_FOLDER_ID : folderId! + folderType === 'all' ? ALL_FOLDER_ID : isArchived ? ARCHIVED_FOLDER_ID : folderId! ); const shouldDisplayArchive = folderType === 'all' && canDisplayArchive; const orderedIds = useFolderManagerForOrderedIds(resolvedFolderId); + useUserStoriesPolling(orderedIds); const chatsHeight = (orderedIds?.length || 0) * CHAT_HEIGHT_PX; const archiveHeight = shouldDisplayArchive @@ -162,6 +173,16 @@ const ChatList: FC = ({ shouldIgnoreDragRef.current = true; }); + const handleShowStoryRibbon = useLastCallback(() => { + toggleStoryRibbon({ isShown: true, isArchived }); + }); + + const handleHideStoryRibbon = useLastCallback(() => { + toggleStoryRibbon({ isShown: false, isArchived }); + }); + + const renderedOverflowTrigger = useTopOverscroll(containerRef, handleShowStoryRibbon, handleHideStoryRibbon); + function renderChats() { const viewportOffset = orderedIds!.indexOf(viewportIds![0]); @@ -196,6 +217,7 @@ const ChatList: FC = ({ itemSelector=".ListItem:not(.chat-item-archive)" preloadBackwards={CHAT_LIST_SLICE} withAbsolutePositioning + beforeChildren={renderedOverflowTrigger} maxHeight={chatsHeight + archiveHeight} onLoadMore={getMore} onDragLeave={handleDragLeave} diff --git a/src/components/left/main/ContactList.tsx b/src/components/left/main/ContactList.tsx index e9c984826..33cdc40de 100644 --- a/src/components/left/main/ContactList.tsx +++ b/src/components/left/main/ContactList.tsx @@ -71,12 +71,11 @@ const ContactList: FC = ({ viewportIds.map((id) => ( handleClick(id)} - ripple={!isMobile} > - + )) ) : viewportIds && !viewportIds.length ? ( diff --git a/src/components/left/main/LeftMain.scss b/src/components/left/main/LeftMain.scss index bd38722d3..0fbd15f07 100644 --- a/src/components/left/main/LeftMain.scss +++ b/src/components/left/main/LeftMain.scss @@ -16,6 +16,23 @@ flex-direction: column; overflow: hidden; + &.withStoryRibbon { + --story-ribbon-height: 5.5rem; + &.shown { + transform: translateY(calc(var(--story-ribbon-height) * -1)); + height: calc(100% + var(--story-ribbon-height)); + transition: none; + } + + &.open, &.closing { + transition: transform 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94); + } + + &.open { + transform: translateY(0); + } + } + .tabs-placeholder { height: 2.625rem; /* stylelint-disable-next-line plugin/no-low-performance-animation-properties */ diff --git a/src/components/left/main/LeftMainHeader.tsx b/src/components/left/main/LeftMainHeader.tsx index f95387ba6..c991377c6 100644 --- a/src/components/left/main/LeftMainHeader.tsx +++ b/src/components/left/main/LeftMainHeader.tsx @@ -61,6 +61,7 @@ import ShowTransition from '../../ui/ShowTransition'; import ConnectionStatusOverlay from '../ConnectionStatusOverlay'; import StatusButton from './StatusButton'; import Toggle from '../../ui/Toggle'; +import StoryToggler from '../../story/StoryToggler'; import './LeftMainHeader.scss'; @@ -132,6 +133,7 @@ const LeftMainHeader: FC = ({ }) => { const { openChat, + openChatWithInfo, setGlobalSearchDate, setSettingOption, setGlobalSearchChatId, @@ -173,6 +175,10 @@ const LeftMainHeader: FC = ({ } }); + const handleOpenMyStories = useLastCallback(() => { + openChatWithInfo({ id: currentUserId, shouldReplaceHistory: true, profileTab: 'stories' }); + }); + useHotkeys(canSetPasscode ? { 'Ctrl+Shift+L': handleLockScreenHotkey, 'Alt+Shift+L': handleLockScreenHotkey, @@ -317,6 +323,12 @@ const LeftMainHeader: FC = ({ > {lang('Contacts')} + + {lang('Settings.MyStories')} + = ({ onSpinnerClick={connectionStatusPosition === 'minimized' ? toggleConnectionStatus : undefined} > {searchContent} + {isCurrentUserPremium && } {hasPasscode && ( diff --git a/src/components/left/main/StatusPickerMenu.module.scss b/src/components/left/main/StatusPickerMenu.module.scss index 0f0bbbcea..f5b8c802d 100644 --- a/src/components/left/main/StatusPickerMenu.module.scss +++ b/src/components/left/main/StatusPickerMenu.module.scss @@ -13,7 +13,7 @@ :global(body:not(.no-menu-blur)) & { --color-background: var(--color-background-compact-menu); - backdrop-filter: blur(10px); + backdrop-filter: blur(25px); } @media (max-width: 26rem) { diff --git a/src/components/left/main/hooks/useChatListEntry.tsx b/src/components/left/main/hooks/useChatListEntry.tsx index 0ec6606ed..c74d9dc12 100644 --- a/src/components/left/main/hooks/useChatListEntry.tsx +++ b/src/components/left/main/hooks/useChatListEntry.tsx @@ -151,6 +151,7 @@ export default function useChatListEntry({ : )} + {lastMessage.forwardInfo && ()} {renderSummary(lang, lastMessage, observeIntersection, mediaBlobUrl || mediaThumbnail, isRoundVideo)}

); diff --git a/src/components/left/search/LeftSearch.scss b/src/components/left/search/LeftSearch.scss index 5db7a2a71..c18c26106 100644 --- a/src/components/left/search/LeftSearch.scss +++ b/src/components/left/search/LeftSearch.scss @@ -117,6 +117,10 @@ .ListItem.search-result { .ChatInfo { + // Fix for overflow hidden and stories indicator + padding: 0.0625rem; + margin: -0.0625rem; + .handle { unicode-bidi: plaintext; color: var(--color-primary); diff --git a/src/components/left/search/LeftSearchResultChat.tsx b/src/components/left/search/LeftSearchResultChat.tsx index 52c654d28..68c17e879 100644 --- a/src/components/left/search/LeftSearchResultChat.tsx +++ b/src/components/left/search/LeftSearchResultChat.tsx @@ -85,7 +85,7 @@ const LeftSearchResultChat: FC = ({ buttonRef={buttonRef} > {isUserId(chatId) ? ( - + ) : ( )} diff --git a/src/components/main/ForwardRecipientPicker.tsx b/src/components/main/ForwardRecipientPicker.tsx index fc3ac134b..50109226f 100644 --- a/src/components/main/ForwardRecipientPicker.tsx +++ b/src/components/main/ForwardRecipientPicker.tsx @@ -2,13 +2,15 @@ import type { FC } from '../../lib/teact/teact'; import React, { memo, useCallback, useEffect, } from '../../lib/teact/teact'; -import { getActions, withGlobal } from '../../global'; +import { getActions, getGlobal, withGlobal } from '../../global'; -import { selectTabState } from '../../global/selectors'; +import { selectChat, selectTabState, selectUser } from '../../global/selectors'; +import { getChatTitle, getUserFirstOrLastName, isUserId } from '../../global/helpers'; import useLang from '../../hooks/useLang'; import useFlag from '../../hooks/useFlag'; import RecipientPicker from '../common/RecipientPicker'; +import usePrevious from '../../hooks/usePrevious'; export type OwnProps = { isOpen: boolean; @@ -17,22 +19,26 @@ export type OwnProps = { interface StateProps { currentUserId?: string; isManyMessages?: boolean; + isStory?: boolean; } const ForwardRecipientPicker: FC = ({ isOpen, currentUserId, isManyMessages, + isStory, }) => { const { setForwardChatOrTopic, exitForwardMode, forwardToSavedMessages, + forwardStory, showNotification, } = getActions(); const lang = useLang(); + const renderingIsStory = usePrevious(isStory, true); const [isShown, markIsShown, unmarkIsShown] = useFlag(); useEffect(() => { if (isOpen) { @@ -41,17 +47,43 @@ const ForwardRecipientPicker: FC = ({ }, [isOpen, markIsShown]); const handleSelectRecipient = useCallback((recipientId: string, threadId?: number) => { - if (recipientId === currentUserId) { - forwardToSavedMessages(); - showNotification({ - message: lang(isManyMessages + const isSelf = recipientId === currentUserId; + if (isStory) { + forwardStory({ toChatId: recipientId }); + const global = getGlobal(); + if (isUserId(recipientId)) { + showNotification({ + message: isSelf + ? lang('Conversation.StoryForwardTooltip.SavedMessages.One') + : lang( + 'StorySharedTo', + getUserFirstOrLastName(selectUser(global, recipientId)), + ), + }); + } else { + const chat = selectChat(global, recipientId); + if (!chat) return; + + showNotification({ + message: lang('StorySharedTo', getChatTitle(lang, chat)), + }); + } + return; + } + + if (isSelf) { + const message = lang( + isManyMessages ? 'Conversation.ForwardTooltip.SavedMessages.Many' - : 'Conversation.ForwardTooltip.SavedMessages.One'), - }); + : 'Conversation.ForwardTooltip.SavedMessages.One', + ); + + forwardToSavedMessages(); + showNotification({ message }); } else { setForwardChatOrTopic({ chatId: recipientId, topicId: threadId }); } - }, [currentUserId, forwardToSavedMessages, isManyMessages, lang, setForwardChatOrTopic, showNotification]); + }, [currentUserId, isManyMessages, isStory, lang]); const handleClose = useCallback(() => { exitForwardMode(); @@ -64,6 +96,7 @@ const ForwardRecipientPicker: FC = ({ return ( = ({ }; export default memo(withGlobal((global): StateProps => { + const { messageIds, storyId } = selectTabState(global).forwardMessages; return { currentUserId: global.currentUserId, - isManyMessages: (selectTabState(global).forwardMessages.messageIds?.length || 0) > 1, + isManyMessages: (messageIds?.length || 0) > 1, + isStory: Boolean(storyId), }; })(ForwardRecipientPicker)); diff --git a/src/components/main/Main.scss b/src/components/main/Main.scss index 3a43633ac..4e605292d 100644 --- a/src/components/main/Main.scss +++ b/src/components/main/Main.scss @@ -40,10 +40,6 @@ background-color: var(--color-background); - @media (max-width: 600px) { - height: calc(var(--vh, 1vh) * 100); - } - @media (min-width: 926px) { --left-column-max-width: 40vw; } @@ -115,6 +111,7 @@ } @media (max-width: 600px) { + height: calc(var(--vh, 1vh) * 100); max-width: none; --left-column-max-width: calc(100vw - env(safe-area-inset-left)); transform: translate3d(-20vw, 0, 0); @@ -126,6 +123,18 @@ left: 0 !important; width: 100vw !important; } + + // Fix: when opening the SymbolMenu, the chat list flashes in the background + body.is-symbol-menu-open &::before { + content: ''; + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + background: var(--color-background); + z-index: 1; + } } } diff --git a/src/components/main/Main.tsx b/src/components/main/Main.tsx index 60905e5d2..45d2727d2 100644 --- a/src/components/main/Main.tsx +++ b/src/components/main/Main.tsx @@ -36,6 +36,7 @@ import { selectPerformanceSettingsValue, selectCanAnimateInterface, selectChatFolder, + selectIsStoryViewerOpen, } from '../../global/selectors'; import { getUserFullName } from '../../global/helpers'; import buildClassName from '../../util/buildClassName'; @@ -57,7 +58,6 @@ import useInterval from '../../hooks/useInterval'; import { useFullscreenStatus } from '../../hooks/useFullscreen'; import useAppLayout from '../../hooks/useAppLayout'; import useTimeout from '../../hooks/useTimeout'; -import useFlag from '../../hooks/useFlag'; import StickerSetModal from '../common/StickerSetModal.async'; import UnreadCount from '../common/UnreadCounter'; @@ -94,6 +94,7 @@ import DraftRecipientPicker from './DraftRecipientPicker.async'; import AttachBotRecipientPicker from './AttachBotRecipientPicker.async'; import ReactionPicker from '../middle/message/ReactionPicker.async'; import ChatlistModal from '../modals/chatlist/ChatlistModal.async'; +import StoryViewer from '../story/StoryViewer.async'; import './Main.scss'; @@ -108,6 +109,7 @@ type StateProps = { isMiddleColumnOpen: boolean; isRightColumnOpen: boolean; isMediaViewerOpen: boolean; + isStoryViewerOpen: boolean; isForwardModalOpen: boolean; hasNotifications: boolean; hasDialogs: boolean; @@ -152,7 +154,6 @@ type StateProps = { const APP_OUTDATED_TIMEOUT_MS = 5 * 60 * 1000; // 5 min const CALL_BUNDLE_LOADING_DELAY_MS = 5000; // 5 sec -const REACTION_PICKER_LOADING_DELAY_MS = 7000; // 7 sec // eslint-disable-next-line @typescript-eslint/naming-convention let DEBUG_isLogged = false; @@ -163,6 +164,7 @@ const Main: FC = ({ isMiddleColumnOpen, isRightColumnOpen, isMediaViewerOpen, + isStoryViewerOpen, isForwardModalOpen, hasNotifications, hasDialogs, @@ -256,9 +258,6 @@ const Main: FC = ({ void loadBundle(Bundles.Calls); }, CALL_BUNDLE_LOADING_DELAY_MS); - const [shouldLoadReactionPicker, markShouldLoadReactionPicker] = useFlag(false); - useTimeout(markShouldLoadReactionPicker, REACTION_PICKER_LOADING_DELAY_MS); - // eslint-disable-next-line no-null/no-null const containerRef = useRef(null); // eslint-disable-next-line no-null/no-null @@ -503,7 +502,7 @@ const Main: FC = ({ // Online status and browser tab indicators useBackgroundMode(handleBlur, handleFocus, !!IS_ELECTRON); useBeforeUnload(handleBlur); - usePreventPinchZoomGesture(isMediaViewerOpen); + usePreventPinchZoomGesture(isMediaViewerOpen || isStoryViewerOpen); return (
@@ -511,6 +510,7 @@ const Main: FC = ({ + @@ -556,7 +556,7 @@ const Main: FC = ({ - +
); }; @@ -616,6 +616,7 @@ export default memo(withGlobal( isMiddleColumnOpen: Boolean(chatId), isRightColumnOpen: selectIsRightColumnShown(global, isMobile), isMediaViewerOpen: selectIsMediaViewerOpen(global), + isStoryViewerOpen: selectIsStoryViewerOpen(global), isForwardModalOpen: selectIsForwardModalOpen(global), isReactionPickerOpen: selectIsReactionPickerOpen(global), hasNotifications: Boolean(notifications.length), diff --git a/src/components/mediaViewer/SeekLine.tsx b/src/components/mediaViewer/SeekLine.tsx index aa1e4a0ff..14b561aae 100644 --- a/src/components/mediaViewer/SeekLine.tsx +++ b/src/components/mediaViewer/SeekLine.tsx @@ -8,8 +8,8 @@ import type { ApiDimensions } from '../../api/types'; import useLastCallback from '../../hooks/useLastCallback'; import useSignal from '../../hooks/useSignal'; +import useCurrentTimeSignal from '../../hooks/useCurrentTimeSignal'; import { useThrottledSignal } from '../../hooks/useAsyncResolvers'; -import useCurrentTimeSignal from './hooks/useCurrentTimeSignal'; import useVideoWaitingSignal from './hooks/useVideoWaitingSignal'; import { captureEvents } from '../../util/captureEvents'; diff --git a/src/components/mediaViewer/VideoPlayer.tsx b/src/components/mediaViewer/VideoPlayer.tsx index 932f362e1..c512aa6c5 100644 --- a/src/components/mediaViewer/VideoPlayer.tsx +++ b/src/components/mediaViewer/VideoPlayer.tsx @@ -18,7 +18,7 @@ import usePictureInPicture from '../../hooks/usePictureInPicture'; import useShowTransition from '../../hooks/useShowTransition'; import useVideoCleanup from '../../hooks/useVideoCleanup'; import useAppLayout from '../../hooks/useAppLayout'; -import useCurrentTimeSignal from './hooks/useCurrentTimeSignal'; +import useCurrentTimeSignal from '../../hooks/useCurrentTimeSignal'; import useControlsSignal from './hooks/useControlsSignal'; import useVideoWaitingSignal from './hooks/useVideoWaitingSignal'; import useUnsupportedMedia from '../../hooks/media/useUnsupportedMedia'; diff --git a/src/components/mediaViewer/VideoPlayerControls.tsx b/src/components/mediaViewer/VideoPlayerControls.tsx index edad7a292..679cefa0b 100644 --- a/src/components/mediaViewer/VideoPlayerControls.tsx +++ b/src/components/mediaViewer/VideoPlayerControls.tsx @@ -12,7 +12,7 @@ import useFlag from '../../hooks/useFlag'; import useAppLayout from '../../hooks/useAppLayout'; import useDerivedState from '../../hooks/useDerivedState'; import useSignal from '../../hooks/useSignal'; -import useCurrentTimeSignal from './hooks/useCurrentTimeSignal'; +import useCurrentTimeSignal from '../../hooks/useCurrentTimeSignal'; import useControlsSignal from './hooks/useControlsSignal'; import buildClassName from '../../util/buildClassName'; diff --git a/src/components/middle/MessageList.tsx b/src/components/middle/MessageList.tsx index 44316b12b..85a8fe967 100644 --- a/src/components/middle/MessageList.tsx +++ b/src/components/middle/MessageList.tsx @@ -123,6 +123,7 @@ type StateProps = { const MESSAGE_REACTIONS_POLLING_INTERVAL = 15 * 1000; const MESSAGE_COMMENTS_POLLING_INTERVAL = 15 * 1000; +const MESSAGE_STORY_POLLING_INTERVAL = 5 * 60 * 1000; const BOTTOM_THRESHOLD = 50; const UNREAD_DIVIDER_TOP = 10; const UNREAD_DIVIDER_TOP_WITH_TOOLS = 60; @@ -173,7 +174,7 @@ const MessageList: FC = ({ }) => { const { loadViewportMessages, setScrollOffset, loadSponsoredMessages, loadMessageReactions, copyMessagesByIds, - loadMessageViews, + loadMessageViews, loadUserStoriesByIds, } = getActions(); // eslint-disable-next-line no-null/no-null @@ -259,6 +260,28 @@ const MessageList: FC = ({ loadMessageReactions({ chatId, ids }); }, MESSAGE_REACTIONS_POLLING_INTERVAL); + useInterval(() => { + if (!messageIds || !messagesById || type === 'scheduled') { + return; + } + const storyDataList = messageIds.map((id) => messagesById[id]?.content.storyData).filter(Boolean); + + if (!storyDataList.length) return; + + const storiesByUserIds = storyDataList.reduce((acc, storyData) => { + const { userId, id } = storyData!; + if (!acc[userId]) { + acc[userId] = []; + } + acc[userId].push(id); + return acc; + }, {} as Record); + + Object.entries(storiesByUserIds).forEach(([userId, storyIds]) => { + loadUserStoriesByIds({ userId, storyIds }); + }); + }, MESSAGE_STORY_POLLING_INTERVAL); + useInterval(() => { if (!messageIds || !messagesById || threadId !== MAIN_THREAD_ID || type === 'scheduled') { return; diff --git a/src/components/middle/MiddleColumn.scss b/src/components/middle/MiddleColumn.scss index eefcfb54e..f0d12ad55 100644 --- a/src/components/middle/MiddleColumn.scss +++ b/src/components/middle/MiddleColumn.scss @@ -35,7 +35,7 @@ } .Composer { - #message-compose { + .composer-wrapper { transform: scaleX(1) translateX(0); transition: transform var(--select-transition), border-bottom-right-radius var(--select-transition); @@ -83,7 +83,7 @@ height: 0; } - #message-compose { + .composer-wrapper { transform: scaleX(var(--composer-hidden-scale, 1)) translateX(var(--composer-translate-x, 0)); border-bottom-right-radius: var(--border-radius-messages); diff --git a/src/components/middle/MiddleColumn.tsx b/src/components/middle/MiddleColumn.tsx index 22c64d924..e9067e956 100644 --- a/src/components/middle/MiddleColumn.tsx +++ b/src/components/middle/MiddleColumn.tsx @@ -21,6 +21,8 @@ import { GENERAL_TOPIC_ID, TMP_CHAT_ID, MAX_SCREEN_WIDTH_FOR_EXPAND_PINNED_MESSAGES, + EDITABLE_INPUT_ID, + EDITABLE_INPUT_CSS_SELECTOR, } from '../../config'; import { IS_ANDROID, IS_IOS, IS_TRANSLATION_SUPPORTED, MASK_IMAGE_DISABLED, @@ -78,7 +80,6 @@ import Transition from '../ui/Transition'; import MiddleHeader from './MiddleHeader'; import MessageList from './MessageList'; import FloatingActionButtons from './FloatingActionButtons'; -import Composer from './composer/Composer'; import Button from '../ui/Button'; import MobileSearch from './MobileSearch.async'; import MessageSelectToolbar from './MessageSelectToolbar.async'; @@ -88,6 +89,7 @@ import EmojiInteractionAnimation from './EmojiInteractionAnimation.async'; import ReactorListModal from './ReactorListModal.async'; import GiftPremiumModal from '../main/premium/GiftPremiumModal.async'; import ChatLanguageModal from './ChatLanguageModal.async'; +import Composer from '../common/Composer'; import './MiddleColumn.scss'; @@ -529,6 +531,7 @@ function MiddleColumn({
{renderingCanPost && ( )} {isPinnedMessageList && canUnpin && ( diff --git a/src/components/middle/MiddleHeader.scss b/src/components/middle/MiddleHeader.scss index be1b048f0..bb1b28680 100644 --- a/src/components/middle/MiddleHeader.scss +++ b/src/components/middle/MiddleHeader.scss @@ -31,10 +31,12 @@ background: var(--color-background); position: relative; z-index: var(--z-middle-header); - padding-top: 0.5rem; - padding-bottom: 0.5rem; - padding-left: max(1.5rem, env(safe-area-inset-left)); + padding-top: 0.25rem; + padding-bottom: 0.25rem; + padding-left: max(1.4375rem, env(safe-area-inset-left)); padding-right: max(0.8125rem, env(safe-area-inset-right)); + flex-shrink: 0; + height: 3.5rem; @media (max-width: 600px) { position: relative; @@ -214,6 +216,8 @@ cursor: var(--custom-cursor, pointer); display: flex; align-items: center; + // Space for unread story circle + padding: 0.0625rem 0 0.0625rem 0.0625rem; @media (max-width: 600px) { user-select: none; @@ -266,6 +270,11 @@ .custom-emoji { color: var(--color-primary); } + + .story-circle { + max-width: 2.625rem !important; + max-height: 2.625rem !important; + } } .Avatar, .topic-header-icon { diff --git a/src/components/middle/MiddleHeader.tsx b/src/components/middle/MiddleHeader.tsx index dcbc511b0..07b83497d 100644 --- a/src/components/middle/MiddleHeader.tsx +++ b/src/components/middle/MiddleHeader.tsx @@ -367,6 +367,7 @@ const MiddleHeader: FC = ({ withDots={Boolean(connectionStatusText)} withFullInfo withMediaViewer + withStory={!isChatWithSelf} withUpdatingStatus emojiStatusSize={EMOJI_STATUS_SIZE} noRtl diff --git a/src/components/middle/composer/AttachMenu.tsx b/src/components/middle/composer/AttachMenu.tsx index dd7c28c41..43d2d0fdd 100644 --- a/src/components/middle/composer/AttachMenu.tsx +++ b/src/components/middle/composer/AttachMenu.tsx @@ -40,11 +40,13 @@ export type OwnProps = { canSendDocuments: boolean; canSendAudios: boolean; isScheduled?: boolean; - attachBots: GlobalState['attachMenu']['bots']; + attachBots?: GlobalState['attachMenu']['bots']; peerType?: ApiAttachMenuPeerType; shouldCollectDebugLogs?: boolean; onFileSelect: (files: File[], shouldSuggestCompression?: boolean) => void; - onPollCreate: () => void; + onPollCreate: NoneToVoidFunction; + onMenuOpen: NoneToVoidFunction; + onMenuClose: NoneToVoidFunction; theme: ISettings['theme']; }; @@ -62,6 +64,8 @@ const AttachMenu: FC = ({ peerType, isScheduled, onFileSelect, + onMenuOpen, + onMenuClose, onPollCreate, theme, shouldCollectDebugLogs, @@ -73,12 +77,22 @@ const AttachMenu: FC = ({ const canSendVideoOrPhoto = canSendPhotos || canSendVideos; const [isAttachmentBotMenuOpen, markAttachmentBotMenuOpen, unmarkAttachmentBotMenuOpen] = useFlag(); + const isMenuOpen = isAttachMenuOpen || isAttachmentBotMenuOpen; + useEffect(() => { if (isAttachMenuOpen) { markMouseInside(); } }, [isAttachMenuOpen, markMouseInside]); + useEffect(() => { + if (isMenuOpen) { + onMenuOpen(); + } else { + onMenuClose(); + } + }, [isMenuOpen, onMenuClose, onMenuOpen]); + const handleToggleAttachMenu = useLastCallback(() => { if (isAttachMenuOpen) { closeAttachMenu(); @@ -118,13 +132,15 @@ const AttachMenu: FC = ({ }); const bots = useMemo(() => { - return Object.values(attachBots).filter((bot) => { - if (!peerType) return false; - if (peerType === 'bots' && bot.id === chatId && bot.peerTypes.includes('self')) { - return true; - } - return bot.peerTypes.includes(peerType); - }); + return attachBots + ? Object.values(attachBots).filter((bot) => { + if (!peerType) return false; + if (peerType === 'bots' && bot.id === chatId && bot.peerTypes.includes('self')) { + return true; + } + return bot.peerTypes.includes(peerType); + }) + : undefined; }, [attachBots, chatId, peerType]); const lang = useLang(); @@ -149,7 +165,7 @@ const AttachMenu: FC = ({ = ({ {lang('Poll')} )} - {canAttachMedia && !isScheduled && bots.map((bot) => ( + {canAttachMedia && !isScheduled && bots?.map((bot) => ( ; canShowCustomSendMenu?: boolean; isReady: boolean; + isForMessage?: boolean; shouldSchedule?: boolean; shouldSuggestCompression?: boolean; shouldForceCompression?: boolean; shouldForceAsFile?: boolean; isForCurrentMessageList?: boolean; + forceDarkTheme?: boolean; onCaptionUpdate: (html: string) => void; onSend: (sendCompressed: boolean, sendGrouped: boolean) => void; onFileAppend: (files: File[], isSpoiler?: boolean) => void; @@ -112,6 +114,7 @@ const AttachmentModal: FC = ({ recentEmojis, baseEmojiKeywords, emojiKeywords, + isForMessage, shouldSchedule, shouldSuggestCustomEmoji, customEmojiForEmoji, @@ -120,6 +123,7 @@ const AttachmentModal: FC = ({ shouldForceCompression, shouldForceAsFile, isForCurrentMessageList, + forceDarkTheme, onAttachmentsUpdate, onCaptionUpdate, onSend, @@ -194,7 +198,7 @@ const AttachmentModal: FC = ({ insertEmoji, closeEmojiTooltip, } = useEmojiTooltip( - Boolean(isReady && isForCurrentMessageList && renderingIsOpen), + Boolean(isReady && (isForCurrentMessageList || !isForMessage) && renderingIsOpen), getHtml, onCaptionUpdate, EDITABLE_INPUT_MODAL_ID, @@ -208,7 +212,7 @@ const AttachmentModal: FC = ({ insertCustomEmoji, closeCustomEmojiTooltip, } = useCustomEmojiTooltip( - Boolean(isReady && isForCurrentMessageList && renderingIsOpen && shouldSuggestCustomEmoji), + Boolean(isReady && (isForCurrentMessageList || !isForMessage) && renderingIsOpen && shouldSuggestCustomEmoji), getHtml, onCaptionUpdate, getSelectionRange, @@ -256,7 +260,7 @@ const AttachmentModal: FC = ({ const sendAttachments = useLastCallback((isSilent?: boolean, shouldSendScheduled?: boolean) => { if (isOpen) { - const send = (shouldSchedule || shouldSendScheduled) ? onSendScheduled + const send = ((shouldSchedule || shouldSendScheduled) && isForMessage) ? onSendScheduled : isSilent ? onSendSilent : onSend; send(isSendingCompressed, shouldSendGrouped); updateAttachmentSettings({ @@ -508,6 +512,7 @@ const AttachmentModal: FC = ({ !areAttachmentsNotScrolled && styles.headerBorder, isMobile && styles.mobile, isSymbolMenuOpen && styles.symbolMenuOpen, + forceDarkTheme && 'component-theme-dark', )} noBackdropClose > @@ -586,6 +591,7 @@ const AttachmentModal: FC = ({ isAttachmentModal canSendPlainText className="attachment-modal-symbol-menu with-menu-transitions" + idPrefix="attachment" /> = ({ chatId={chatId} threadId={threadId} isAttachmentModalInput + customEmojiPrefix="attachment" isReady={isReady} isActive={isOpen} getHtml={getHtml} @@ -618,6 +625,7 @@ const AttachmentModal: FC = ({ {canShowCustomSendMenu && ( = ({ forwardsHaveCaptions, shouldForceShowEditing, isCurrentUserPremium, + isContextMenuDisabled, onClear, }) => { const { @@ -201,7 +203,7 @@ const ComposerEmbeddedMessage: FC = ({ customText={customText} title={editingId ? lang('EditMessage') : noAuthors ? lang('HiddenSendersNameDescription') : undefined} onClick={handleMessageClick} - hasContextMenu={isForwarding} + hasContextMenu={isForwarding && !isContextMenuDisabled} /> - {isForwarding && ( + {isForwarding && !isContextMenuDisabled && ( ( forward?.content.text && Object.keys(forward.content).length > 1 )); + const isContextMenuDisabled = isForwarding && forwardMessageIds!.length === 1 + && Boolean(message?.content.storyData); + return { replyingToId, editingId, @@ -349,6 +354,7 @@ export default memo(withGlobal( noCaptions, forwardsHaveCaptions, isCurrentUserPremium: selectIsCurrentUserPremium(global), + isContextMenuDisabled, }; }, )(ComposerEmbeddedMessage)); diff --git a/src/components/middle/composer/CustomSendMenu.tsx b/src/components/middle/composer/CustomSendMenu.tsx index 0b33ed2be..44735fca8 100644 --- a/src/components/middle/composer/CustomSendMenu.tsx +++ b/src/components/middle/composer/CustomSendMenu.tsx @@ -17,6 +17,7 @@ export type OwnProps = { isOpen: boolean; isOpenToBottom?: boolean; isSavedMessages?: boolean; + canSchedule?: boolean; canScheduleUntilOnline?: boolean; onSendSilent?: NoneToVoidFunction; onSendSchedule?: NoneToVoidFunction; @@ -29,6 +30,7 @@ const CustomSendMenu: FC = ({ isOpen, isOpenToBottom = false, isSavedMessages, + canSchedule, canScheduleUntilOnline, onSendSilent, onSendSchedule, @@ -62,12 +64,12 @@ const CustomSendMenu: FC = ({ noCloseOnBackdrop={!IS_TOUCH_ENV} > {onSendSilent && {lang('SendWithoutSound')}} - {onSendSchedule && ( + {canSchedule && onSendSchedule && ( {lang(isSavedMessages ? 'SetReminder' : 'ScheduleMessage')} )} - {onSendSchedule && displayScheduleUntilOnline && ( + {canSchedule && onSendSchedule && displayScheduleUntilOnline && ( {lang('SendWhenOnline')} diff --git a/src/components/middle/composer/MessageInput.tsx b/src/components/middle/composer/MessageInput.tsx index 138ac45d4..f8765f205 100644 --- a/src/components/middle/composer/MessageInput.tsx +++ b/src/components/middle/composer/MessageInput.tsx @@ -47,6 +47,8 @@ type OwnProps = { chatId: string; threadId: number; isAttachmentModalInput?: boolean; + isStoryInput?: boolean; + customEmojiPrefix: string; editableInputId?: string; isReady: boolean; isActive: boolean; @@ -63,6 +65,8 @@ type OwnProps = { onSend: () => void; onScroll?: (event: React.UIEvent) => void; captionLimit?: number; + onFocus?: NoneToVoidFunction; + onBlur?: NoneToVoidFunction; }; type StateProps = { @@ -73,6 +77,7 @@ type StateProps = { }; const MAX_ATTACHMENT_MODAL_INPUT_HEIGHT = 160; +const MAX_STORY_MODAL_INPUT_HEIGHT = 128; const TAB_INDEX_PRIORITY_TIMEOUT = 2000; // Heuristics allowing the user to make a triple click const SELECTION_RECALCULATE_DELAY_MS = 260; @@ -102,6 +107,8 @@ const MessageInput: FC = ({ chatId, captionLimit, isAttachmentModalInput, + isStoryInput, + customEmojiPrefix, editableInputId, isReady, isActive, @@ -121,6 +128,8 @@ const MessageInput: FC = ({ onSuppressedFocus, onSend, onScroll, + onFocus, + onBlur, }) => { const { editLastMessage, @@ -162,13 +171,15 @@ const MessageInput: FC = ({ sharedCanvasRef, sharedCanvasHqRef, absoluteContainerRef, - isAttachmentModalInput ? 'attachment' : 'composer', + customEmojiPrefix, canPlayAnimatedEmojis, isReady, isActive, ); - const maxInputHeight = isAttachmentModalInput ? MAX_ATTACHMENT_MODAL_INPUT_HEIGHT : (isMobile ? 256 : 416); + const maxInputHeight = isAttachmentModalInput + ? MAX_ATTACHMENT_MODAL_INPUT_HEIGHT + : isStoryInput ? MAX_STORY_MODAL_INPUT_HEIGHT : (isMobile ? 256 : 416); const updateInputHeight = useLastCallback((willSend = false) => { requestForcedReflow(() => { const scroller = inputRef.current!.closest(`.${SCROLLER_CLASS}`)!; @@ -351,7 +362,6 @@ const MessageInput: FC = ({ const { isComposing } = e; const html = getHtml(); - if (!isComposing && !html && (e.metaKey || e.ctrlKey)) { const targetIndexDelta = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : undefined; if (targetIndexDelta) { @@ -549,6 +559,8 @@ const MessageInput: FC = ({ onContextMenu={IS_ANDROID ? handleAndroidContextMenu : undefined} onTouchCancel={IS_ANDROID ? processSelectionWithTimeout : undefined} aria-label={placeholder} + onFocus={onFocus} + onBlur={onBlur} /> {!forcedPlaceholder && ( void; @@ -90,6 +92,8 @@ const StickerPicker: FC = ({ canAnimate, isSavedMessages, isCurrentUserPremium, + noContextMenus, + idPrefix, onStickerSelect, }) => { const { @@ -114,6 +118,7 @@ const StickerPicker: FC = ({ const sendMessageAction = useSendMessageAction(chat!.id, threadId); + const prefix = `${idPrefix}-sticker-set`; const { activeSetIndex, observeIntersectionForSet, @@ -121,7 +126,7 @@ const StickerPicker: FC = ({ observeIntersectionForShowingItems, observeIntersectionForCovers, selectStickerSet, - } = useStickerPickerObservers(containerRef, headerRef, 'sticker-set', isHidden); + } = useStickerPickerObservers(containerRef, headerRef, prefix, isHidden); const lang = useLang(); @@ -355,7 +360,9 @@ const StickerPicker: FC = ({ key={stickerSet.id} stickerSet={stickerSet} loadAndPlay={Boolean(canAnimate && loadAndPlay)} + noContextMenus={noContextMenus} index={i} + idPrefix={prefix} observeIntersection={observeIntersectionForSet} observeIntersectionForPlayingItems={observeIntersectionForPlayingItems} observeIntersectionForShowingItems={observeIntersectionForShowingItems} diff --git a/src/components/middle/composer/SymbolMenu.scss b/src/components/middle/composer/SymbolMenu.scss index 56f0ddc5a..63a4ecc9a 100644 --- a/src/components/middle/composer/SymbolMenu.scss +++ b/src/components/middle/composer/SymbolMenu.scss @@ -51,7 +51,7 @@ transition: none; } - &.left-column-open { + &.left-column-open.in-middle-column { transform: translate3d(100vw, 0, 0) !important; } } @@ -154,7 +154,7 @@ body:not(.no-menu-blur) & { background: var(--color-background-compact-menu); - backdrop-filter: blur(10px); + backdrop-filter: blur(25px); } &:not(.open) { @@ -224,6 +224,7 @@ &-external { color: var(--color-text); + text-align: start; } } diff --git a/src/components/middle/composer/SymbolMenu.tsx b/src/components/middle/composer/SymbolMenu.tsx index ace4731d7..07d66494e 100644 --- a/src/components/middle/composer/SymbolMenu.tsx +++ b/src/components/middle/composer/SymbolMenu.tsx @@ -39,6 +39,8 @@ export type OwnProps = { isOpen: boolean; canSendStickers?: boolean; canSendGifs?: boolean; + isMessageComposer?: boolean; + idPrefix: string; onLoad: () => void; onClose: () => void; onEmojiSelect: (emoji: string) => void; @@ -79,27 +81,29 @@ const SymbolMenu: FC = ({ isOpen, canSendStickers, canSendGifs, + isMessageComposer, isLeftColumnShown, isCurrentUserPremium, - onLoad, - onClose, - onEmojiSelect, + idPrefix, isAttachmentModal, canSendPlainText, - onCustomEmojiSelect, - onStickerSelect, className, - onGifSelect, - onRemoveSymbol, - onSearchOpen, - addRecentEmoji, - addRecentCustomEmoji, positionX, positionY, transformOriginX, transformOriginY, style, isBackgroundTranslucent, + onLoad, + onClose, + onEmojiSelect, + onCustomEmojiSelect, + onStickerSelect, + onGifSelect, + onRemoveSymbol, + onSearchOpen, + addRecentEmoji, + addRecentCustomEmoji, }) => { const { loadPremiumSetStickers } = getActions(); const [activeTab, setActiveTab] = useState(0); @@ -218,6 +222,7 @@ const SymbolMenu: FC = ({ = ({ className="picker-tab" isHidden={!isOpen || !isActive} loadAndPlay={canSendStickers ? isOpen && (isActive || isFrom) : false} + idPrefix={idPrefix} canSendStickers={canSendStickers} + noContextMenus={!isMessageComposer} chatId={chatId} threadId={threadId} isTranslucent={!isMobile && isBackgroundTranslucent} @@ -285,6 +292,7 @@ const SymbolMenu: FC = ({ activeTab={activeTab} onSwitchTab={setActiveTab} onRemoveSymbol={onRemoveSymbol} + canSearch={isMessageComposer} onSearchOpen={handleSearch} isAttachmentModal={isAttachmentModal} canSendPlainText={canSendPlainText} @@ -302,6 +310,7 @@ const SymbolMenu: FC = ({ transitionClassNames, isLeftColumnShown && 'left-column-open', isAttachmentModal && 'in-attachment-modal', + isMessageComposer && 'in-middle-column', ); if (isAttachmentModal) { diff --git a/src/components/middle/composer/SymbolMenuButton.tsx b/src/components/middle/composer/SymbolMenuButton.tsx index 45010f261..cd056dcb6 100644 --- a/src/components/middle/composer/SymbolMenuButton.tsx +++ b/src/components/middle/composer/SymbolMenuButton.tsx @@ -27,6 +27,8 @@ type OwnProps = { isSymbolMenuOpen?: boolean; canSendGifs?: boolean; canSendStickers?: boolean; + isMessageComposer?: boolean; + idPrefix: string; openSymbolMenu: VoidFunction; closeSymbolMenu: VoidFunction; onCustomEmojiSelect: (emoji: ApiSticker) => void; @@ -46,6 +48,7 @@ type OwnProps = { isAttachmentModal?: boolean; canSendPlainText?: boolean; className?: string; + inputCssSelector?: string; }; const SymbolMenuButton: FC = ({ @@ -54,21 +57,24 @@ const SymbolMenuButton: FC = ({ isMobile, canSendGifs, canSendStickers, + isMessageComposer, isReady, isSymbolMenuOpen, + idPrefix, + isAttachmentModal, + canSendPlainText, + isSymbolMenuForced, + className, + inputCssSelector = EDITABLE_INPUT_CSS_SELECTOR, openSymbolMenu, closeSymbolMenu, onCustomEmojiSelect, onStickerSelect, onGifSelect, - isAttachmentModal, - canSendPlainText, onRemoveSymbol, onEmojiSelect, closeBotCommandMenu, closeSendAsMenu, - isSymbolMenuForced, - className, }) => { const { setStickerSearchQuery, @@ -113,7 +119,7 @@ const SymbolMenuButton: FC = ({ const handleSymbolMenuOpen = useLastCallback(() => { const messageInput = document.querySelector( - isAttachmentModal ? EDITABLE_INPUT_MODAL_CSS_SELECTOR : EDITABLE_INPUT_CSS_SELECTOR, + isAttachmentModal ? EDITABLE_INPUT_MODAL_CSS_SELECTOR : inputCssSelector, ); if (!isMobile || messageInput !== document.activeElement) { @@ -176,6 +182,8 @@ const SymbolMenuButton: FC = ({ isOpen={isSymbolMenuOpen || Boolean(isSymbolMenuForced)} canSendGifs={canSendGifs} canSendStickers={canSendStickers} + isMessageComposer={isMessageComposer} + idPrefix={idPrefix} onLoad={onSymbolMenuLoadingComplete} onClose={closeSymbolMenu} onEmojiSelect={onEmojiSelect} diff --git a/src/components/middle/composer/SymbolMenuFooter.tsx b/src/components/middle/composer/SymbolMenuFooter.tsx index 11fb2a7b7..0da07bf82 100644 --- a/src/components/middle/composer/SymbolMenuFooter.tsx +++ b/src/components/middle/composer/SymbolMenuFooter.tsx @@ -15,6 +15,7 @@ type OwnProps = { onSearchOpen: (type: 'stickers' | 'gifs') => void; isAttachmentModal?: boolean; canSendPlainText?: boolean; + canSearch?: boolean; }; export enum SymbolMenuTabs { @@ -40,7 +41,7 @@ const SYMBOL_MENU_TAB_ICONS = { const SymbolMenuFooter: FC = ({ activeTab, onSwitchTab, onRemoveSymbol, onSearchOpen, isAttachmentModal, - canSendPlainText, + canSendPlainText, canSearch, }) => { const lang = useLang(); @@ -70,7 +71,7 @@ const SymbolMenuFooter: FC = ({ return (
- {activeTab !== SymbolMenuTabs.Emoji && activeTab !== SymbolMenuTabs.CustomEmoji && ( + {activeTab !== SymbolMenuTabs.Emoji && activeTab !== SymbolMenuTabs.CustomEmoji && canSearch && (
)} {sticker && ( @@ -1070,6 +1107,13 @@ const Message: FC = ({ isDownloading={isDownloading} /> )} + {storyData && !isStoryMention && ( + + )} + {isStoryMention && } {contact && ( )} @@ -1128,6 +1172,8 @@ const Message: FC = ({ isDownloading={isDownloading} isProtected={isProtected} theme={theme} + story={webPageStory} + isConnected={isConnected} onMediaClick={handleMediaClick} onCancelMediaTransfer={handleCancelUpload} /> @@ -1175,6 +1221,8 @@ const Message: FC = ({ } } else if (forwardInfo?.hiddenUserName) { senderTitle = forwardInfo.hiddenUserName; + } else if (storyData && originSender) { + senderTitle = getSenderTitle(lang, originSender!); } const senderEmojiStatus = senderPeer && 'emojiStatus' in senderPeer && senderPeer.emojiStatus; const senderIsPremium = senderPeer && 'isPremium' in senderPeer && senderPeer.isPremium; @@ -1284,12 +1332,12 @@ const Message: FC = ({ > {asForwarded && !isInDocumentGroupNotFirst && (
- {lang('ForwardedMessage')} + {lang(storyData ? 'ForwardedStory' : 'ForwardedMessage')} {forwardAuthor && {forwardAuthor}}
)} {renderContent()} - {!isInDocumentGroupNotLast && metaPosition === 'standalone' && renderReactionsAndMeta()} + {!isInDocumentGroupNotLast && metaPosition === 'standalone' && !isStoryMention && renderReactionsAndMeta()} {canShowActionButton && canForward ? ( )} {canManage && !isInsideTopic && ( @@ -505,6 +512,7 @@ const RightHeader: FC = ({ isMobile || contentKey === HeaderContent.SharedMedia || contentKey === HeaderContent.MemberList + || contentKey === HeaderContent.StoryList || contentKey === HeaderContent.AddingMembers || contentKey === HeaderContent.MessageStatistics || isManagement @@ -577,6 +585,7 @@ export default withGlobal( isInsideTopic, canEditTopic, userId: user?.id, + isSelf: user?.isSelf, messageSearchQuery, stickerSearchQuery, gifSearchQuery, diff --git a/src/components/right/hooks/useProfileState.ts b/src/components/right/hooks/useProfileState.ts index b45c1068e..c75a9d1c9 100644 --- a/src/components/right/hooks/useProfileState.ts +++ b/src/components/right/hooks/useProfileState.ts @@ -27,7 +27,11 @@ export default function useProfileState( const container = containerRef.current!; const tabsEl = container.querySelector('.TabList')!; if (container.scrollTop < tabsEl.offsetTop) { - onProfileStateChange(tabType === 'members' ? ProfileState.MemberList : ProfileState.SharedMedia); + onProfileStateChange( + tabType === 'members' + ? ProfileState.MemberList + : (tabType === 'stories' ? ProfileState.StoryList : ProfileState.SharedMedia), + ); isScrollingProgrammatically = true; animateScroll(container, tabsEl, 'start', undefined, undefined, undefined, TRANSITION_DURATION); setTimeout(() => { @@ -84,7 +88,7 @@ export default function useProfileState( if (container.scrollTop >= tabListEl.offsetTop) { state = tabType === 'members' ? ProfileState.MemberList - : ProfileState.SharedMedia; + : (tabType === 'stories' ? ProfileState.StoryList : ProfileState.SharedMedia); } onProfileStateChange(state); diff --git a/src/components/right/hooks/useProfileViewportIds.ts b/src/components/right/hooks/useProfileViewportIds.ts index 9bfe78de8..0b1e8ec39 100644 --- a/src/components/right/hooks/useProfileViewportIds.ts +++ b/src/components/right/hooks/useProfileViewportIds.ts @@ -14,6 +14,8 @@ export default function useProfileViewportIds( loadMoreMembers: AnyToVoidFunction, loadCommonChats: AnyToVoidFunction, searchMessages: AnyToVoidFunction, + loadStories: AnyToVoidFunction, + loadStoriesArchive: AnyToVoidFunction, tabType: ProfileTabType, mediaSearchType?: SharedMediaType, groupChatMembers?: ApiChatMember[], @@ -24,6 +26,8 @@ export default function useProfileViewportIds( chatMessages?: Record, foundIds?: number[], topicId?: number, + storyIds?: number[], + archiveStoryIds?: number[], ) { const resultType = tabType === 'members' || !mediaSearchType ? tabType : mediaSearchType; @@ -75,6 +79,18 @@ export default function useProfileViewportIds( loadCommonChats, chatIds, ); + const [storyViewportIds, getMoreStories, noProfileInfoForStories] = useInfiniteScrollForLoadableItems( + loadStories, storyIds, + ); + + const [ + archiveStoryViewportIds, + getMoreStoriesArchive, + noProfileInfoForStoriesArchive, + ] = useInfiniteScrollForLoadableItems( + loadStoriesArchive, archiveStoryIds, + ); + let viewportIds: number[] | string[] | undefined; let getMore: AnyToVoidFunction | undefined; let noProfileInfo = false; @@ -115,14 +131,24 @@ export default function useProfileViewportIds( getMore = getMoreVoices; noProfileInfo = noProfileInfoForVoices; break; + case 'stories': + viewportIds = storyViewportIds; + getMore = getMoreStories; + noProfileInfo = noProfileInfoForStories; + break; + case 'storiesArchive': + viewportIds = archiveStoryViewportIds; + getMore = getMoreStoriesArchive; + noProfileInfo = noProfileInfoForStoriesArchive; + break; } return [resultType, viewportIds, getMore, noProfileInfo] as const; } -function useInfiniteScrollForLoadableItems( +function useInfiniteScrollForLoadableItems( handleLoadMore?: AnyToVoidFunction, - itemIds?: string[], + itemIds?: ListId[], ) { const [viewportIds, getMore] = useInfiniteScroll( handleLoadMore, diff --git a/src/components/story/MediaStory.module.scss b/src/components/story/MediaStory.module.scss new file mode 100644 index 000000000..de77e7b86 --- /dev/null +++ b/src/components/story/MediaStory.module.scss @@ -0,0 +1,31 @@ +.root { + position: relative; +} + +.wrapper { + // Aspect-ratio trick https://css-tricks.com/aspect-ratio-boxes/ + height: 0; + padding-bottom: 179%; + overflow: hidden; + cursor: var(--custom-cursor, pointer); +} + +.media { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + object-fit: cover; +} + +.expiredIcon { + font-size: 1.25rem; + line-height: 0.9375rem; + vertical-align: -0.1875rem; +} + +.contextMenu { + position: relative; + z-index: var(--z-right-column-menu); +} diff --git a/src/components/story/MediaStory.tsx b/src/components/story/MediaStory.tsx new file mode 100644 index 000000000..9329d5e09 --- /dev/null +++ b/src/components/story/MediaStory.tsx @@ -0,0 +1,156 @@ +import React, { + memo, useCallback, useEffect, useRef, +} from '../../lib/teact/teact'; +import { getActions } from '../../global'; + +import type { ApiStory, ApiTypeStory } from '../../api/types'; + +import { getStoryMediaHash } from '../../global/helpers'; +import buildClassName from '../../util/buildClassName'; +import stopEvent from '../../util/stopEvent'; +import { preventMessageInputBlurWithBubbling } from '../middle/helpers/preventMessageInputBlur'; + +import useMedia from '../../hooks/useMedia'; +import useLang from '../../hooks/useLang'; +import useContextMenuHandlers from '../../hooks/useContextMenuHandlers'; +import useMenuPosition from '../../hooks/useMenuPosition'; +import useLastCallback from '../../hooks/useLastCallback'; + +import Menu from '../ui/Menu'; +import MenuItem from '../ui/MenuItem'; + +import styles from './MediaStory.module.scss'; + +interface OwnProps { + story: ApiTypeStory; + isProtected?: boolean; + isArchive?: boolean; +} + +function MediaStory({ story, isProtected, isArchive }: OwnProps) { + const { + openStoryViewer, + loadUserSkippedStories, + toggleStoryPinned, + showNotification, + } = getActions(); + + const lang = useLang(); + // eslint-disable-next-line no-null/no-null + const containerRef = useRef(null); + + 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 isFullyLoaded = story && 'content' in story; + const isDeleted = story && 'isDeleted' in story; + const video = isFullyLoaded ? (story as ApiStory).content.video : undefined; + const imageHash = isFullyLoaded ? getStoryMediaHash(story as ApiStory) : undefined; + const imgBlobUrl = useMedia(imageHash); + const thumbUrl = imgBlobUrl || video?.thumbnail?.dataUri; + + useEffect(() => { + if (story && !(isFullyLoaded || isDeleted)) { + loadUserSkippedStories({ userId: story.userId }); + } + }, [isDeleted, isFullyLoaded, story]); + + const { + isContextMenuOpen, contextMenuPosition, + handleBeforeContextMenu, handleContextMenu, + handleContextMenuClose, handleContextMenuHide, + } = useContextMenuHandlers(containerRef); + const { + positionX, positionY, transformOriginX, transformOriginY, style: menuStyle, + } = useMenuPosition( + contextMenuPosition, + getTriggerElement, + getRootElement, + getMenuElement, + getLayout, + ); + + const handleClick = useCallback(() => { + openStoryViewer({ + userId: story.userId, + storyId: story.id, + isSingleUser: true, + isPrivate: true, + isArchive, + }); + }, [isArchive, story.id, story.userId]); + + const handleMouseDown = useLastCallback((e: React.MouseEvent) => { + preventMessageInputBlurWithBubbling(e); + handleBeforeContextMenu(e); + }); + + const handlePinClick = useLastCallback((e: React.SyntheticEvent) => { + stopEvent(e); + + toggleStoryPinned({ storyId: story.id, isPinned: true }); + showNotification({ + message: lang('Story.ToastSavedToProfileText'), + }); + handleContextMenuClose(); + }); + + const handleUnpinClick = useLastCallback((e: React.SyntheticEvent) => { + stopEvent(e); + + toggleStoryPinned({ storyId: story.id, isPinned: false }); + showNotification({ + message: lang('Story.ToastRemovedFromProfileText'), + }); + handleContextMenuClose(); + }); + + return ( +
+ {isDeleted && ( + + + {lang('ExpiredStory')} + + )} +
+ {thumbUrl && ( + + )} + {isProtected && } +
+ {contextMenuPosition !== undefined && ( + + {isArchive && {lang('StoryList.SaveToProfile')}} + {!isArchive && ( + + {lang('Story.Context.RemoveFromProfile')} + + )} + + )} +
+ ); +} + +export default memo(MediaStory); diff --git a/src/components/story/Story.tsx b/src/components/story/Story.tsx new file mode 100644 index 000000000..0a1b2a7af --- /dev/null +++ b/src/components/story/Story.tsx @@ -0,0 +1,789 @@ +import React, { + memo, useEffect, useMemo, useRef, useState, +} from '../../lib/teact/teact'; +import { getActions, getGlobal, withGlobal } from '../../global'; + +import type { FC } from '../../lib/teact/teact'; +import type { ApiStory, ApiTypeStory, ApiUser } from '../../api/types'; +import type { IDimensions } from '../../global/types'; +import type { Signal } from '../../util/signals'; + +import { ApiMediaFormat, MAIN_THREAD_ID } from '../../api/types'; +import buildClassName from '../../util/buildClassName'; +import renderText from '../common/helpers/renderText'; +import { + getStoryMediaHash, getUserFirstOrLastName, hasMessageText, +} from '../../global/helpers'; +import { formatRelativeTime } from '../../util/dateFormat'; +import { getServerTime } from '../../util/serverTime'; +import { selectChat, selectTabState } from '../../global/selectors'; +import captureKeyboardListeners from '../../util/captureKeyboardListeners'; + +import useAppLayout, { getIsMobile } from '../../hooks/useAppLayout'; +import useLang from '../../hooks/useLang'; +import useMedia from '../../hooks/useMedia'; +import useStoryPreloader from './hooks/useStoryPreloader'; +import useBackgroundMode from '../../hooks/useBackgroundMode'; +import useShowTransition from '../../hooks/useShowTransition'; +import useLastCallback from '../../hooks/useLastCallback'; +import useCurrentOrPrev from '../../hooks/useCurrentOrPrev'; +import useFlag from '../../hooks/useFlag'; +import useCurrentTimeSignal from '../../hooks/useCurrentTimeSignal'; +import useEffectWithPrevDeps from '../../hooks/useEffectWithPrevDeps'; +import useLongPress from '../../hooks/useLongPress'; +import useUnsupportedMedia from '../../hooks/media/useUnsupportedMedia'; +import useCanvasBlur from '../../hooks/useCanvasBlur'; +import useMediaTransition from '../../hooks/useMediaTransition'; + +import Button from '../ui/Button'; +import Avatar from '../common/Avatar'; +import OptimizedVideo from '../ui/OptimizedVideo'; +import StoryProgress from './StoryProgress'; +import Composer from '../common/Composer'; +import MenuItem from '../ui/MenuItem'; +import DropdownMenu from '../ui/DropdownMenu'; +import Skeleton from '../ui/Skeleton'; +import StoryCaption from './StoryCaption'; + +import styles from './StoryViewer.module.scss'; + +interface OwnProps { + userId: string; + storyId: number; + dimensions: IDimensions; + // eslint-disable-next-line react/no-unused-prop-types + isReportModalOpen?: boolean; + // eslint-disable-next-line react/no-unused-prop-types + isDeleteModalOpen?: boolean; + isPrivateStories?: boolean; + isArchivedStories?: boolean; + isSingleStory?: boolean; + getIsAnimating: Signal; + onDelete: (storyId: number) => void; + onClose: NoneToVoidFunction; + onReport: NoneToVoidFunction; +} + +interface StateProps { + user: ApiUser; + story?: ApiTypeStory; + isMuted: boolean; + isSelf: boolean; + orderedIds?: number[]; + shouldForcePause?: boolean; + storyChangelogUserId?: string; + viewersExpirePeriod: number; + isChatExist?: boolean; + areChatSettingsLoaded?: boolean; +} + +const VIDEO_MIN_READY_STATE = 4; +const SPACEBAR_CODE = 32; + +const PRIMARY_VIDEO_MIME = 'video/mp4; codecs="hvc1"'; +const SECONDARY_VIDEO_MIME = 'video/mp4; codecs="avc1.64001E"'; + +function Story({ + isSelf, + userId, + storyId, + user, + isMuted, + isArchivedStories, + isPrivateStories, + story, + orderedIds, + isSingleStory, + dimensions, + shouldForcePause, + storyChangelogUserId, + viewersExpirePeriod, + isChatExist, + areChatSettingsLoaded, + getIsAnimating, + onDelete, + onClose, + onReport, +}: OwnProps & StateProps) { + const { + viewStory, + setStoryViewerMuted, + openPreviousStory, + openNextStory, + loadUserSkippedStories, + openForwardMenu, + openStorySeenBy, + copyStoryLink, + toggleStoryPinned, + openChat, + showNotification, + openStoryPrivacyEditor, + loadChatSettings, + fetchChat, + loadStorySeenBy, + } = getActions(); + const serverTime = getServerTime(); + + const lang = useLang(); + const { isMobile } = useAppLayout(); + const [, setCurrentTime] = useCurrentTimeSignal(); + const [isComposerHasFocus, markComposerHasFocus, unmarkComposerHasFocus] = useFlag(false); + const [isStoryPlaybackRequested, playStory, pauseStory] = useFlag(false); + const [isStoryPlaying, markStoryPlaying, unmarkStoryPlaying] = useFlag(false); + const [isAppFocused, markAppFocused, unmarkAppFocused] = useFlag(true); + const [isCaptionExpanded, expandCaption, foldCaption] = useFlag(false); + const [isPausedBySpacebar, setIsPausedBySpacebar] = useState(false); + const [isPausedByLongPress, markIsPausedByLongPress, unmarkIsPausedByLongPress] = useFlag(false); + // eslint-disable-next-line no-null/no-null + const videoRef = useRef(null); + const isLoadedStory = story && 'content' in story; + const isDeletedStory = story && 'isDeleted' in story; + const hasText = isLoadedStory ? hasMessageText(story) : false; + const canPinToProfile = useCurrentOrPrev( + isSelf && isLoadedStory ? !story.isPinned : undefined, + true, + ); + const canUnpinFromProfile = useCurrentOrPrev( + isSelf && isLoadedStory ? story.isPinned : undefined, + true, + ); + const areViewsExpired = Boolean( + isSelf && isLoadedStory && (story!.date + viewersExpirePeriod) < getServerTime(), + ); + const canShare = Boolean( + isLoadedStory + && story.isPublic + && !story.noForwards + && userId !== storyChangelogUserId + && !isCaptionExpanded, + ); + + let thumbnail: string | undefined; + if (isLoadedStory) { + if (story.content.photo?.thumbnail) { + thumbnail = story.content.photo.thumbnail.dataUri; + } + if (story.content.video?.thumbnail?.dataUri) { + thumbnail = story.content.video.thumbnail.dataUri; + } + } + + const previewHash = isLoadedStory ? getStoryMediaHash(story) : undefined; + const previewBlobUrl = useMedia(previewHash); + const isVideo = Boolean(isLoadedStory && story.content.video); + const noSound = isLoadedStory && story.content.video?.noSound; + const fullMediaHash = isLoadedStory ? getStoryMediaHash(story, 'full') : undefined; + const fullMediaData = useMedia(fullMediaHash, !story, isVideo ? ApiMediaFormat.Progressive : ApiMediaFormat.BlobUrl); + const altMediaHash = isVideo && isLoadedStory ? getStoryMediaHash(story, 'full', true) : undefined; + const altMediaData = useMedia(altMediaHash, !story, ApiMediaFormat.Progressive); + + const hasFullData = Boolean(fullMediaData || altMediaData); + const canPlayStory = Boolean( + hasFullData && !shouldForcePause && isAppFocused && !isComposerHasFocus && !isCaptionExpanded + && !isPausedBySpacebar && !isPausedByLongPress, + ); + const { + shouldRender: shouldRenderSkeleton, transitionClassNames: skeletonTransitionClassNames, + } = useShowTransition((isVideo && !hasFullData) || (!isVideo && !previewBlobUrl)); + + const { + transitionClassNames: mediaTransitionClassNames, + } = useShowTransition(Boolean(fullMediaData)); + + const hasThumb = !previewBlobUrl && !hasFullData; + const thumbRef = useCanvasBlur(thumbnail, !hasThumb); + const previewTransitionClassNames = useMediaTransition(previewBlobUrl); + + const { + shouldRender: shouldRenderComposer, + transitionClassNames: composerAppearanceAnimationClassNames, + } = useShowTransition(!isSelf); + + const { + shouldRender: shouldRenderCaptionBackdrop, + transitionClassNames: captionBackdropTransitionClassNames, + } = useShowTransition(hasText && isCaptionExpanded); + + const { transitionClassNames: appearanceAnimationClassNames } = useShowTransition(true); + + useStoryPreloader(userId, storyId); + + useEffect(() => { + if (storyId) { + viewStory({ userId, storyId }); + } + }, [storyId, userId]); + + useEffect(() => { + loadUserSkippedStories({ userId }); + }, [userId]); + + // Fetching user privacy settings for use in Composer + useEffect(() => { + if (!isChatExist) { + fetchChat({ chatId: userId }); + } + }, [isChatExist, userId]); + useEffect(() => { + if (isChatExist && !areChatSettingsLoaded) { + loadChatSettings({ chatId: userId }); + } + }, [areChatSettingsLoaded, isChatExist, userId]); + + const handlePauseStory = useLastCallback(() => { + if (isVideo) { + videoRef.current?.pause(); + } + + unmarkStoryPlaying(); + pauseStory(); + }); + + const handlePlayStory = useLastCallback(() => { + if (!canPlayStory) return; + + playStory(); + if (!isVideo) markStoryPlaying(); + }); + + const handleLongPressStart = useLastCallback(() => { + markIsPausedByLongPress(); + }); + const handleLongPressEnd = useLastCallback(() => { + unmarkIsPausedByLongPress(); + }); + + const { + onMouseDown: handleLongPressMouseDown, + onMouseUp: handleLongPressMouseUp, + onMouseLeave: handleLongPressMouseLeave, + onTouchStart: handleLongPressTouchStart, + onTouchEnd: handleLongPressTouchEnd, + } = useLongPress(handleLongPressStart, handleLongPressEnd); + + const isUnsupported = useUnsupportedMedia(videoRef, undefined, !isVideo || !fullMediaData); + + const hasAllData = fullMediaData && (!altMediaHash || altMediaData); + // Play story after media has been downloaded + useEffect(() => { if (hasAllData && !isUnsupported) handlePlayStory(); }, [hasAllData, isUnsupported]); + useBackgroundMode(unmarkAppFocused, markAppFocused); + + useEffect(() => { + if (!hasAllData) return; + videoRef.current?.load(); + }, [hasAllData]); + + useEffect(() => { + if (!isSelf || isDeletedStory || areViewsExpired) return; + if (story && 'recentViewerIds' in story && story.recentViewerIds?.length) return; + + // Refresh recent viewers list on new stories each view + loadStorySeenBy({ storyId }); + }, [isDeletedStory, areViewsExpired, isSelf, story, storyId]); + + useEffect(() => { + if ( + shouldForcePause || !isAppFocused || isComposerHasFocus + || isCaptionExpanded || isPausedBySpacebar || isPausedByLongPress + ) { + handlePauseStory(); + } else { + handlePlayStory(); + } + }, [ + handlePlayStory, isAppFocused, isCaptionExpanded, isComposerHasFocus, + shouldForcePause, isPausedBySpacebar, isPausedByLongPress, + ]); + + useEffect(() => { + if (isComposerHasFocus || shouldForcePause || isCaptionExpanded) { + return undefined; + } + + function handleKeyDown(e: KeyboardEvent) { + if (e.keyCode === SPACEBAR_CODE) { + e.preventDefault(); + setIsPausedBySpacebar(!isPausedBySpacebar); + } + } + + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [isCaptionExpanded, isComposerHasFocus, isPausedBySpacebar, shouldForcePause]); + + // Reset the state of `isPausedBySpacebar` when closing the caption, losing focus by composer or disable forced pause + useEffectWithPrevDeps(([ + prevIsComposerHasFocus, + prevIsCaptionExpanded, + prevShouldForcePause, + prevIsAppFocused, + prevIsPausedByLongPress, + ]) => { + if ( + !isPausedBySpacebar || isCaptionExpanded || isComposerHasFocus + || shouldForcePause || !isAppFocused || isPausedByLongPress + ) return; + + if ( + prevIsCaptionExpanded !== isCaptionExpanded + || prevIsComposerHasFocus !== isComposerHasFocus + || prevShouldForcePause !== shouldForcePause + || prevIsAppFocused !== isAppFocused + || prevIsPausedByLongPress !== isPausedByLongPress + ) { + setIsPausedBySpacebar(false); + } + }, [isComposerHasFocus, isCaptionExpanded, shouldForcePause, isAppFocused, isPausedByLongPress, isPausedBySpacebar]); + + const handleVideoStoryTimeUpdate = useLastCallback((e: React.SyntheticEvent) => { + const video = e.currentTarget; + if (video.readyState >= VIDEO_MIN_READY_STATE) { + setCurrentTime(video.currentTime); + } + }); + + const handleOpenChat = useLastCallback(() => { + onClose(); + openChat({ id: userId }); + }); + + const handleOpenPrevStory = useLastCallback(() => { + setCurrentTime(0); + openPreviousStory(); + }); + + const handleOpenNextStory = useLastCallback(() => { + setCurrentTime(0); + openNextStory(); + }); + + useEffect(() => { + return !getIsAnimating() && !isComposerHasFocus ? captureKeyboardListeners({ + onRight: handleOpenNextStory, + onLeft: handleOpenPrevStory, + }) : undefined; + }, [getIsAnimating, isComposerHasFocus]); + + const handleCopyStoryLink = useLastCallback(() => { + copyStoryLink({ userId, storyId }); + }); + + const handlePinClick = useLastCallback(() => { + toggleStoryPinned({ storyId, isPinned: true }); + }); + + const handleUnpinClick = useLastCallback(() => { + toggleStoryPinned({ storyId, isPinned: false }); + }); + + const handleDeleteStoryClick = useLastCallback(() => { + setCurrentTime(0); + onDelete(story!.id); + }); + + const handleReportStoryClick = useLastCallback(() => { + onReport(); + }); + + const handleForwardClick = useLastCallback(() => { + openForwardMenu({ fromChatId: userId, storyId }); + handlePauseStory(); + }); + + const handleOpenStorySeenBy = useLastCallback(() => { + openStorySeenBy({ storyId }); + }); + + const handleInfoPrivacyEdit = useLastCallback(() => { + openStoryPrivacyEditor(); + }); + + const handleInfoPrivacyClick = useLastCallback(() => { + const visibility = !isLoadedStory || story.isPublic + ? undefined + : story.isForContacts ? 'contacts' : (story.isForCloseFriends ? 'closeFriends' : 'selectedContacts'); + + let message; + const myName = getUserFirstOrLastName(user); + switch (visibility) { + case 'selectedContacts': + message = lang('StorySelectedContactsHint', myName); + break; + case 'contacts': + message = lang('StoryContactsHint', myName); + break; + case 'closeFriends': + message = lang('StoryCloseFriendsHint', myName); + break; + default: + return; + } + showNotification({ message }); + }); + + const handleVolumeMuted = useLastCallback(() => { + if (noSound) { + showNotification({ + message: lang('Story.TooltipVideoHasNoSound'), + }); + return; + } + // Browser requires explicit user interaction to keep video playing after unmuting + videoRef.current!.muted = !videoRef.current!.muted; + setStoryViewerMuted({ isMuted: !isMuted }); + }); + + useEffect(() => { + if (!isDeletedStory) return; + + showNotification({ + message: lang('StoryNotFound'), + }); + }, [lang, isDeletedStory]); + + const MenuButton: FC<{ onTrigger: () => void; isOpen?: boolean }> = useMemo(() => { + return ({ onTrigger, isOpen }) => { + return ( + + ); + }; + }, [isMobile, lang]); + + function renderStoriesTabs() { + const duration = isLoadedStory && story.content.video?.duration + ? story.content.video.duration + : undefined; + + return ( +
+ {(isSingleStory ? [storyId] : orderedIds ?? []).map((id) => ( + story?.id : id < story?.id))} + isPaused={!isStoryPlaying} + duration={duration} + onImageComplete={handleOpenNextStory} + /> + ))} +
+ ); + } + + function renderStoryPrivacyButton() { + let privacyIcon = 'channel-filled'; + const gradient: Record = { + 'channel-filled': ['#50ABFF', '#007AFF'], + 'user-filled': ['#C36EFF', '#8B60FA'], + 'favorite-filled': ['#88D93A', '#30B73B'], + 'group-filled': ['#FFB743', '#F69A36'], + }; + + if (isSelf) { + const { visibility } = (story && 'visibility' in story && story.visibility) || {}; + + switch (visibility) { + case 'everybody': + privacyIcon = 'channel-filled'; + break; + case 'contacts': + privacyIcon = 'user-filled'; + break; + case 'closeFriends': + privacyIcon = 'favorite-filled'; + break; + case 'selectedContacts': + privacyIcon = 'group-filled'; + } + } else { + if (!story || !('content' in story) || story.isPublic) { + return undefined; + } + + privacyIcon = story.isForCloseFriends + ? 'favorite-filled' + : (story.isForContacts ? 'user-filled' : 'group-filled'); + } + + return ( +
+ + {isSelf && } +
+ ); + } + + function renderSender() { + return ( +
+ +
+ + {renderText(getUserFirstOrLastName(user) || '')} + +
+ {story && 'date' in story && ( + {formatRelativeTime(lang, serverTime, story.date)} + )} + {isLoadedStory && story.isEdited && ( + {lang('Story.HeaderEdited')} + )} +
+
+ +
+ {renderStoryPrivacyButton()} + {isVideo && ( + + )} + + {lang('CopyLink')} + {canPinToProfile && ( + {lang('StorySave')} + )} + {canUnpinFromProfile && ( + {lang('ArchiveStory')} + )} + {isSelf && {lang('Delete')}} + {!isSelf && {lang('Report')}} + +
+
+ ); + } + + function renderRecentViewers() { + // No need for expensive global updates on chats and users, so we avoid them + const { users: { byId: usersById } } = getGlobal(); + + const { recentViewerIds, viewsCount } = story as ApiStory; + + if (!viewsCount) { + return ( +
+ {lang('NobodyViewed')} +
+ ); + } + + return ( +
+ {!areViewsExpired && recentViewerIds?.map((viewerId) => ( + + ))} + + {lang('Views', viewsCount, 'i')} +
+ ); + } + + return ( +
+
+ {renderStoriesTabs()} + {renderSender()} +
+ +
+ + {previewBlobUrl && ( + + )} + {shouldRenderSkeleton && ( + + )} + {!isVideo && fullMediaData && ( + + )} + {isVideo && fullMediaData && ( + + + {altMediaData && } + + )} + + {!isPausedByLongPress && !isComposerHasFocus && ( + <> +
+ + {isSelf && renderRecentViewers()} + {shouldRenderCaptionBackdrop && ( +
foldCaption()} + aria-label={lang('Close')} + /> + )} + {hasText && ( + + )} + {shouldRenderComposer && ( + + )} +
+ ); +} + +export default memo(withGlobal((global, { + userId, storyId, isPrivateStories, isArchivedStories, isReportModalOpen, isDeleteModalOpen, +}): StateProps => { + const { currentUserId, appConfig } = global; + const user = global.users.byId[userId]; + const chat = selectChat(global, userId); + const tabState = selectTabState(global); + const { + storyViewer: { isMuted, storyIdSeenBy, isPrivacyModalOpen }, + forwardMessages: { storyId: forwardedStoryId }, + premiumModal, + } = tabState; + const { isOpen: isPremiumModalOpen } = premiumModal || {}; + const { + byId, orderedIds, pinnedIds, archiveIds, + } = global.stories.byUserId[userId] || {}; + const story = byId && storyId ? byId[storyId] : undefined; + const shouldForcePause = Boolean( + storyIdSeenBy || forwardedStoryId || tabState.reactionPicker?.storyId || isReportModalOpen || isPrivacyModalOpen + || isPremiumModalOpen || isDeleteModalOpen, + ); + + return { + user, + story, + orderedIds: isArchivedStories ? archiveIds : (isPrivateStories ? pinnedIds : orderedIds), + isMuted, + isSelf: currentUserId === userId, + shouldForcePause, + storyChangelogUserId: appConfig!.storyChangelogUserId, + viewersExpirePeriod: appConfig!.storyExpirePeriod + appConfig!.storyViewersExpirePeriod, + isChatExist: Boolean(chat), + areChatSettingsLoaded: Boolean(chat?.settings), + }; +})(Story)); diff --git a/src/components/story/StoryCaption.tsx b/src/components/story/StoryCaption.tsx new file mode 100644 index 000000000..6aed59291 --- /dev/null +++ b/src/components/story/StoryCaption.tsx @@ -0,0 +1,98 @@ +import React, { + memo, useEffect, useRef, useState, +} from '../../lib/teact/teact'; + +import type { ApiStory } from '../../api/types'; + +import buildClassName from '../../util/buildClassName'; +import { requestMutation } from '../../lib/fasterdom/fasterdom'; +import { addExtraClass, removeExtraClass } from '../../lib/teact/teact-dom'; + +import usePrevDuringAnimation from '../../hooks/usePrevDuringAnimation'; +import useLang from '../../hooks/useLang'; + +import MessageText from '../common/MessageText'; + +import styles from './StoryViewer.module.scss'; + +interface OwnProps { + story: ApiStory; + isExpanded: boolean; + onExpand: NoneToVoidFunction; + className?: string; +} + +const EXPAND_ANIMATION_DURATION_MS = 400; +const OVERFLOW_THRESHOLD_PX = 4; + +function StoryCaption({ + story, isExpanded, className, onExpand, +}: OwnProps) { + const lang = useLang(); + // eslint-disable-next-line no-null/no-null + const ref = useRef(null); + // eslint-disable-next-line no-null/no-null + const contentRef = useRef(null); + const [hasOverflow, setHasOverflow] = useState(false); + const [height, setHeight] = useState(0); + const prevIsExpanded = usePrevDuringAnimation(isExpanded || undefined, EXPAND_ANIMATION_DURATION_MS); + + useEffect(() => { + if (!ref.current) { + return; + } + + const { scrollHeight, clientHeight } = ref.current; + setHasOverflow(scrollHeight - clientHeight > OVERFLOW_THRESHOLD_PX); + setHeight(scrollHeight - clientHeight); + }, []); + + useEffect(() => { + requestMutation(() => { + if (!contentRef.current) { + return; + } + + if (isExpanded) { + addExtraClass(contentRef.current, styles.animate); + } else { + removeExtraClass(contentRef.current, styles.animate); + } + }); + }, [height, isExpanded]); + + const canExpand = hasOverflow && !isExpanded; + const fullClassName = buildClassName( + styles.captionContent, + hasOverflow && !isExpanded && styles.hasOverflow, + (isExpanded || prevIsExpanded) && styles.expanded, + canExpand && styles.captionInteractive, + ); + + return ( +
+
onExpand() : undefined} + > +
+ {hasOverflow && ( +
+ {lang('Story.CaptionShowMore')} +
+ )} + + +
+
+
+ ); +} + +export default memo(StoryCaption); diff --git a/src/components/story/StoryDeleteConfirmModal.tsx b/src/components/story/StoryDeleteConfirmModal.tsx new file mode 100644 index 000000000..ea082d50a --- /dev/null +++ b/src/components/story/StoryDeleteConfirmModal.tsx @@ -0,0 +1,44 @@ +import React, { memo, useCallback } from '../../lib/teact/teact'; + +import { getActions } from '../../global'; + +import useLang from '../../hooks/useLang'; + +import ConfirmDialog from '../ui/ConfirmDialog'; + +interface OwnProps { + isOpen: boolean; + storyId?: number; + onClose: NoneToVoidFunction; +} + +function StoryDeleteConfirmModal({ isOpen, storyId, onClose }: OwnProps) { + const { deleteStory, openNextStory } = getActions(); + + const lang = useLang(); + + const handleDeleteStoryClick = useCallback(() => { + if (!storyId) { + return; + } + + openNextStory(); + deleteStory({ storyId }); + onClose(); + }, [onClose, storyId]); + + return ( + + ); +} + +export default memo(StoryDeleteConfirmModal); diff --git a/src/components/story/StoryPreview.tsx b/src/components/story/StoryPreview.tsx new file mode 100644 index 000000000..4194bc8b6 --- /dev/null +++ b/src/components/story/StoryPreview.tsx @@ -0,0 +1,92 @@ +import React, { memo, useEffect, useMemo } from '../../lib/teact/teact'; +import { getActions, withGlobal } from '../../global'; + +import type { ApiTypeStory, ApiUser, ApiUserStories } from '../../api/types'; + +import { selectTabState } from '../../global/selectors'; +import renderText from '../common/helpers/renderText'; +import { getStoryMediaHash, getUserFirstOrLastName } from '../../global/helpers'; +import useMedia from '../../hooks/useMedia'; + +import Avatar from '../common/Avatar'; + +import styles from './StoryViewer.module.scss'; + +interface OwnProps { + user?: ApiUser; + userStories?: ApiUserStories; +} + +interface StateProps { + lastViewedId?: number; +} + +function StoryPreview({ + user, userStories, lastViewedId, +}: OwnProps & StateProps) { + const { openStoryViewer, loadUserSkippedStories } = getActions(); + + const story = useMemo(() => { + if (!userStories) { + return undefined; + } + + const { + orderedIds, lastReadId, byId, + } = userStories; + const hasUnreadStories = orderedIds[orderedIds.length - 1] !== lastReadId; + const previewIndexId = lastViewedId ?? (hasUnreadStories ? (lastReadId ?? -1) : -1); + const resultId = byId[previewIndexId]?.id || orderedIds[0]; + + return byId[resultId]; + }, [lastViewedId, userStories]); + + useEffect(() => { + if (story && !('content' in story)) { + loadUserSkippedStories({ userId: story.userId }); + } + }, [story]); + + const video = story && 'content' in story ? story.content.video : undefined; + const imageHash = story && 'content' in story + ? getStoryMediaHash(story) + : undefined; + const imgBlobUrl = useMedia(imageHash); + const thumbUrl = imgBlobUrl || video?.thumbnail?.dataUri; + + if (!user || !story || 'isDeleted' in story) { + return undefined; + } + + return ( +
{ openStoryViewer({ userId: story.userId, storyId: story.id }); }} + > + {thumbUrl && ( + + )} + +
+ +
{renderText(getUserFirstOrLastName(user) || '')}
+
+
+ ); +} + +export default memo(withGlobal((global, { user }): StateProps => { + const { + storyViewer: { + lastViewedByUserIds, + }, + } = selectTabState(global); + + return { + lastViewedId: user?.id ? lastViewedByUserIds?.[user.id] : undefined, + }; +})(StoryPreview)); diff --git a/src/components/story/StoryProgress.module.scss b/src/components/story/StoryProgress.module.scss new file mode 100644 index 000000000..dd359c585 --- /dev/null +++ b/src/components/story/StoryProgress.module.scss @@ -0,0 +1,43 @@ +.root { + --progress-duration: 6s; + flex: 1 1 auto; + background-color: rgba(255, 255, 255, 0.25); + border-radius: 0.125rem; + margin: 0 0.125rem; + position: relative; + overflow: hidden; +} + +.viewed { + background-color: var(--color-white); +} + +.active { + background-color: rgba(255, 255, 255, 0.5); +} + +.inner { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + background: var(--color-white); + border-radius: 0.125rem; + transform-origin: 0 50%; + transform: translateX(-100%); + animation: progress var(--progress-duration) linear forwards; +} + +.paused .inner { + animation-play-state: paused; +} + +@keyframes progress { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(0); + } +} diff --git a/src/components/story/StoryProgress.tsx b/src/components/story/StoryProgress.tsx new file mode 100644 index 000000000..7b0a713ca --- /dev/null +++ b/src/components/story/StoryProgress.tsx @@ -0,0 +1,50 @@ +import React, { + memo, +} from '../../lib/teact/teact'; + +import buildClassName from '../../util/buildClassName'; + +import useLastCallback from '../../hooks/useLastCallback'; + +import styles from './StoryProgress.module.scss'; + +interface OwnProps { + isActive: boolean; + isViewed: boolean; + isVideo?: boolean; + duration?: number; + isPaused?: boolean; + onImageComplete: NoneToVoidFunction; +} + +const DEFAULT_STORY_DURATION_S = 6; + +function StoryProgress({ + isActive, isViewed, isVideo, duration = DEFAULT_STORY_DURATION_S, isPaused, onImageComplete, +}: OwnProps) { + const handleAnimationEnd = useLastCallback((event: React.AnimationEvent) => { + if (!isVideo && event.animationName === styles.progress) { + onImageComplete(); + } + }); + + const classNames = buildClassName( + styles.root, + isViewed && styles.viewed, + isActive && styles.active, + isPaused && styles.paused, + ); + + return ( + + {isActive && ( + + )} + + ); +} + +export default memo(StoryProgress); diff --git a/src/components/story/StoryRibbon.module.scss b/src/components/story/StoryRibbon.module.scss new file mode 100644 index 000000000..bb2c4b366 --- /dev/null +++ b/src/components/story/StoryRibbon.module.scss @@ -0,0 +1,76 @@ +.root { + display: flex; + justify-content: space-between; + column-gap: 0.875rem; + padding: 0.25rem 0.5rem 0.5rem 1rem; + overflow-x: auto; + overflow-y: hidden; + white-space: nowrap; + max-height: 5.5rem; + position: relative; + z-index: var(--z-story-ribbon); + + transition: opacity 0.2s ease-in-out; + + animation: fadeIn 0.2s ease-in-out; +} + +.closing { + opacity: 0; +} + +.user { + flex: 0 0 3.75rem; + width: 3.75rem; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + font-size: 0.75rem; + background: none; + border: none; + padding: 0; + cursor: var(--custom-cursor, pointer); + color: var(--color-text-secondary); + margin-inline: auto; + + &:focus { + outline: none; + } +} + +.name { + margin-top: 0.25rem; + overflow: hidden; + text-overflow: ellipsis; + unicode-bidi: plaintext; + max-width: 110%; + + &_hasUnreadStory { + color: var(--color-text); + } +} + +.hidden { + display: none; +} + +.contextMenu { + position: absolute; + + :global(.bubble) { + --offset-y: 0; + + width: auto; + } +} + +@keyframes fadeIn { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} diff --git a/src/components/story/StoryRibbon.tsx b/src/components/story/StoryRibbon.tsx new file mode 100644 index 000000000..31dce60bc --- /dev/null +++ b/src/components/story/StoryRibbon.tsx @@ -0,0 +1,78 @@ +import React, { memo, useRef } from '../../lib/teact/teact'; +import { withGlobal } from '../../global'; + +import type { ApiUser } from '../../api/types'; + +import { getIsMobile } from '../../hooks/useAppLayout'; +import buildClassName from '../../util/buildClassName'; +import useLang from '../../hooks/useLang'; +import useHorizontalScroll from '../../hooks/useHorizontalScroll'; + +import StoryRibbonButton from './StoryRibbonButton'; + +import styles from './StoryRibbon.module.scss'; + +interface OwnProps { + isArchived?: boolean; + className?: string; + isClosing?: boolean; +} + +interface StateProps { + orderedUserIds: string[]; + usersById: Record; +} + +function StoryRibbon({ + isArchived, className, orderedUserIds, usersById, isClosing, +}: OwnProps & StateProps) { + const lang = useLang(); + const fullClassName = buildClassName( + styles.root, + !orderedUserIds.length && styles.hidden, + isClosing && styles.closing, + className, + 'no-scrollbar', + ); + + // eslint-disable-next-line no-null/no-null + const ref = useRef(null); + + useHorizontalScroll(ref, getIsMobile()); + + return ( +
+ {orderedUserIds.map((userId) => { + const user = usersById[userId]; + + if (!user) { + return undefined; + } + + return ( + + ); + })} +
+ ); +} + +export default memo(withGlobal( + (global, { isArchived }): StateProps => { + const { orderedUserIds: { active, archived } } = global.stories; + const usersById = global.users.byId; + + return { + orderedUserIds: isArchived ? archived : active, + usersById, + }; + }, +)(StoryRibbon)); diff --git a/src/components/story/StoryRibbonButton.tsx b/src/components/story/StoryRibbonButton.tsx new file mode 100644 index 000000000..2d3dd0cfa --- /dev/null +++ b/src/components/story/StoryRibbonButton.tsx @@ -0,0 +1,156 @@ +import React, { memo, useRef } from '../../lib/teact/teact'; +import { getActions } from '../../global'; + +import type { ApiUser } from '../../api/types'; + +import buildClassName from '../../util/buildClassName'; +import { getUserFirstOrLastName } from '../../global/helpers'; +import { preventMessageInputBlurWithBubbling } from '../middle/helpers/preventMessageInputBlur'; + +import useLang from '../../hooks/useLang'; +import useContextMenuHandlers from '../../hooks/useContextMenuHandlers'; +import useLastCallback from '../../hooks/useLastCallback'; +import useMenuPosition from '../../hooks/useMenuPosition'; +import useStoryPreloader from './hooks/useStoryPreloader'; + +import Avatar from '../common/Avatar'; +import Menu from '../ui/Menu'; +import MenuItem from '../ui/MenuItem'; + +import styles from './StoryRibbon.module.scss'; + +interface OwnProps { + user: ApiUser; + isArchived?: boolean; +} + +function StoryRibbonButton({ user, isArchived }: OwnProps) { + const { + openChat, + openChatWithInfo, + openStoryViewer, + toggleStoriesHidden, + } = getActions(); + + const lang = useLang(); + // eslint-disable-next-line no-null/no-null + const ref = useRef(null); + + useStoryPreloader(user.id); + + const { + isContextMenuOpen, contextMenuPosition, + handleBeforeContextMenu, handleContextMenu, + handleContextMenuClose, handleContextMenuHide, + } = useContextMenuHandlers(ref); + + const getTriggerElement = useLastCallback(() => ref.current); + const getRootElement = useLastCallback(() => document.body); + const getMenuElement = useLastCallback(() => ref.current!.querySelector('.story-user-context-menu .bubble')); + const getLayout = useLastCallback(() => ({ withPortal: true, isDense: true })); + + const { + positionX, positionY, transformOriginX, transformOriginY, style: menuStyle, + } = useMenuPosition( + contextMenuPosition, + getTriggerElement, + getRootElement, + getMenuElement, + getLayout, + ); + + const handleClick = useLastCallback(() => { + if (isContextMenuOpen) return; + + openStoryViewer({ userId: user.id }); + }); + + const handleMouseDown = useLastCallback((e: React.MouseEvent) => { + preventMessageInputBlurWithBubbling(e); + handleBeforeContextMenu(e); + }); + + const handleSavedStories = useLastCallback(() => { + openChatWithInfo({ id: user.id, shouldReplaceHistory: true, profileTab: 'stories' }); + }); + + const handleArchivedStories = useLastCallback(() => { + openChatWithInfo({ id: user.id, shouldReplaceHistory: true, profileTab: 'storiesArchive' }); + }); + + const handleOpenChat = useLastCallback(() => { + openChat({ id: user.id, shouldReplaceHistory: true }); + }); + + const handleOpenProfile = useLastCallback(() => { + openChatWithInfo({ id: user.id, shouldReplaceHistory: true }); + }); + + const handleArchiveUser = useLastCallback(() => { + toggleStoriesHidden({ userId: user.id, isHidden: !isArchived }); + }); + + return ( +
+ +
+ {user.isSelf ? lang('MyStory') : getUserFirstOrLastName(user)} +
+ {contextMenuPosition !== undefined && ( + + {user.isSelf ? ( + <> + + {lang('StoryList.Context.SavedStories')} + + + {lang('StoryList.Context.ArchivedStories')} + + + ) : ( + <> + + {lang('SendMessageTitle')} + + + {lang('StoryList.Context.ViewProfile')} + + + {lang(isArchived ? 'StoryList.Context.Unarchive' : 'StoryList.Context.Archive')} + + + )} + + )} +
+ ); +} + +export default memo(StoryRibbonButton); diff --git a/src/components/story/StorySettings.module.scss b/src/components/story/StorySettings.module.scss new file mode 100644 index 000000000..9ce6b9e79 --- /dev/null +++ b/src/components/story/StorySettings.module.scss @@ -0,0 +1,191 @@ +.modal :global(.modal-dialog) { + max-width: 28rem; +} + +.modal :global(.modal-content) { + padding: 0; + display: flex; + flex-direction: column; + overflow: hidden; + color: var(--color-text); + height: 38rem; + + @supports (height: min(38rem, 90vh)) { + height: min(38rem, 90vh); + } +} + +.header { + padding: 1rem 1rem 0.75rem; + display: flex; + align-items: center; + flex-shrink: 0; +} + +.closeButton { + margin-inline-end: 1rem; +} + +.headerTitle { + margin-bottom: 0; + height: 100%; + line-height: 1; + display: flex; + align-items: center; +} + +.content { + min-height: 0; + border-radius: 0 0 var(--border-radius-default) var(--border-radius-default); + overflow: hidden; + overflow-y: auto; +} + +.section { + padding: 0 0.5rem; + + & + & { + border-top: 0.75rem solid #181818; + } +} + +.title { + font-size: 1rem; + font-weight: 500; + color: var(--color-text-secondary); + padding: 0 0.75rem 0.75rem; +} + +.list { + display: flex; + flex-direction: column; +} + +.option { + display: flex; + align-items: center; + width: 100%; + position: relative; + overflow: hidden; + margin-bottom: 0; + padding: 0.4375rem 0 0.4375rem 3.5rem; + border-radius: var(--border-radius-default); + + @media (hover: hover) { + &:hover, + &:focus { + background-color: var(--color-chat-hover); + } + } + + &::before, + &::after { + content: ""; + display: block; + position: absolute; + left: 1rem; + top: 50%; + width: 1.25rem; + height: 1.25rem; + transform: translateY(-50%); + } + + &::before { + border: 2px solid var(--color-borders-input); + border-radius: 50%; + background-color: var(--color-background); + opacity: 1; + transition: border-color 0.1s ease, opacity 0.1s ease; + } + + &::after { + left: 1.3125rem; + width: 0.625rem; + height: 0.625rem; + border-radius: 50%; + background: var(--color-primary); + opacity: 0; + transition: opacity 0.1s ease; + } + + &.checked { + &::before { + border-color: var(--color-primary); + } + + &::after { + opacity: 1; + } + } +} + +.input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + top: -1rem; + z-index: -1; +} + +.icon { + display: flex; + flex: 0 0 2.625rem; + align-items: center; + justify-content: center; + width: 2.625rem; + height: 2.625rem; + border-radius: 50%; + background: linear-gradient(180deg, var(--color-from) 0%, var(--color-to) 100%); + color: #fff; + font-size: 1.5rem; + margin-inline-end: 1rem; + + > :global(.icon-group-filled) { + font-size: 1.25rem; + } +} + +.action { + color: #8774E1; + cursor: var(--custom-cursor, pointer); + opacity: 0.8; + transition: opacity 200ms; + + > :global(.icon) { + font-size: 0.875rem; + line-height: 1; + vertical-align: -0.0625rem; + } + + @media (hover: hover) { + &:hover, + &:active { + opacity: 1; + } + } +} + +.optionContent { + display: flex; + flex-direction: column; + align-items: flex-start; +} + +.footer { + background-color: #181818; + flex-grow: 1; + display: flex; + flex-direction: column; +} + +.info { + color: var(--color-text-secondary); + font-size: 0.875rem; + padding: 0.5rem 1rem; +} + +.submit { + padding: 1rem; + margin-top: auto; +} diff --git a/src/components/story/StorySettings.tsx b/src/components/story/StorySettings.tsx new file mode 100644 index 000000000..49260e7f0 --- /dev/null +++ b/src/components/story/StorySettings.tsx @@ -0,0 +1,409 @@ +import React, { + memo, useEffect, useMemo, useState, +} from '../../lib/teact/teact'; +import { getActions, withGlobal } from '../../global'; + +import type { ApiStory, ApiUser } from '../../api/types'; +import type { ApiPrivacySettings, PrivacyVisibility } from '../../types'; + +import buildClassName from '../../util/buildClassName'; +import { selectTabState, selectUserStory } from '../../global/selectors'; +import { getUserFullName } from '../../global/helpers'; +import stopEvent from '../../util/stopEvent'; + +import useLang from '../../hooks/useLang'; +import useFlag from '../../hooks/useFlag'; +import useLastCallback from '../../hooks/useLastCallback'; + +import Modal from '../ui/Modal'; +import ListItem from '../ui/ListItem'; +import Switcher from '../ui/Switcher'; +import Button from '../ui/Button'; +import Transition from '../ui/Transition'; +import CloseFriends from './privacy/CloseFriends'; +import AllowDenyList from './privacy/AllowDenyList'; + +import styles from './StorySettings.module.scss'; + +interface OwnProps { + isOpen?: boolean; + onClose?: NoneToVoidFunction; +} + +interface StateProps { + story?: ApiStory; + visibility?: ApiPrivacySettings; + contactListIds?: string[]; + usersById: Record; + currentUserId: string; +} + +type PrivacyAction = 'blockUserIds' | 'closeFriends' | 'allowUserIds'; + +interface PrivacyOption { + name: string; + value: PrivacyVisibility; + color: [string, string]; + icon: string; + actions?: PrivacyAction; +} + +const OPTIONS: PrivacyOption[] = [{ + name: 'StoryPrivacyOptionEveryone', + value: 'everybody', + color: ['#50ABFF', '#007AFF'], + icon: 'channel-filled', + actions: undefined, +}, { + name: 'StoryPrivacyOptionContacts', + value: 'contacts', + color: ['#C36EFF', '#8B60FA'], + icon: 'user-filled', + actions: 'blockUserIds', +}, { + name: 'StoryPrivacyOptionCloseFriends', + value: 'closeFriends', + color: ['#88D93A', '#30B73B'], + icon: 'favorite-filled', + actions: 'closeFriends', +}, { + name: 'StoryPrivacyOptionSelectedContacts', + value: 'selectedContacts', + color: ['#FFB743', '#F69A36'], + icon: 'group-filled', + actions: 'allowUserIds', +}]; + +enum Screens { + privacy, + allowList, + closeFriends, + denyList, +} + +function StorySettings({ + isOpen, story, visibility, contactListIds, usersById, currentUserId, onClose, +}: OwnProps & StateProps) { + const { editStoryPrivacy, toggleStoryPinned } = getActions(); + + const lang = useLang(); + const [isOpenModal, openModal, closeModal] = useFlag(false); + const [privacy, setPrivacy] = useState(visibility); + const [isPinned, setIsPinned] = useState(story?.isPinned); + const [activeKey, setActiveKey] = useState(Screens.privacy); + const isBackButton = activeKey !== Screens.privacy; + + const closeFriendIds = useMemo(() => { + return (contactListIds || []).filter((userId) => usersById[userId]?.isCloseFriend); + }, [contactListIds, usersById]); + + const lockedIds = useMemo(() => { + if (activeKey === Screens.allowList + && (!privacy?.allowUserIds?.length || privacy.allowUserIds[0] === currentUserId) + ) { + return [currentUserId]; + } + + return undefined; + }, [activeKey, currentUserId, privacy?.allowUserIds]); + + const handleAllowUserIdsChange = useLastCallback((newIds: string[]) => { + setPrivacy({ + ...privacy!, + allowUserIds: newIds?.length ? newIds?.filter((id) => id !== currentUserId) : [currentUserId], + }); + }); + + const handleDenyUserIdsChange = useLastCallback((newIds: string[]) => { + setPrivacy({ + ...privacy!, + blockUserIds: newIds, + }); + }); + + useEffect(() => { + if (isOpen) { + setActiveKey(Screens.privacy); + openModal(); + } + }, [isOpen]); + + useEffect(() => { + setPrivacy(visibility); + }, [visibility]); + + const handleCloseButtonClick = useLastCallback(() => { + if (activeKey === Screens.privacy) { + closeModal(); + return; + } + + setActiveKey(Screens.privacy); + }); + + function handleVisibilityChange(newVisibility: PrivacyVisibility) { + setPrivacy({ + ...privacy!, + visibility: newVisibility, + }); + } + + function handleActionClick(e: React.MouseEvent, action: PrivacyAction) { + stopEvent(e); + + switch (action) { + case 'closeFriends': + setActiveKey(Screens.closeFriends); + break; + case 'allowUserIds': + setActiveKey(Screens.allowList); + break; + case 'blockUserIds': + setActiveKey(Screens.denyList); + } + } + + const handleIsPinnedToggle = useLastCallback(() => { + setIsPinned(!isPinned); + }); + + // console.warn(privacy?.visibility, story?.visibility, OPTIONS); + + const handleSubmit = useLastCallback(() => { + editStoryPrivacy({ + storyId: story!.id, + privacy: privacy!, + }); + if (story!.isPinned !== isPinned) { + toggleStoryPinned({ storyId: story!.id, isPinned }); + } + closeModal(); + }); + + function renderActionName(action: PrivacyAction) { + if (action === 'closeFriends') { + if (closeFriendIds.length === 0) { + return lang('StoryPrivacyOptionCloseFriendsDetail'); + } + + if (closeFriendIds.length === 1) { + return getUserFullName(usersById[closeFriendIds[0]]); + } + + return lang('StoryPrivacyOptionPeople', closeFriendIds.length, 'i'); + } + + if (action === 'blockUserIds') { + if (!privacy?.blockUserIds || privacy.blockUserIds.length === 0) { + return lang('StoryPrivacyOptionContactsDetail'); + } + + if (privacy.blockUserIds.length === 1) { + return lang('StoryPrivacyOptionExcludePerson', getUserFullName(usersById[closeFriendIds[0]])); + } + + return lang('StoryPrivacyOptionExcludePeople', privacy.blockUserIds.length, 'i'); + } + + if (!privacy?.allowUserIds || privacy.allowUserIds.length === 0) { + return lang('StoryPrivacyOptionSelectedContactsDetail'); + } + + if (privacy.allowUserIds.length === 1) { + return getUserFullName(usersById[privacy.allowUserIds[0]]); + } + + return lang('StoryPrivacyOptionPeople', privacy.allowUserIds.length, 'i'); + } + + // eslint-disable-next-line consistent-return + function renderHeaderContent() { + switch (activeKey) { + case Screens.privacy: + return

{lang('StoryPrivacyAlertEditTitle')}

; + case Screens.allowList: + return

{lang('StoryPrivacyAlertSelectContactsTitle')}

; + case Screens.closeFriends: + return

{lang('CloseFriends')}

; + case Screens.denyList: + return

{lang('StoryPrivacyAlertExcludedContactsTitle')}

; + } + } + + // eslint-disable-next-line consistent-return + function renderContent(isActive: boolean) { + switch (activeKey) { + case Screens.privacy: + return renderPrivacyList(); + case Screens.closeFriends: + return ( + + ); + case Screens.denyList: + return ( + + ); + case Screens.allowList: + return ( + + ); + } + } + + function renderPrivacyList() { + const storyLifeTime = story ? convertSecondsToHours(story.expireDate - story.date) : 0; + + return ( + <> +
+

{lang('StoryPrivacyAlertSubtitleProfile')}

+
+ {OPTIONS.map((option) => ( + + ))} +
+
+
+ + {lang('StoryKeep')} + + +
+
+
{lang('StoryKeepInfo', storyLifeTime)}
+
+ +
+
+ + ); + } + + return ( + +
+ + + {renderHeaderContent()} + +
+ + {renderContent} + +
+ ); +} + +export default memo(withGlobal((global): StateProps => { + const { + storyViewer: { + storyId, userId, + }, + } = selectTabState(global); + const story = (userId && storyId) + ? selectUserStory(global, userId, storyId) + : undefined; + + return { + story: story && 'content' in story ? story as ApiStory : undefined, + visibility: story && 'visibility' in story ? story.visibility : undefined, + contactListIds: global.contactList?.userIds, + usersById: global.users.byId, + currentUserId: global.currentUserId!, + }; +})(StorySettings)); + +function convertSecondsToHours(seconds: number): number { + const secondsInHour = 3600; + const minutesInHour = 60; + + const hours = Math.floor(seconds / secondsInHour); + const remainingSeconds = seconds % secondsInHour; + const remainingMinutes = Math.floor(remainingSeconds / minutesInHour); + + // If remaining minutes are greater than or equal to 30, round up the hours + return remainingMinutes >= 30 ? hours + 1 : hours; +} diff --git a/src/components/story/StorySlides.tsx b/src/components/story/StorySlides.tsx new file mode 100644 index 000000000..5648f42cc --- /dev/null +++ b/src/components/story/StorySlides.tsx @@ -0,0 +1,319 @@ +import React, { + memo, useEffect, useLayoutEffect, useMemo, useRef, useState, +} from '../../lib/teact/teact'; +import { getGlobal, withGlobal } from '../../global'; + +import type { ApiUserStories } from '../../api/types'; + +import { IS_FIREFOX, IS_SAFARI } from '../../util/windowEnvironment'; +import { ANIMATION_END_DELAY } from '../../config'; +import { selectIsStoryViewerOpen, selectTabState, selectUser } from '../../global/selectors'; +import { calculateOffsetX, calculateSlideSizes } from './helpers/dimensions'; +import buildClassName from '../../util/buildClassName'; +import buildStyle from '../../util/buildStyle'; + +import useLastCallback from '../../hooks/useLastCallback'; +import usePrevious from '../../hooks/usePrevious'; +import useWindowSize from '../../hooks/useWindowSize'; +import useCurrentOrPrev from '../../hooks/useCurrentOrPrev'; +import useSignal from '../../hooks/useSignal'; + +import Story from './Story'; +import StoryPreview from './StoryPreview'; + +import styles from './StoryViewer.module.scss'; + +interface OwnProps { + isReportModalOpen?: boolean; + isDeleteModalOpen?: boolean; + onDelete: (storyId: number) => void; + onReport: NoneToVoidFunction; + onClose: NoneToVoidFunction; +} + +interface StateProps { + userIds: string[]; + currentUserId?: string; + currentStoryId?: number; + byUserId?: Record; + isSingleUser?: boolean; + isSingleStory?: boolean; + isPrivate?: boolean; + isArchive?: boolean; +} + +const ANIMATION_DURATION_MS = 350 + (IS_SAFARI || IS_FIREFOX ? ANIMATION_END_DELAY : 20); +const ACTIVE_SLIDE_VERTICAL_CORRECTION_REM = 1.75; +const FROM_ACTIVE_SCALE_VALUE = 0.333; +const ANIMATION_TO_ACTIVE_SCALE = '3'; +const ANIMATION_FROM_ACTIVE_SCALE = `${FROM_ACTIVE_SCALE_VALUE}`; + +function StorySlides({ + userIds, currentUserId, currentStoryId, isSingleUser, isSingleStory, isPrivate, isArchive, byUserId, + isReportModalOpen, isDeleteModalOpen, onDelete, onClose, onReport, +}: OwnProps & StateProps) { + const [renderingUserId, setRenderingUserId] = useState(currentUserId); + const [renderingStoryId, setRenderingStoryId] = useState(currentStoryId); + const prevUserId = usePrevious(currentUserId); + const renderingIsArchive = useCurrentOrPrev(isArchive, true); + const renderingIsPrivate = useCurrentOrPrev(isPrivate, true); + const renderingIsSingleUser = useCurrentOrPrev(isSingleUser, true); + const renderingIsSingleStory = useCurrentOrPrev(isSingleStory, true); + const { width: windowWidth, height: windowHeight } = useWindowSize(); + const slideSizes = useMemo(() => { + return calculateSlideSizes(windowWidth, windowHeight); + }, [windowWidth, windowHeight]); + const rendersRef = useRef>({}); + const [getIsAnimating, setIsAnimating] = useSignal(false); + + function setRef(ref: HTMLDivElement | null, userId: string) { + if (!ref) { + return; + } + if (!rendersRef.current[userId]) { + rendersRef.current[userId] = { current: ref }; + } else { + rendersRef.current[userId].current = ref; + } + } + + const renderingUserIds = useMemo(() => { + if (renderingUserId && (renderingIsSingleUser || renderingIsSingleStory)) { + return [renderingUserId]; + } + + const index = renderingUserId ? userIds.indexOf(renderingUserId) : -1; + if (!renderingUserId || index === -1) { + return []; + } + + const start = Math.max(index - 4, 0); + const end = Math.min(index + 5, userIds.length); + + return userIds.slice(start, end); + }, [renderingIsSingleStory, renderingIsSingleUser, renderingUserId, userIds]); + + const renderingUserPosition = useMemo(() => { + if (!renderingUserIds.length || !renderingUserId) { + return -1; + } + + return renderingUserIds.indexOf(renderingUserId); + }, [renderingUserId, renderingUserIds]); + + const currentUserPosition = useMemo(() => { + if (!renderingUserIds.length || !currentUserId) { + return -1; + } + return renderingUserIds.indexOf(currentUserId); + }, [currentUserId, renderingUserIds]); + + useEffect(() => { + const timeoutId = window.setTimeout(() => { + setRenderingUserId(currentUserId); + }, ANIMATION_DURATION_MS); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [currentUserId]); + + useEffect(() => { + let timeOutId: number | undefined; + + if (renderingUserId !== currentUserId) { + timeOutId = window.setTimeout(() => { + setRenderingStoryId(currentStoryId); + }, ANIMATION_DURATION_MS); + } else if (currentStoryId !== renderingStoryId) { + setRenderingStoryId(currentStoryId); + } + + return () => { + window.clearTimeout(timeOutId); + }; + }, [renderingUserId, currentStoryId, currentUserId, renderingStoryId]); + + useEffect(() => { + let timeOutId: number | undefined; + + if (prevUserId && prevUserId !== currentUserId) { + setIsAnimating(true); + timeOutId = window.setTimeout(() => { + setIsAnimating(false); + }, ANIMATION_DURATION_MS); + } + + return () => { + setIsAnimating(false); + window.clearTimeout(timeOutId); + }; + }, [prevUserId, currentUserId, setIsAnimating]); + + const slideAmount = currentUserPosition - renderingUserPosition; + const isBackward = renderingUserPosition > currentUserPosition; + + const calculateTransformX = useLastCallback(() => { + return userIds.reduce>((transformX, userId, index) => { + if (userId === renderingUserId) { + transformX[userId] = calculateOffsetX({ + scale: slideSizes.scale, + slideAmount, + isBackward, + isActiveSlideSize: isBackward, + }); + } else { + let isMoveThroughActiveSlide = false; + if (!isBackward && index > 0 && userIds[index - 1] === renderingUserId) { + isMoveThroughActiveSlide = true; + } + if (isBackward && index < userIds.length - 1 && userIds[index + 1] === renderingUserId) { + isMoveThroughActiveSlide = true; + } + + transformX[userId] = calculateOffsetX({ + scale: slideSizes.scale, + slideAmount, + isBackward, + isActiveSlideSize: currentUserId === userId && !isBackward, + isMoveThroughActiveSlide, + }); + } + + return transformX; + }, {}); + }); + + useLayoutEffect(() => { + const transformX = calculateTransformX(); + + Object.entries(rendersRef.current).forEach(([userId, { current }]) => { + if (!current) return; + + if (!getIsAnimating()) { + current.classList.remove(styles.slideAnimation, styles.slideAnimationToActive, styles.slideAnimationFromActive); + current.style.setProperty('--slide-translate-x', '0px'); + current.style.setProperty('--slide-translate-y', '0px'); + current.style.setProperty('--slide-translate-scale', '1'); + + return; + } + + const scale = currentUserId === userId + ? ANIMATION_TO_ACTIVE_SCALE + : userId === renderingUserId ? ANIMATION_FROM_ACTIVE_SCALE : '1'; + + let offsetY = 0; + if (userId === renderingUserId) { + offsetY = -ACTIVE_SLIDE_VERTICAL_CORRECTION_REM * FROM_ACTIVE_SCALE_VALUE; + current.classList.add(styles.slideAnimationFromActive); + } + if (userId === currentUserId) { + offsetY = ACTIVE_SLIDE_VERTICAL_CORRECTION_REM; + current.classList.add(styles.slideAnimationToActive); + } + + current.classList.add(styles.slideAnimation); + current.style.setProperty('--slide-translate-x', `${transformX[userId] || 0}px`); + current.style.setProperty('--slide-translate-y', `${offsetY}rem`); + current.style.setProperty('--slide-translate-scale', scale); + }); + }, [currentUserId, getIsAnimating, renderingUserId]); + + function renderStoryPreview(userId: string, index: number, position: number) { + const style = buildStyle( + `width: ${slideSizes.slide.width}px`, + `height: ${slideSizes.slide.height}px`, + ); + const className = buildClassName( + styles.slide, + styles.slidePreview, + `slide-${position}`, + ); + + return ( +
setRef(ref, userId)} + className={className} + style={style} + > + +
+ ); + } + + function renderStory(userId: string) { + const style = buildStyle( + `width: ${slideSizes.activeSlide.width}px`, + `--slide-media-height: ${slideSizes.activeSlide.height}px`, + ); + + return ( +
setRef(ref, userId)} + className={buildClassName(styles.slide, styles.activeSlide)} + style={style} + > + +
+ ); + } + + return ( +
+
+ {renderingUserIds.length > 1 && ( +
+ )} + {renderingUserIds.map((userId, index) => { + if (userId === renderingUserId) { + return renderStory(renderingUserId); + } + + return renderStoryPreview(userId, index, index - renderingUserPosition); + })} +
+ ); +} + +export default memo(withGlobal((global, ownProps, detachWhenChanged): StateProps => { + const { + storyViewer: { + userId: currentUserId, storyId: currentStoryId, isSingleUser, isSingleStory, isPrivate, isArchive, + }, + } = selectTabState(global); + const { byUserId, orderedUserIds: { archived, active } } = global.stories; + const user = currentUserId ? selectUser(global, currentUserId) : undefined; + + const isOpen = selectIsStoryViewerOpen(global); + detachWhenChanged(isOpen); + + return { + byUserId, + userIds: user?.areStoriesHidden ? archived : active, + currentUserId, + currentStoryId, + isSingleUser, + isSingleStory, + isPrivate, + isArchive, + }; +})(StorySlides)); diff --git a/src/components/story/StoryToggler.module.scss b/src/components/story/StoryToggler.module.scss new file mode 100644 index 000000000..499b1a581 --- /dev/null +++ b/src/components/story/StoryToggler.module.scss @@ -0,0 +1,35 @@ +.root { + position: absolute; + top: 50%; + right: 0.125rem; + transform: translateY(-50%); + padding: 0; + margin: 0; + border: none; + background: none; + outline: none !important; + cursor: var(--custom-cursor, pointer); + display: flex; + flex-direction: row-reverse; + + &[dir="rtl"] { + right: auto; + left: 0.125rem; + } +} + +.avatar { + border: 0.125rem solid var(--color-background); + + &::before { + z-index: -2; + } + + &:global(.has-unread-story)::before { + z-index: -1; + } +} + +.avatar + .avatar { + margin-inline-end: -1.125rem; +} diff --git a/src/components/story/StoryToggler.tsx b/src/components/story/StoryToggler.tsx new file mode 100644 index 000000000..9cd0d2f71 --- /dev/null +++ b/src/components/story/StoryToggler.tsx @@ -0,0 +1,94 @@ +import React, { memo, useMemo } from '../../lib/teact/teact'; +import { getActions, withGlobal } from '../../global'; + +import type { ApiUser } from '../../api/types'; + +import { PREVIEW_AVATAR_COUNT } from '../../config'; +import buildClassName from '../../util/buildClassName'; +import { selectTabState } from '../../global/selectors'; + +import useLang from '../../hooks/useLang'; +import useShowTransition from '../../hooks/useShowTransition'; +import useStoryPreloader from './hooks/useStoryPreloader'; + +import Avatar from '../common/Avatar'; + +import styles from './StoryToggler.module.scss'; + +interface OwnProps { + isArchived?: boolean; + canShow?: boolean; +} + +interface StateProps { + currentUserId: string; + orderedUserIds: string[]; + isShown: boolean; + usersById: Record; +} + +const PRELOAD_USERS = 5; + +function StoryToggler({ + currentUserId, + orderedUserIds, + usersById, + canShow, + isShown, + isArchived, +}: OwnProps & StateProps) { + const { toggleStoryRibbon } = getActions(); + + const lang = useLang(); + + const users = useMemo(() => { + return orderedUserIds + .map((id) => usersById[id]) + .filter((user) => user && user.id !== currentUserId) + .slice(0, PREVIEW_AVATAR_COUNT) + .reverse(); + }, [currentUserId, orderedUserIds, usersById]); + + const preloadUserIds = useMemo(() => { + return orderedUserIds.slice(0, PRELOAD_USERS); + }, [orderedUserIds]); + useStoryPreloader(preloadUserIds); + + const { shouldRender, transitionClassNames } = useShowTransition(canShow && isShown); + + if (!shouldRender) { + return undefined; + } + + return ( + + ); +} + +export default memo(withGlobal((global, { isArchived }): StateProps => { + const { orderedUserIds: { archived, active } } = global.stories; + const { storyViewer: { isRibbonShown, isArchivedRibbonShown } } = selectTabState(global); + + return { + currentUserId: global.currentUserId!, + orderedUserIds: isArchived ? archived : active, + isShown: isArchived ? !isArchivedRibbonShown : !isRibbonShown, + usersById: global.users.byId, + }; +})(StoryToggler)); diff --git a/src/components/story/StoryViewer.async.tsx b/src/components/story/StoryViewer.async.tsx new file mode 100644 index 000000000..38bb5a4e0 --- /dev/null +++ b/src/components/story/StoryViewer.async.tsx @@ -0,0 +1,17 @@ +import type { FC } from '../../lib/teact/teact'; +import React, { memo } from '../../lib/teact/teact'; + +import { Bundles } from '../../util/moduleLoader'; +import useModuleLoader from '../../hooks/useModuleLoader'; + +interface OwnProps { + isOpen: boolean; +} + +const StoryViewerAsync: FC = ({ isOpen }) => { + const StoryViewer = useModuleLoader(Bundles.Extra, 'StoryViewer', !isOpen); + + return StoryViewer ? : undefined; +}; + +export default memo(StoryViewerAsync); diff --git a/src/components/story/StoryViewer.module.scss b/src/components/story/StoryViewer.module.scss new file mode 100644 index 000000000..5fc7e7711 --- /dev/null +++ b/src/components/story/StoryViewer.module.scss @@ -0,0 +1,703 @@ +@import "../../styles/mixins"; + +.root { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: var(--z-story-viewer); + transform-origin: 50% 50%; + + @media (max-width: 600px) { + background: rgba(0, 0, 0, 1); + } + + // Potential perf improvement + &:not(:global(.shown)) { + display: block !important; + transform: scale(0); + } + + :global(.opacity-transition) { + transition: opacity 350ms; + @media (max-width: 600px) { + transition: none; + } + } + + :global(.text-entity-link) { + --color-links: var(--color-white); + + text-decoration: underline !important; + + &:hover { + text-decoration: none !important; + } + } + + @media (max-width: 600px) { + transition: transform var(--layer-transition); + + :global(body.enable-symbol-menu-transforms) & { + transform: translate3d(0, 0, 0); + } + + :global(body.is-symbol-menu-open) & { + transform: translate3d(0, calc(-1 * (var(--symbol-menu-height))), 0); + } + } +} + +.fullSize, .backdrop, .captionBackdrop { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} + +.captionBackdrop { + background-color: rgba(0, 0, 0, 0.5); +} + +.backdrop { + background-color: rgba(0, 0, 0, 0.9); +} + +.backdropNonInteractive { + position: absolute; + top: 50%; + left: 0; + right: 0; + + transform: translateY(-50%); +} + +.close { + position: absolute; + right: 1rem; + top: 1rem; + z-index: 2; + + @media (max-width: 600px) { + top: 1.125rem; + } +} + +.wrapper { + position: absolute; + top: 0; + left: 50%; + width: 100vw; + height: 100%; + overflow: hidden; + transform: translateX(-50%); + max-width: calc(73.5rem * var(--story-viewer-scale)); + + @media (max-width: 600px) { + max-width: 100%; + } +} + +.slideAnimation { + transition: transform 350ms ease-in-out; +} + +.slideAnimationToActive { + @media (min-width: 600.001px) { + --border-radius-default-small: 0.25rem; + + &::before { + pointer-events: none; + content: ""; + position: absolute; + left: 0; + top: 0; + right: 0; + height: 4.5rem; + background: linear-gradient(to bottom, rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0)); + z-index: 1; + } + + .content { + opacity: 0; + } + } +} + +.slideAnimationFromActive { + @media (min-width: 600.001px) { + .composer, + .caption, + .storyIndicators { + transition: opacity 250ms ease-in-out; + opacity: 0; + } + } +} + +.slide { + position: absolute; + top: 50%; + left: 50%; + transform: translate3d( + calc(var(--slide-x, -50%) - var(--slide-translate-x, 0px)), + calc(-50% - var(--slide-translate-y, 0px)), + 0 + ) + scale(var(--slide-translate-scale, 1)); + transform-origin: 0 50%; + + border-radius: var(--border-radius-default-small); + + @for $i from -4 through 4 { + $slideWidth: 10.875rem; + $basis: 4.25rem; + @if $i < 0 { + $basis: -12.625rem; + } + + $offset: $basis + $i * $slideWidth; + + &:global(.slide-#{$i}) { + --slide-x: calc(#{$offset} * var(--story-viewer-scale)); + } + } + + @media (max-width: 600px) { + display: none; + border-radius: 0; + } +} + +.slidePreview { + overflow: hidden; + + &::before { + pointer-events: none; + content: ""; + position: absolute; + left: 0; + top: 0; + right: 0; + height: 100%; + background: rgba(0, 0, 0, 0.5); + z-index: 1; + } + + &.slideAnimationToActive::before { + transition: opacity 350ms ease-in-out; + opacity: 0; + } +} + +.activeSlide { + height: calc(var(--slide-media-height) + 3.5rem); + z-index: 1; + + @media (max-width: 600px) { + display: block; + left: 0; + top: 0; + width: 100% !important; + height: 100%; + transform: none; + + &::before { + display: none; + } + } + + &::before { + pointer-events: none; + content: ""; + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 3.5rem; + background: rgba(0, 0, 0, 0.5); + opacity: 0; + z-index: 3; + } + + @media (min-width: 600.001px) { + &.slideAnimationFromActive::before { + transition: opacity 350ms ease-in-out; + opacity: 1; + } + } +} + +.slideInner { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; +} + +.mediaWrapper { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + background: #000; + border-radius: var(--border-radius-default-small); + overflow: hidden; + + @media (max-width: 600px) { + width: 100% !important; + height: calc(100% - 4rem) !important; + border-radius: 0; + } +} + +.media { + position: absolute; + left: 0; + top: 0; + + object-fit: cover; + width: inherit; + height: inherit; + border-radius: var(--border-radius-default-small); + + @media (max-width: 600px) { + bottom: 0; + width: 100%; + height: 100%; + border-radius: 0; + object-fit: contain; + } +} + +.content { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 2; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + max-width: 90%; + transition: opacity 300ms; +} + +.name { + margin-top: 0.25rem; + color: var(--color-white); + font-size: 1rem; + font-weight: 500; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.forward { + position: absolute; + right: 0.75rem; + bottom: 4.25rem; + opacity: 0.5; + z-index: 2; + + transition: opacity 300ms; + + &:hover { + opacity: 1; + } + + @media (max-width: 600px) { + bottom: 4.75rem; + } +} + +.storyHeader { + position: absolute; + width: 100%; + content: ""; + left: 0; + top: 0; + right: 0; + height: 4.5rem; + background: linear-gradient(to bottom, rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0)); + z-index: 1; + border-radius: var(--border-radius-default-small) var(--border-radius-default-small) 0 0; +} + +.storyIndicators { + position: absolute; + width: 100%; + height: 0.125rem; + padding: 0 0.375rem; + z-index: 2; + + display: flex; + top: 0.5rem; + left: 0; +} + +.sender { + position: absolute; + z-index: 2; + right: 0.5rem; + left: 1rem; + top: 1.25rem; + display: flex; + color: var(--color-white); + + align-items: center; +} + +.senderInfo { + display: inline-flex; + flex-direction: column; + margin-left: 0.75rem; + line-height: 1.25rem; + overflow: hidden; +} + +.senderName { + font-size: 1rem; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + white-space: nowrap; + cursor: var(--custom-cursor, pointer); +} + +.storyMetaRow { + display: flex; + align-items: center; + overflow: hidden; +} + +.storyMeta { + font-size: 0.875rem; + opacity: 0.5; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + & + & { + margin-left: 0.375rem; + + &::before { + content: ""; + width: 0.25rem; + height: 0.25rem; + border-radius: 50%; + background-color: var(--color-white); + display: inline-block; + margin-inline-end: 0.375rem; + position: relative; + top: -0.125rem; + } + } +} + +.actions { + margin-inline-start: auto; + display: flex; + align-items: center; + + @media (max-width: 600px) { + position: relative; + right: 3.25rem; + } +} + +.visibilityButton { + min-width: 1.5rem; + height: 1.5rem; + border-radius: 1.5rem; + display: inline-flex; + align-items: center; + justify-content: center; + background: linear-gradient(180deg, var(--color-from) 0%, var(--color-to) 100%); + color: #fff; + font-size: 0.75rem; + cursor: var(--custom-cursor, pointer); + + > :global(.icon + .icon) { + margin-left: 0.125rem; + } +} + +.visibilityButtonSelf { + padding: 0 0.25rem 0 0.375rem; +} + +.button { + margin-left: 0.5rem; + + > :global(.icon) { + font-size: 1.5rem !important; + } +} + +.buttonMenu :global(.MenuItem:not(.destructive)) { + color: var(--color-text) !important; +} + +.buttonMenu > :global(.Button.translucent) { + color: var(--color-white); + opacity: 0.5; + width: 2.25rem; + height: 2.25rem; + + &:hover { + opacity: 1; + } +} + +.caption { + position: absolute; + bottom: 3.5rem; + left: 0; + width: 100%; + display: flex; + flex-direction: column; + border-radius: 0 0 var(--border-radius-default-small) var(--border-radius-default-small); + max-height: 35%; + overflow: hidden; + + @media (max-width: 600px) { + bottom: 4rem; + border-radius: 0; + } +} + +.captionInner { + word-break: break-word; + white-space: pre-wrap; + line-height: 1.3125; + text-align: initial; + unicode-bidi: plaintext; + padding: 1rem 1rem 0; + margin-bottom: 1rem; + overflow-x: hidden; + overflow-y: scroll; + @include adapt-padding-to-scrollbar(1rem); +} + +.captionContent { + width: 100%; + color: var(--color-white); + font-size: var(--message-text-size, 1rem); + background: linear-gradient(to bottom, transparent 0%, rgba(0, 0, 0, 0.6) 100%); + display: flex; + flex-direction: column; + min-height: 0; + + &:not(&.expanded) { + .captionInner { + max-height: 3.5rem; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + scrollbar-gutter: stable; + } + } +} + +.hasOverflow { + transform: translateY(calc(100% - 4.4375rem)); +} + +.expanded { + transition: transform 400ms; + + &::before { + opacity: 1; + } +} + +.animate { + transform: translateY(0) !important; +} + +.captionInteractive { + cursor: var(--custom-cursor, pointer); +} + +.captionExpand { + float: right; + margin-bottom: 0.125rem; + padding: 0.5rem 1rem; + transition: opacity 200ms; + + @include adapt-padding-to-scrollbar(1rem); + + &.hidden { + opacity: 0; + } +} + +.composer { + --color-background: #212121; + --color-placeholders: #707478; + --color-composer-button: #707478; + + position: absolute; + height: 3rem; + bottom: 0; + left: 0; + margin-bottom: 0; + z-index: 3; + + &:global(.Composer) { + --base-height: 3rem; + + @media (max-width: 600px) { + padding: 0 0.5rem 0.5rem; + + @supports (padding-bottom: env(safe-area-inset-bottom)) { + padding-bottom: max(env(safe-area-inset-bottom), 0.5rem); + } + } + } + + :global(.SymbolMenu .bubble) { + --offset-y: 3.25rem; + --offset-x: 4%; + --color-background-compact-menu: rgba(0, 0, 0, 0.3); + --color-interactive-element-hover: rgba(255, 255, 255, 0.1); + --color-text-secondary: #aaa; + --color-text-secondary-rgb: 255, 255, 255; + --color-text-lighter: #ccc; + --color-text: #fff; + --color-default-shadow: rgba(0, 0, 0, 0.3); + --color-background-selected: rgba(0, 0, 0, 0.2); + } + + :global(.main-button) { + --color-composer-button: #fff; + } + + :global(.main-button .icon) { + --color-primary: #fff; + } + + :global(.composer-wrapper) { + max-width: 100%; + } + + :global(.message-input-wrapper .recording-state) { + color: #fff; + } + + :global(.SymbolMenu-footer .Button.activated) { + --color-text: #fff; + } + + :global(.input-scroller) { + --color-text: #fff; + + max-height: 8rem; + } +} + +.navigate { + position: absolute; + top: 0; + bottom: 0; + width: 50%; + background: none; + padding: 0; + margin: 0; + border: none; + outline: none !important; + cursor: var(--custom-cursor, pointer); +} + +.prev { + left: 0; +} + +.next { + right: 0; +} + +.recentViewers { + position: absolute; + bottom: 0; + left: 0; + display: flex; + align-items: center; + transition: background-color 200ms; + padding: 0.25rem; + border-radius: var(--border-radius-default); + color: #fff; +} + +.recentViewersInteractive { + cursor: var(--custom-cursor, pointer); + + &:hover { + background: var(--color-interactive-element-hover); + } +} + +.recentViewer { + z-index: 3; +} +.recentViewer + .recentViewer { + margin-left: -0.5rem; + z-index: 2; +} +.recentViewer + .recentViewer + .recentViewer { + z-index: 1; +} + +.recentViewersCount { + margin-inline-start: 0.5rem; +} + +.modal :global(.modal-content) { + padding: 0.5rem !important; + max-height: 35rem; + + @supports (max-height: min(80vh, 35rem)) { + max-height: min(80vh, 35rem); + } +} + +.seenByList { + min-height: 8rem; + display: flex; + flex-direction: column; +} + +.seenByListLoading { + justify-content: center; +} + +.thumbnail { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: block; + object-fit: cover; +} + +.skeleton { + position: absolute; + top: 0; + left: 0; +} + +.expiredText { + color: var(--color-text-secondary); + text-align: center; + margin-block: auto; +} diff --git a/src/components/story/StoryViewer.tsx b/src/components/story/StoryViewer.tsx new file mode 100644 index 000000000..422e62cf2 --- /dev/null +++ b/src/components/story/StoryViewer.tsx @@ -0,0 +1,146 @@ +import React, { + memo, useCallback, useEffect, useState, +} from '../../lib/teact/teact'; +import { getActions, withGlobal } from '../../global'; + +import { selectIsStoryViewerOpen, selectTabState } from '../../global/selectors'; +import captureEscKeyListener from '../../util/captureEscKeyListener'; +import { disableDirectTextInput, enableDirectTextInput } from '../../util/directInputManager'; + +import useFlag from '../../hooks/useFlag'; +import useLang from '../../hooks/useLang'; +import useHistoryBack from '../../hooks/useHistoryBack'; + +import ShowTransition from '../ui/ShowTransition'; +import Button from '../ui/Button'; +import StorySlides from './StorySlides'; +import StoryDeleteConfirmModal from './StoryDeleteConfirmModal'; +import StoryViewers from './StoryViewers'; +import ReportModal from '../common/ReportModal'; +import StorySettings from './StorySettings'; + +import styles from './StoryViewer.module.scss'; + +interface StateProps { + isOpen: boolean; + userId?: string; + storyId?: number; + shouldSkipHistoryAnimations?: boolean; + isPrivacyModalOpen?: boolean; +} + +function StoryViewer({ + isOpen, + userId, + storyId, + shouldSkipHistoryAnimations, + isPrivacyModalOpen, +}: StateProps) { + const { closeStoryViewer, closeStoryPrivacyEditor } = getActions(); + + const lang = useLang(); + const [idStoryForDelete, setIdStoryForDelete] = useState(undefined); + const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useFlag(false); + const [isReportModalOpen, openReportModal, closeReportModal] = useFlag(false); + + useEffect(() => { + if (!isOpen) { + setIdStoryForDelete(undefined); + closeReportModal(); + closeDeleteModal(); + } + }, [isOpen]); + + useEffect(() => { + if (!isOpen) { + return undefined; + } + + disableDirectTextInput(); + + return () => { + enableDirectTextInput(); + }; + }, [isOpen]); + + const handleClose = useCallback(() => { + closeStoryViewer(); + }, [closeStoryViewer]); + + const handleOpenDeleteModal = useCallback((id: number) => { + setIdStoryForDelete(id); + openDeleteModal(); + }, []); + + const handleCloseDeleteModal = useCallback(() => { + closeDeleteModal(); + setIdStoryForDelete(undefined); + }, []); + + useHistoryBack({ + isActive: isOpen, + onBack: handleClose, + shouldBeReplaced: true, + }); + + useEffect(() => (isOpen ? captureEscKeyListener(() => { + handleClose(); + }) : undefined), [handleClose, isOpen]); + + return ( + +
+ + + + + + + + + + ); +} + +export default memo(withGlobal((global): StateProps => { + const { shouldSkipHistoryAnimations, storyViewer: { storyId, userId, isPrivacyModalOpen } } = selectTabState(global); + + return { + isOpen: selectIsStoryViewerOpen(global), + shouldSkipHistoryAnimations, + userId, + storyId, + isPrivacyModalOpen, + }; +})(StoryViewer)); diff --git a/src/components/story/StoryViewers.tsx b/src/components/story/StoryViewers.tsx new file mode 100644 index 000000000..c016a1dd1 --- /dev/null +++ b/src/components/story/StoryViewers.tsx @@ -0,0 +1,146 @@ +import React, { memo, useEffect, useMemo } from '../../lib/teact/teact'; +import { getActions, withGlobal } from '../../global'; + +import { selectStorySeenBy, selectTabState, selectUserStory } from '../../global/selectors'; +import buildClassName from '../../util/buildClassName'; +import { formatDateAtTime } from '../../util/dateFormat'; +import { getServerTime } from '../../util/serverTime'; +import renderText from '../common/helpers/renderText'; + +import useLang from '../../hooks/useLang'; +import useLastCallback from '../../hooks/useLastCallback'; +import useCurrentOrPrev from '../../hooks/useCurrentOrPrev'; +import usePrevious from '../../hooks/usePrevious'; + +import Modal from '../ui/Modal'; +import ListItem from '../ui/ListItem'; +import PrivateChatInfo from '../common/PrivateChatInfo'; +import Button from '../ui/Button'; +import Loading from '../ui/Loading'; + +import styles from './StoryViewer.module.scss'; + +interface StateProps { + storyId?: number; + storyDate?: number; + viewsCount?: number; + seenByDates?: Record; + viewersExpirePeriod: number; +} +const CLOSE_ANIMATION_DURATION = 100; + +function StoryViewers({ + storyId, + storyDate, + viewsCount, + viewersExpirePeriod, + seenByDates, +}: StateProps) { + const { + loadStorySeenBy, openChat, closeStorySeenBy, closeStoryViewer, + } = getActions(); + + const lang = useLang(); + + const isOpen = Boolean(storyId); + const isExpired = Boolean(storyDate) && (storyDate + viewersExpirePeriod) < getServerTime(); + const renderingSeenByDates = useCurrentOrPrev(seenByDates, true); + const renderingIsExpired = usePrevious(isExpired) || isExpired; + const renderingViewsCount = useCurrentOrPrev(viewsCount, true); + + const memberIds = useMemo(() => { + if (!renderingSeenByDates || renderingIsExpired) { + return undefined; + } + + const result = Object.keys(renderingSeenByDates); + result.sort((leftId, rightId) => renderingSeenByDates[rightId] - renderingSeenByDates[leftId]); + + return result; + }, [renderingIsExpired, renderingSeenByDates]); + const isLoading = !renderingIsExpired && (!memberIds || memberIds.length === 0); + + useEffect(() => { + if (!storyId || seenByDates || renderingIsExpired) { + return; + } + + // TODO Infinite scroll + loadStorySeenBy({ storyId }); + }, [renderingIsExpired, seenByDates, storyId]); + + const handleCloseSeenByModal = useLastCallback(() => { + closeStorySeenBy(); + }); + + const handleClick = useLastCallback((userId: string) => { + closeStorySeenBy(); + closeStoryViewer(); + + setTimeout(() => { + openChat({ id: userId }); + }, CLOSE_ANIMATION_DURATION); + }); + + return ( + +
+ {isLoading && } + {renderingIsExpired && ( +
+ {renderText(lang('ExpiredViewsStub'), ['simple_markdown', 'emoji'])} +
+ )} + {memberIds?.map((userId) => ( + handleClick(userId)} + > + + + ))} +
+
+ +
+
+ ); +} + +export default memo(withGlobal((global) => { + const { appConfig } = global; + const { storyViewer: { storyIdSeenBy } } = selectTabState(global); + const story = storyIdSeenBy ? selectUserStory(global, global.currentUserId!, storyIdSeenBy) : undefined; + const storyDate = story && 'date' in story ? story.date : undefined; + const viewsCount = story && 'viewsCount' in story ? story.viewsCount : undefined; + + return { + storyId: storyIdSeenBy, + seenByDates: storyIdSeenBy ? selectStorySeenBy(global, global.currentUserId!, storyIdSeenBy) : undefined, + viewersExpirePeriod: appConfig!.storyExpirePeriod + appConfig!.storyViewersExpirePeriod, + storyDate, + viewsCount, + }; +})(StoryViewers)); diff --git a/src/components/story/helpers/dimensions.ts b/src/components/story/helpers/dimensions.ts new file mode 100644 index 000000000..fcbbf6a75 --- /dev/null +++ b/src/components/story/helpers/dimensions.ts @@ -0,0 +1,57 @@ +import type { IDimensions } from '../../../global/types'; + +const BASE_SCREEN_WIDTH = 1200; +const BASE_SCREEN_HEIGHT = 800; +const BASE_ACTIVE_SLIDE_WIDTH = 404; +const BASE_ACTIVE_SLIDE_HEIGHT = 720; +const BASE_SLIDE_WIDTH = 135; +const BASE_SLIDE_HEIGHT = 240; +const BASE_GAP_WIDTH = 40; + +export function calculateSlideSizes(windowWidth: number, windowHeight: number): { + activeSlide: IDimensions; + slide: IDimensions; + scale: number; +} { + const scale = calculateScale(BASE_SCREEN_WIDTH, BASE_SCREEN_HEIGHT, windowWidth, windowHeight); + + return { + activeSlide: { + width: BASE_ACTIVE_SLIDE_WIDTH * scale, + height: BASE_ACTIVE_SLIDE_HEIGHT * scale, + }, + slide: { + width: BASE_SLIDE_WIDTH * scale, + height: BASE_SLIDE_HEIGHT * scale, + }, + scale, + }; +} + +export function calculateOffsetX({ + scale, + slideAmount, + isActiveSlideSize, + isMoveThroughActiveSlide, + isBackward, +}: { + scale: number; + slideAmount: number; + isActiveSlideSize: boolean; + isMoveThroughActiveSlide?: boolean; + isBackward: boolean; +}) { + const mainOffset = BASE_GAP_WIDTH + (isActiveSlideSize ? BASE_ACTIVE_SLIDE_WIDTH : BASE_SLIDE_WIDTH); + const additionalOffset = (Math.abs(slideAmount) - 1) + * ((isMoveThroughActiveSlide ? BASE_ACTIVE_SLIDE_WIDTH : BASE_SLIDE_WIDTH) + BASE_GAP_WIDTH); + const totalOffset = (mainOffset + additionalOffset) * scale; + + return isBackward ? -totalOffset : totalOffset; +} + +function calculateScale(baseWidth: number, baseHeight: number, newWidth: number, newHeight: number) { + const widthScale = newWidth / baseWidth; + const heightScale = newHeight / baseHeight; + + return Math.min(widthScale, heightScale); +} diff --git a/src/components/story/hooks/useStoryPreloader.ts b/src/components/story/hooks/useStoryPreloader.ts new file mode 100644 index 000000000..d42af356e --- /dev/null +++ b/src/components/story/hooks/useStoryPreloader.ts @@ -0,0 +1,101 @@ +import { useEffect } from '../../../lib/teact/teact'; +import { getGlobal } from '../../../global'; + +import { ApiMediaFormat } from '../../../api/types'; + +import { selectUserStories } from '../../../global/selectors'; +import { getStoryMediaHash } from '../../../global/helpers'; +import * as mediaLoader from '../../../util/mediaLoader'; +import { pause } from '../../../util/schedulers'; + +const preloadedStories: Record> = {}; +const USER_STORIES_FOR_PRELOAD = 5; +const PROGRESSIVE_PRELOAD_DURATION = 1000; + +const FIRST_PRELOAD_DELAY = 1000; +const canPreload = pause(FIRST_PRELOAD_DELAY); + +function useStoryPreloader(userIds: string[]): void; +function useStoryPreloader(userId: string, aroundStoryId?: number): void; +function useStoryPreloader(userId: string | string[], aroundStoryId?: number) { + useEffect(() => { + const preloadHashes = async (mediaHashes: { hash: string; format: ApiMediaFormat }[]) => { + await canPreload; + mediaHashes.forEach(({ hash, format }) => { + mediaLoader.fetch(hash, format).then((result) => { + if (format === ApiMediaFormat.Progressive) { + preloadProgressive(result); + } + }); + }); + }; + + const userIds = Array.isArray(userId) ? userId : [userId]; + + userIds.forEach((id) => { + const storyId = aroundStoryId || getGlobal().stories.byUserId[id]?.orderedIds?.[0]; + if (!storyId) return; + preloadHashes(getPreloadMediaHashes(id, storyId)); + }); + }, [aroundStoryId, userId]); +} + +function findIdsAroundCurrentId(ids: T[], currentId: T, aroundAmount: number): T[] { + const currentIndex = ids.indexOf(currentId); + + return ids.slice(currentIndex - aroundAmount, currentIndex + aroundAmount); +} + +function getPreloadMediaHashes(userId: string, storyId: number) { + const userStories = selectUserStories(getGlobal(), userId); + if (!userStories || !userStories.orderedIds?.length) { + return []; + } + + const preloadIds = findIdsAroundCurrentId(userStories.orderedIds, storyId, USER_STORIES_FOR_PRELOAD); + + const mediaHashes: { hash: string; format: ApiMediaFormat }[] = []; + preloadIds.forEach((currentStoryId) => { + if (preloadedStories[userId]?.has(currentStoryId)) { + return; + } + + const story = userStories.byId[currentStoryId]; + if (!story || !('content' in story)) { + return; + } + + // Media + mediaHashes.push({ + hash: getStoryMediaHash(story, 'full'), + format: story.content.video ? ApiMediaFormat.Progressive : ApiMediaFormat.BlobUrl, + }); + // Thumbnail + mediaHashes.push({ hash: getStoryMediaHash(story), format: ApiMediaFormat.BlobUrl }); + // Alt video with different codec + if (story.content.altVideo) { + mediaHashes.push({ hash: getStoryMediaHash(story, 'full', true)!, format: ApiMediaFormat.Progressive }); + } + + preloadedStories[userId] = (preloadedStories[userId] || new Set()).add(currentStoryId); + }); + + return mediaHashes; +} + +function preloadProgressive(url: string) { + const head = document.head; + const video = document.createElement('video'); + video.preload = 'auto'; + video.src = url; + video.muted = true; + video.autoplay = true; + video.style.display = 'none'; + head.appendChild(video); + + setTimeout(() => { + head.removeChild(video); + }, PROGRESSIVE_PRELOAD_DURATION); +} + +export default useStoryPreloader; diff --git a/src/components/story/privacy/AllowDenyList.tsx b/src/components/story/privacy/AllowDenyList.tsx new file mode 100644 index 000000000..0a22b66e4 --- /dev/null +++ b/src/components/story/privacy/AllowDenyList.tsx @@ -0,0 +1,53 @@ +import React, { memo, useMemo, useState } from '../../../lib/teact/teact'; +import Picker from '../../common/Picker'; +import { unique } from '../../../util/iteratees'; +import { filterUsersByName } from '../../../global/helpers'; +import type { ApiUser } from '../../../api/types'; +import useLang from '../../../hooks/useLang'; +import { MEMO_EMPTY_ARRAY } from '../../../util/memo'; + +interface OwnProps { + id: string; + contactListIds?: string[]; + currentUserId: string; + selectedIds?: string[]; + lockedIds?: string[]; + usersById: Record; + onSelect: (selectedIds: string[]) => void; +} + +function AllowDenyList({ + id, + contactListIds, + currentUserId, + usersById, + selectedIds, + lockedIds, + onSelect, +}: OwnProps) { + const lang = useLang(); + + const [searchQuery, setSearchQuery] = useState(''); + const displayedIds = useMemo(() => { + const contactIds = (contactListIds || []).filter((userId) => userId !== currentUserId); + return unique(filterUsersByName([...selectedIds || [], ...contactIds], usersById, searchQuery)); + }, [contactListIds, currentUserId, searchQuery, selectedIds, usersById]); + + return ( + + ); +} + +export default memo(AllowDenyList); diff --git a/src/components/story/privacy/CloseFriends.module.scss b/src/components/story/privacy/CloseFriends.module.scss new file mode 100644 index 000000000..340f4e9a7 --- /dev/null +++ b/src/components/story/privacy/CloseFriends.module.scss @@ -0,0 +1,13 @@ +.buttonHolder { + position: absolute; + height: 6rem; + width: 6rem; + bottom: 0; + right: 0; + overflow: hidden; + pointer-events: none; +} + +.active { + pointer-events: auto; +} diff --git a/src/components/story/privacy/CloseFriends.tsx b/src/components/story/privacy/CloseFriends.tsx new file mode 100644 index 000000000..893a06f6e --- /dev/null +++ b/src/components/story/privacy/CloseFriends.tsx @@ -0,0 +1,91 @@ +import React, { + memo, useCallback, useMemo, useState, +} from '../../../lib/teact/teact'; +import { getActions } from '../../../global'; + +import type { ApiUser } from '../../../api/types'; + +import { unique } from '../../../util/iteratees'; +import { filterUsersByName } from '../../../global/helpers'; +import buildClassName from '../../../util/buildClassName'; + +import useEffectWithPrevDeps from '../../../hooks/useEffectWithPrevDeps'; +import useLang from '../../../hooks/useLang'; +import useLastCallback from '../../../hooks/useLastCallback'; + +import Picker from '../../common/Picker'; +import FloatingActionButton from '../../ui/FloatingActionButton'; + +import styles from './CloseFriends.module.scss'; + +export type OwnProps = { + isActive?: boolean; + currentUserId: string; + usersById: Record; + contactListIds?: string[]; + onClose: NoneToVoidFunction; +}; + +function CloseFriends({ + isActive, contactListIds, usersById, currentUserId, onClose, +}: OwnProps) { + const { saveCloseFriends } = getActions(); + + const lang = useLang(); + const [searchQuery, setSearchQuery] = useState(''); + const [isSubmitShown, setIsSubmitShown] = useState(false); + const [newSelectedContactIds, setNewSelectedContactIds] = useState([]); + + const closeFriendIds = useMemo(() => { + return (contactListIds || []).filter((userId) => usersById[userId]?.isCloseFriend); + }, [contactListIds, usersById]); + + const displayedIds = useMemo(() => { + const contactIds = (contactListIds || []).filter((id) => id !== currentUserId); + return unique(filterUsersByName([...closeFriendIds, ...contactIds], usersById, searchQuery)); + }, [closeFriendIds, contactListIds, currentUserId, searchQuery, usersById]); + + useEffectWithPrevDeps(([prevIsActive]) => { + if (!prevIsActive && isActive) { + setIsSubmitShown(false); + setNewSelectedContactIds(closeFriendIds); + } + }, [isActive, closeFriendIds]); + + const handleSelectedContactIdsChange = useCallback((value: string[]) => { + setNewSelectedContactIds(value); + setIsSubmitShown(true); + }, []); + + const handleSubmit = useLastCallback(() => { + saveCloseFriends({ userIds: newSelectedContactIds }); + onClose(); + }); + + return ( + <> + + +
+ + + +
+ + ); +} + +export default memo(CloseFriends); diff --git a/src/components/ui/ConfirmDialog.tsx b/src/components/ui/ConfirmDialog.tsx index 06f0c9f5f..9bd90aaa3 100644 --- a/src/components/ui/ConfirmDialog.tsx +++ b/src/components/ui/ConfirmDialog.tsx @@ -3,6 +3,7 @@ import React, { memo, useCallback, useRef } from '../../lib/teact/teact'; import type { TextPart } from '../../types'; +import buildClassName from '../../util/buildClassName'; import useLang from '../../hooks/useLang'; import useKeyboardListNavigation from '../../hooks/useKeyboardListNavigation'; @@ -21,6 +22,7 @@ type OwnProps = { confirmHandler: () => void; confirmIsDestructive?: boolean; areButtonsInColumn?: boolean; + className?: string; children?: React.ReactNode; }; @@ -36,6 +38,7 @@ const ConfirmDialog: FC = ({ confirmHandler, confirmIsDestructive, areButtonsInColumn, + className, children, }) => { const lang = useLang(); @@ -51,7 +54,7 @@ const ConfirmDialog: FC = ({ return ( void; onScroll?: (e: UIEvent) => void; + onWheel?: (e: React.WheelEvent) => void; + onClick?: (e: React.MouseEvent) => void; onKeyDown?: (e: React.KeyboardEvent) => void; onDragOver?: (e: React.DragEvent) => void; onDragLeave?: (e: React.DragEvent) => void; @@ -60,6 +62,8 @@ const InfiniteScroll: FC = ({ children, onLoadMore, onScroll, + onWheel, + onClick, onKeyDown, onDragOver, onDragLeave, @@ -236,10 +240,12 @@ const InfiniteScroll: FC = ({ ref={containerRef} className={className} onScroll={handleScroll} + onWheel={onWheel} teactFastList={!noFastList && !withAbsolutePositioning} onKeyDown={onKeyDown} onDragOver={onDragOver} onDragLeave={onDragLeave} + onClick={onClick} style={style} > {beforeChildren} diff --git a/src/components/ui/ListItem.scss b/src/components/ui/ListItem.scss index 53ef8da7a..38806ecbf 100644 --- a/src/components/ui/ListItem.scss +++ b/src/components/ui/ListItem.scss @@ -217,6 +217,16 @@ padding: 0.5625rem; } + &.contact-list-item { + .ListItem-button { + padding: 0.5rem; + } + + .ChatInfo { + padding: 0.0625rem; + } + } + .Avatar { margin-right: 0.5rem; } @@ -251,12 +261,17 @@ .typing-status { font-size: 1rem; margin: 0; - overflow: hidden; white-space: nowrap; text-overflow: ellipsis; text-align: initial; } + h3, + .last-message, + .typing-status { + overflow: hidden; + } + .title { h3 { font-weight: 500; diff --git a/src/components/ui/Loading.tsx b/src/components/ui/Loading.tsx index f14d90578..44536c7d8 100644 --- a/src/components/ui/Loading.tsx +++ b/src/components/ui/Loading.tsx @@ -1,9 +1,9 @@ -import type { FC } from '../../lib/teact/teact'; import React, { memo } from '../../lib/teact/teact'; -import Spinner from './Spinner'; import buildClassName from '../../util/buildClassName'; +import Spinner from './Spinner'; + import './Loading.scss'; type OwnProps = { @@ -12,7 +12,7 @@ type OwnProps = { onClick?: NoneToVoidFunction; }; -const Loading: FC = ({ color = 'blue', backgroundColor, onClick }) => { +const Loading = ({ color = 'blue', backgroundColor, onClick }: OwnProps) => { return (
diff --git a/src/components/ui/Menu.scss b/src/components/ui/Menu.scss index a8bf6304f..1c4cbdc17 100644 --- a/src/components/ui/Menu.scss +++ b/src/components/ui/Menu.scss @@ -26,6 +26,7 @@ min-width: 13.5rem; z-index: var(--z-menu-bubble); overscroll-behavior: contain; + color: var(--color-text); transform: scale(0.85); transition: opacity 150ms cubic-bezier(0.2, 0, 0.2, 1), transform 150ms cubic-bezier(0.2, 0, 0.2, 1) !important; diff --git a/src/components/ui/Modal.scss b/src/components/ui/Modal.scss index 985c9cf1d..be0add448 100644 --- a/src/components/ui/Modal.scss +++ b/src/components/ui/Modal.scss @@ -1,6 +1,7 @@ .Modal { position: relative; z-index: var(--z-modal); + color: var(--color-text); &.confirm { z-index: var(--z-modal-confirm); diff --git a/src/components/ui/Notification.scss b/src/components/ui/Notification.scss index bbf132086..cd9c37d12 100644 --- a/src/components/ui/Notification.scss +++ b/src/components/ui/Notification.scss @@ -1,6 +1,6 @@ .Notification-container { position: relative; - width: 27rem; + width: 22rem; max-width: 100vw; margin: 4.25rem auto 0.25rem; z-index: var(--z-notification); diff --git a/src/components/ui/Notification.tsx b/src/components/ui/Notification.tsx index 6cc3ef44c..4796c1d8e 100644 --- a/src/components/ui/Notification.tsx +++ b/src/components/ui/Notification.tsx @@ -26,7 +26,7 @@ type OwnProps = { message: TextPart[]; duration?: number; onDismiss: () => void; - action?: CallbackAction; + action?: CallbackAction | CallbackAction[]; actionText?: string; className?: string; }; @@ -53,8 +53,13 @@ const Notification: FC = ({ const handleClick = useCallback(() => { if (action) { - // @ts-ignore - actions[action.action](action.payload); + if (Array.isArray(action)) { + // @ts-ignore + action.forEach((cb) => actions[cb.action](cb.payload)); + } else { + // @ts-ignore + actions[action.action](action.payload); + } } closeAndDismiss(); }, [action, actions, closeAndDismiss]); diff --git a/src/components/ui/OptimizedVideo.tsx b/src/components/ui/OptimizedVideo.tsx index 7a0d84707..618151ae8 100644 --- a/src/components/ui/OptimizedVideo.tsx +++ b/src/components/ui/OptimizedVideo.tsx @@ -1,4 +1,4 @@ -import React, { memo, useRef } from '../../lib/teact/teact'; +import React, { memo, useMemo, useRef } from '../../lib/teact/teact'; import useLastCallback from '../../hooks/useLastCallback'; import useVideoAutoPause from '../middle/message/hooks/useVideoAutoPause'; @@ -6,17 +6,23 @@ import useVideoCleanup from '../../hooks/useVideoCleanup'; import useBuffering from '../../hooks/useBuffering'; import useSyncEffect from '../../hooks/useSyncEffect'; +type VideoProps = React.DetailedHTMLProps, HTMLVideoElement>; + type OwnProps = { ref?: React.RefObject; + isPriority?: boolean; canPlay: boolean; + children?: React.ReactNode; onReady?: NoneToVoidFunction; } - & React.DetailedHTMLProps, HTMLVideoElement>; + & VideoProps; function OptimizedVideo({ ref, + isPriority, canPlay, + children, onReady, onTimeUpdate, ...restProps @@ -27,7 +33,7 @@ function OptimizedVideo({ ref = localRef; } - const { handlePlaying: handlePlayingForAutoPause } = useVideoAutoPause(ref, canPlay); + const { handlePlaying: handlePlayingForAutoPause } = useVideoAutoPause(ref, canPlay, isPriority); useVideoCleanup(ref, []); const isReadyRef = useRef(false); @@ -53,11 +59,27 @@ function OptimizedVideo({ handlePlayingForAutoPause(); handlePlayingForBuffering(e); handleReady(); + restProps.onPlaying?.(e); }); + const mergedOtherBufferingHandlers = useMemo(() => { + const mergedHandlers: Record = {}; + Object.keys(otherBufferingHandlers).forEach((keyString) => { + const key = keyString as keyof typeof otherBufferingHandlers; + mergedHandlers[key] = (event: Event) => { + restProps[key as keyof typeof restProps]?.(event); + otherBufferingHandlers[key]?.(event); + }; + }); + + return mergedHandlers; + }, [otherBufferingHandlers, restProps]); + return ( // eslint-disable-next-line react/jsx-props-no-spreading -