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 904841087..41442d299 100644 Binary files a/src/assets/fonts/icomoon.woff and b/src/assets/fonts/icomoon.woff differ diff --git a/src/assets/fonts/icomoon.woff2 b/src/assets/fonts/icomoon.woff2 index ae34270fb..a26a91b20 100644 Binary files a/src/assets/fonts/icomoon.woff2 and b/src/assets/fonts/icomoon.woff2 differ diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts index 2958fade4..4da45b4c1 100644 --- a/src/bundles/extra.ts +++ b/src/bundles/extra.ts @@ -1,4 +1,5 @@ export { default as MediaViewer } from '../components/mediaViewer/MediaViewer'; +export { default as StoryViewer } from '../components/story/StoryViewer'; export { default as ForwardRecipientPicker } from '../components/main/ForwardRecipientPicker'; export { default as DraftRecipientPicker } from '../components/main/DraftRecipientPicker'; diff --git a/src/components/calls/phone/PhoneCall.module.scss b/src/components/calls/phone/PhoneCall.module.scss index 3902a0902..9760643aa 100644 --- a/src/components/calls/phone/PhoneCall.module.scss +++ b/src/components/calls/phone/PhoneCall.module.scss @@ -11,6 +11,8 @@ } :global(.Avatar) { + --radius: 0; + position: absolute; top: 0; left: 0; diff --git a/src/components/common/Avatar.scss b/src/components/common/Avatar.scss index 4eb6ed3fe..71555d30b 100644 --- a/src/components/common/Avatar.scss +++ b/src/components/common/Avatar.scss @@ -1,20 +1,33 @@ .Avatar { --color-user: var(--color-primary); --radius: 50%; + flex: none; align-items: center; justify-content: center; width: 3.375rem; height: 3.375rem; border-radius: var(--radius); - background-image: linear-gradient(var(--color-white) -125%, var(--color-user)); color: white; font-weight: bold; display: flex; white-space: nowrap; user-select: none; position: relative; - overflow: hidden; + + > .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 -