Introduce Stories (#3154)

This commit is contained in:
Alexander Zinchuk 2023-08-14 11:17:40 +02:00
parent e367d82c6c
commit fc605350ea
211 changed files with 12304 additions and 2642 deletions

View File

@ -5,7 +5,12 @@ import { Api as GramJs } from '../../../lib/gramjs';
import type { ApiAppConfig } from '../../types'; import type { ApiAppConfig } from '../../types';
import type { ApiLimitType } from '../../../global/types'; import type { ApiLimitType } from '../../../global/types';
import { buildJson } from './misc'; 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 LimitType = 'default' | 'premium';
type Limit = 'upload_max_fileparts' | 'stickers_faved_limit' | 'saved_gifs_limit' | 'dialog_filters_chats_limit' | 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; autoarchive_setting_available: boolean;
// Forums // Forums
topics_pinned_limit: number; 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) { function buildEmojiSounds(appConfig: GramJsAppConfig) {
@ -100,5 +110,9 @@ export function buildAppConfig(json: GramJs.TypeJSONValue, hash: number): ApiApp
chatlistJoined: getLimit(appConfig, 'chatlist_joined_limit', 'chatlistJoined'), chatlistJoined: getLimit(appConfig, 'chatlist_joined_limit', 'chatlistJoined'),
}, },
hash, 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,
}; };
} }

View File

@ -36,10 +36,17 @@ import type {
ApiMessageExtendedMediaPreview, ApiMessageExtendedMediaPreview,
ApiReaction, ApiReaction,
ApiReactionEmoji, ApiReactionEmoji,
ApiTypeReplyTo,
ApiStory,
ApiStorySkipped,
ApiWebPageStoryData,
ApiMessageStoryData,
ApiTypeStory,
} from '../../types'; } from '../../types';
import { import {
ApiMessageEntityTypes, ApiMessageEntityTypes,
} from '../../types'; } from '../../types';
import type { ApiPrivacySettings, PrivacyVisibility } from '../../../types';
import { import {
DELETED_COMMENTS_CHANNEL_ID, DELETED_COMMENTS_CHANNEL_ID,
@ -50,14 +57,19 @@ import {
SUPPORTED_VIDEO_CONTENT_TYPES, SUPPORTED_VIDEO_CONTENT_TYPES,
VIDEO_WEBM_TYPE, VIDEO_WEBM_TYPE,
} from '../../../config'; } from '../../../config';
import { pick } from '../../../util/iteratees'; import { buildCollectionByCallback, pick } from '../../../util/iteratees';
import { buildStickerFromDocument } from './symbols'; import { buildStickerFromDocument } from './symbols';
import { import {
buildApiPhoto, buildApiPhotoSize, buildApiThumbnailFromPath, buildApiThumbnailFromStripped, buildApiPhoto, buildApiPhotoSize, buildApiThumbnailFromPath, buildApiThumbnailFromStripped,
} from './common'; } from './common';
import { interpolateArray } from '../../../util/waveform'; import { interpolateArray } from '../../../util/waveform';
import { buildPeer } from '../gramjsBuilders'; import { buildPeer } from '../gramjsBuilders';
import { addPhotoToLocalDb, resolveMessageApiChatId, serializeBytes } from '../helpers'; import {
addPhotoToLocalDb,
addStoryToLocalDb,
resolveMessageApiChatId,
serializeBytes,
} from '../helpers';
import { buildApiPeerId, getApiChatIdFromMtpPeer, isPeerUser } from './peers'; import { buildApiPeerId, getApiChatIdFromMtpPeer, isPeerUser } from './peers';
import { buildApiCallDiscardReason } from './calls'; import { buildApiCallDiscardReason } from './calls';
import { getEmojiOnlyCountForMessage } from '../../../global/helpers/getEmojiOnlyCountForMessage'; import { getEmojiOnlyCountForMessage } from '../../../global/helpers/getEmojiOnlyCountForMessage';
@ -181,9 +193,23 @@ export function buildApiMessageWithChatId(
const isInvoiceMedia = mtpMessage.media instanceof GramJs.MessageMediaInvoice const isInvoiceMedia = mtpMessage.media instanceof GramJs.MessageMediaInvoice
&& Boolean(mtpMessage.media.extendedMedia); && Boolean(mtpMessage.media.extendedMedia);
const { let replyToMsgId: number | undefined;
replyToMsgId, replyToTopId, forumTopic, replyToPeerId, let replyToTopId: number | undefined;
} = mtpMessage.replyTo || {}; 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 isEdited = mtpMessage.editDate && !mtpMessage.editHide;
const { const {
inlineButtons, keyboardButtons, keyboardPlaceholder, isKeyboardSingleUse, isKeyboardSelective, inlineButtons, keyboardButtons, keyboardPlaceholder, isKeyboardSingleUse, isKeyboardSelective,
@ -219,6 +245,7 @@ export function buildApiMessageWithChatId(
...(replyToPeerId && { replyToChatId: getApiChatIdFromMtpPeer(replyToPeerId) }), ...(replyToPeerId && { replyToChatId: getApiChatIdFromMtpPeer(replyToPeerId) }),
...(replyToTopId && { replyToTopMessageId: replyToTopId }), ...(replyToTopId && { replyToTopMessageId: replyToTopId }),
...(forwardInfo && { forwardInfo }), ...(forwardInfo && { forwardInfo }),
...(replyToStoryUserId && { replyToStoryUserId, replyToStoryId }),
...(isEdited && { isEdited }), ...(isEdited && { isEdited }),
...(mtpMessage.editDate && { editDate: mtpMessage.editDate }), ...(mtpMessage.editDate && { editDate: mtpMessage.editDate }),
...(isMediaUnread && { isMediaUnread }), ...(isMediaUnread && { isMediaUnread }),
@ -399,7 +426,8 @@ export function buildMessageMediaContent(media: GramJs.TypeMessageMedia): ApiMes
if (photo) return { photo }; if (photo) return { photo };
const video = buildVideo(media); const video = buildVideo(media);
if (video) return { video }; const altVideo = buildAltVideo(media);
if (video) return { video, altVideo };
const audio = buildAudio(media); const audio = buildAudio(media);
if (audio) return { audio }; if (audio) return { audio };
@ -428,6 +456,9 @@ export function buildMessageMediaContent(media: GramJs.TypeMessageMedia): ApiMes
const game = buildGameFromMedia(media); const game = buildGameFromMedia(media);
if (game) return { game }; if (game) return { game };
const storyData = buildMessageStoryData(media);
if (storyData) return { storyData };
return undefined; return undefined;
} }
@ -499,6 +530,7 @@ export function buildVideoFromDocument(document: GramJs.Document, isSpoiler?: bo
h: height, h: height,
supportsStreaming = false, supportsStreaming = false,
roundMessage: isRound = false, roundMessage: isRound = false,
nosound,
} = videoAttr; } = videoAttr;
return { return {
@ -514,6 +546,7 @@ export function buildVideoFromDocument(document: GramJs.Document, isSpoiler?: bo
thumbnail: buildApiThumbnailFromStripped(thumbs), thumbnail: buildApiThumbnailFromStripped(thumbs),
size: size.toJSNumber(), size: size.toJSNumber(),
isSpoiler, isSpoiler,
...(nosound && { noSound: true }),
}; };
} }
@ -529,6 +562,18 @@ function buildVideo(media: GramJs.TypeMessageMedia): ApiVideo | undefined {
return buildVideoFromDocument(media.document, media.spoiler); 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 { function buildAudio(media: GramJs.TypeMessageMedia): ApiAudio | undefined {
if ( if (
!(media instanceof GramJs.MessageMediaDocument) !(media instanceof GramJs.MessageMediaDocument)
@ -856,12 +901,28 @@ export function buildWebPage(media: GramJs.TypeMessageMedia): ApiWebPage | undef
return undefined; return undefined;
} }
const { id, photo, document } = media.webpage; const {
id, photo, document, attributes,
} = media.webpage;
let video; let video;
if (document instanceof GramJs.Document && document.mimeType.startsWith('video/')) { if (document instanceof GramJs.Document && document.mimeType.startsWith('video/')) {
video = buildVideoFromDocument(document); 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 { return {
id: Number(id), id: Number(id),
@ -877,9 +938,20 @@ export function buildWebPage(media: GramJs.TypeMessageMedia): ApiWebPage | undef
photo: photo instanceof GramJs.Photo ? buildApiPhoto(photo) : undefined, photo: photo instanceof GramJs.Photo ? buildApiPhoto(photo) : undefined,
document: !video && document ? buildApiDocument(document) : undefined, document: !video && document ? buildApiDocument(document) : undefined,
video, 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( function buildAction(
action: GramJs.TypeMessageAction, action: GramJs.TypeMessageAction,
senderId: string | undefined, senderId: string | undefined,
@ -1284,8 +1356,7 @@ export function buildLocalMessage(
chat: ApiChat, chat: ApiChat,
text?: string, text?: string,
entities?: ApiMessageEntity[], entities?: ApiMessageEntity[],
replyingTo?: number, replyingTo?: ApiTypeReplyTo,
replyingToTopId?: number,
attachment?: ApiAttachment, attachment?: ApiAttachment,
sticker?: ApiSticker, sticker?: ApiSticker,
gif?: ApiVideo, gif?: ApiVideo,
@ -1294,12 +1365,27 @@ export function buildLocalMessage(
groupedId?: string, groupedId?: string,
scheduledAt?: number, scheduledAt?: number,
sendAs?: ApiChat | ApiUser, sendAs?: ApiChat | ApiUser,
story?: ApiStory | ApiStorySkipped,
): ApiMessage { ): ApiMessage {
const localId = getNextLocalMessageId(chat.lastMessage?.id); const localId = getNextLocalMessageId(chat.lastMessage?.id);
const media = attachment && buildUploadingMedia(attachment); const media = attachment && buildUploadingMedia(attachment);
const isChannel = chat.type === 'chatTypeChannel'; const isChannel = chat.type === 'chatTypeChannel';
const isForum = chat.isForum; 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 = { const message = {
id: localId, id: localId,
chatId: chat.id, chatId: chat.id,
@ -1315,13 +1401,15 @@ export function buildLocalMessage(
...(gif && { video: gif }), ...(gif && { video: gif }),
...(poll && buildNewPoll(poll, localId)), ...(poll && buildNewPoll(poll, localId)),
...(contact && { contact }), ...(contact && { contact }),
...(story && { storyData: story }),
}, },
date: scheduledAt || Math.round(Date.now() / 1000) + getServerTimeOffset(), date: scheduledAt || Math.round(Date.now() / 1000) + getServerTimeOffset(),
isOutgoing: !isChannel, isOutgoing: !isChannel,
senderId: sendAs?.id || currentUserId, senderId: sendAs?.id || currentUserId,
...(replyingTo && { replyToMessageId: replyingTo }), ...(replyToMessageId && { replyToMessageId }),
...(replyingToTopId && { replyToTopMessageId: replyingToTopId }), ...(replyingToTopId && { replyToTopMessageId: replyingToTopId }),
...((replyingTo || replyingToTopId) && isForum && { isTopicReply: true }), ...((replyToMessageId || replyingToTopId) && isForum && { isTopicReply: true }),
...(replyToStoryUserId && { replyToStoryUserId, replyToStoryId }),
...(groupedId && { ...(groupedId && {
groupedId, groupedId,
...(media && (media.photo || media.video) && { isInAlbum: true }), ...(media && (media.photo || media.video) && { isInAlbum: true }),
@ -1627,3 +1715,111 @@ export function buildApiFormattedText(textWithEntities: GramJs.TextWithEntities)
entities: entities.map(buildApiMessageEntity), 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 || [],
};
}

View File

@ -3,7 +3,7 @@ import { Api as GramJs } from '../../../lib/gramjs';
import type { import type {
ApiConfig, ApiCountry, ApiSession, ApiUrlAuthResult, ApiWallpaper, ApiWebSession, ApiLangString, ApiConfig, ApiCountry, ApiSession, ApiUrlAuthResult, ApiWallpaper, ApiWebSession, ApiLangString,
} from '../../types'; } from '../../types';
import type { ApiPrivacySettings, ApiPrivacyKey, PrivacyVisibility } from '../../../types'; import type { ApiPrivacyKey } from '../../../types';
import { buildApiDocument, buildApiReaction } from './messages'; import { buildApiDocument, buildApiReaction } from './messages';
import { buildApiPeerId, getApiChatIdFromMtpPeer } from './peers'; import { buildApiPeerId, getApiChatIdFromMtpPeer } from './peers';
@ -81,47 +81,6 @@ export function buildPrivacyKey(key: GramJs.TypePrivacyKey): ApiPrivacyKey | und
return undefined; 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( export function buildApiNotifyException(
notifySettings: GramJs.TypePeerNotifySettings, peer: GramJs.TypePeer, notifySettings: GramJs.TypePeerNotifySettings, peer: GramJs.TypePeer,
) { ) {

View File

@ -16,7 +16,7 @@ export function buildApiUserFullInfo(mtpUserFull: GramJs.users.UserFull): ApiUse
fullUser: { fullUser: {
about, commonChatsCount, pinnedMsgId, botInfo, blocked, about, commonChatsCount, pinnedMsgId, botInfo, blocked,
profilePhoto, voiceMessagesForbidden, premiumGifts, profilePhoto, voiceMessagesForbidden, premiumGifts,
fallbackPhoto, personalPhoto, translationsDisabled, fallbackPhoto, personalPhoto, translationsDisabled, storiesPinnedAvailable,
}, },
users, users,
} = mtpUserFull; } = mtpUserFull;
@ -29,6 +29,7 @@ export function buildApiUserFullInfo(mtpUserFull: GramJs.users.UserFull): ApiUse
pinnedMessageId: pinnedMsgId, pinnedMessageId: pinnedMsgId,
isBlocked: Boolean(blocked), isBlocked: Boolean(blocked),
noVoiceMessages: voiceMessagesForbidden, noVoiceMessages: voiceMessagesForbidden,
hasPinnedStories: Boolean(storiesPinnedAvailable),
isTranslationDisabled: translationsDisabled, isTranslationDisabled: translationsDisabled,
...(profilePhoto instanceof GramJs.Photo && { profilePhoto: buildApiPhoto(profilePhoto) }), ...(profilePhoto instanceof GramJs.Photo && { profilePhoto: buildApiPhoto(profilePhoto) }),
...(fallbackPhoto instanceof GramJs.Photo && { fallbackPhoto: buildApiPhoto(fallbackPhoto) }), ...(fallbackPhoto instanceof GramJs.Photo && { fallbackPhoto: buildApiPhoto(fallbackPhoto) }),
@ -44,7 +45,7 @@ export function buildApiUser(mtpUser: GramJs.TypeUser): ApiUser | undefined {
} }
const { const {
id, firstName, lastName, fake, scam, id, firstName, lastName, fake, scam, support, closeFriend, storiesUnavailable, storiesMaxId,
} = mtpUser; } = mtpUser;
const hasVideoAvatar = mtpUser.photo instanceof GramJs.UserProfilePhoto const hasVideoAvatar = mtpUser.photo instanceof GramJs.UserProfilePhoto
? Boolean(mtpUser.photo.hasVideo) ? Boolean(mtpUser.photo.hasVideo)
@ -63,6 +64,8 @@ export function buildApiUser(mtpUser: GramJs.TypeUser): ApiUser | undefined {
...(mtpUser.self && { isSelf: true }), ...(mtpUser.self && { isSelf: true }),
isPremium: Boolean(mtpUser.premium), isPremium: Boolean(mtpUser.premium),
...(mtpUser.verified && { isVerified: true }), ...(mtpUser.verified && { isVerified: true }),
...(closeFriend && { isCloseFriend: true }),
...(support && { isSupport: true }),
...((mtpUser.contact || mtpUser.mutualContact) && { isContact: true }), ...((mtpUser.contact || mtpUser.mutualContact) && { isContact: true }),
type: userType, type: userType,
firstName, firstName,
@ -75,6 +78,9 @@ export function buildApiUser(mtpUser: GramJs.TypeUser): ApiUser | undefined {
...(avatarHash && { avatarHash }), ...(avatarHash && { avatarHash }),
emojiStatus, emojiStatus,
hasVideoAvatar, hasVideoAvatar,
areStoriesHidden: Boolean(mtpUser.storiesHidden),
maxStoryId: storiesMaxId,
hasStories: Boolean(storiesMaxId) && !storiesUnavailable,
...(mtpUser.bot && mtpUser.botInlinePlaceholder && { botPlaceholder: mtpUser.botInlinePlaceholder }), ...(mtpUser.bot && mtpUser.botInlinePlaceholder && { botPlaceholder: mtpUser.botInlinePlaceholder }),
...(mtpUser.bot && mtpUser.botAttachMenu && { isAttachBot: mtpUser.botAttachMenu }), ...(mtpUser.bot && mtpUser.botAttachMenu && { isAttachBot: mtpUser.botAttachMenu }),
}; };

View File

@ -1,7 +1,7 @@
import BigInt from 'big-integer'; import BigInt from 'big-integer';
import { Api as GramJs } from '../../../lib/gramjs'; 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 { generateRandomBytes, readBigIntFromBuffer } from '../../../lib/gramjs/Helpers';
import type { import type {
@ -24,6 +24,10 @@ import type {
ApiReaction, ApiReaction,
ApiFormattedText, ApiFormattedText,
ApiBotApp, ApiBotApp,
ApiStory,
ApiStorySkipped,
ApiUser,
ApiTypeReplyTo,
} from '../../types'; } from '../../types';
import { import {
ApiMessageEntityTypes, 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() { export function generateRandomBigInt() {
return readBigIntFromBuffer(generateRandomBytes(8), true, true); return readBigIntFromBuffer(generateRandomBytes(8), true, true);
} }
@ -625,3 +637,82 @@ export function buildInputBotApp(app: ApiBotApp) {
accessHash: BigInt(app.accessHash), 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<GramJs.InputUser[]>((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<GramJs.InputUser[]>((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;
}

View File

@ -1,5 +1,6 @@
import { Api as GramJs } from '../../lib/gramjs'; import { Api as GramJs } from '../../lib/gramjs';
import localDb from './localDb'; import localDb from './localDb';
import type { StoryRepairInfo } from './localDb';
import { buildApiPeerId, getApiChatIdFromMtpPeer } from './apiBuilders/peers'; import { buildApiPeerId, getApiChatIdFromMtpPeer } from './apiBuilders/peers';
const LOG_BACKGROUND = '#111111DD'; 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) { export function addPhotoToLocalDb(photo: GramJs.TypePhoto) {
if (photo instanceof GramJs.Photo) { if (photo instanceof GramJs.Photo) {
localDb.photos[String(photo.id)] = photo; localDb.photos[String(photo.id)] = photo;

View File

@ -8,16 +8,22 @@ import { throttle } from '../../util/schedulers';
// eslint-disable-next-line no-restricted-globals // eslint-disable-next-line no-restricted-globals
const IS_MULTITAB_SUPPORTED = 'BroadcastChannel' in self; const IS_MULTITAB_SUPPORTED = 'BroadcastChannel' in self;
export type StoryRepairInfo = {
storyData?: {
userId: string;
id: number;
};
};
export interface LocalDb { export interface LocalDb {
// Used for loading avatars and media through in-memory Gram JS instances. // Used for loading avatars and media through in-memory Gram JS instances.
chats: Record<string, GramJs.Chat | GramJs.Channel>; chats: Record<string, GramJs.Chat | GramJs.Channel>;
users: Record<string, GramJs.User>; users: Record<string, GramJs.User>;
messages: Record<string, GramJs.Message | GramJs.MessageService>; messages: Record<string, GramJs.Message | GramJs.MessageService>;
documents: Record<string, GramJs.Document>; documents: Record<string, GramJs.Document & StoryRepairInfo>;
stickerSets: Record<string, GramJs.StickerSet>; stickerSets: Record<string, GramJs.StickerSet>;
photos: Record<string, GramJs.Photo>; photos: Record<string, GramJs.Photo & StoryRepairInfo>;
webDocuments: Record<string, GramJs.TypeWebDocument>; webDocuments: Record<string, GramJs.TypeWebDocument>;
commonBoxState: Record<string, number>; commonBoxState: Record<string, number>;
channelPtsById: Record<string, number>; channelPtsById: Record<string, number>;
} }
@ -79,7 +85,7 @@ function convertToVirtualClass(value: any): any {
function createLocalDbInitial(initial?: LocalDb): LocalDb { function createLocalDbInitial(initial?: LocalDb): LocalDb {
return [ return [
'localMessages', 'chats', 'users', 'messages', 'documents', 'stickerSets', 'photos', 'webDocuments', 'localMessages', 'chats', 'users', 'messages', 'documents', 'stickerSets', 'photos', 'webDocuments', 'stories',
'commonBoxState', 'channelPtsById', 'commonBoxState', 'channelPtsById',
] ]
.reduce((acc: Record<string, any>, key) => { .reduce((acc: Record<string, any>, key) => {

View File

@ -10,7 +10,12 @@ import localDb from '../localDb';
import { WEB_APP_PLATFORM } from '../../../config'; import { WEB_APP_PLATFORM } from '../../../config';
import { invokeRequest } from './client'; import { invokeRequest } from './client';
import { import {
buildInputBotApp, buildInputEntity, buildInputPeer, buildInputThemeParams, generateRandomBigInt, buildInputBotApp,
buildInputEntity,
buildInputPeer,
buildInputReplyToMessage,
buildInputThemeParams,
generateRandomBigInt,
} from '../gramjsBuilders'; } from '../gramjsBuilders';
import { buildApiUser } from '../apiBuilders/users'; import { buildApiUser } from '../apiBuilders/users';
import { import {
@ -189,13 +194,12 @@ export async function requestWebView({
silent: isSilent || undefined, silent: isSilent || undefined,
peer: buildInputPeer(peer.id, peer.accessHash), peer: buildInputPeer(peer.id, peer.accessHash),
bot: buildInputPeer(bot.id, bot.accessHash), bot: buildInputPeer(bot.id, bot.accessHash),
replyToMsgId: replyToMessageId,
url, url,
startParam, startParam,
themeParams: theme ? buildInputThemeParams(theme) : undefined, themeParams: theme ? buildInputThemeParams(theme) : undefined,
fromBotMenu: isFromBotMenu || undefined, fromBotMenu: isFromBotMenu || undefined,
platform: WEB_APP_PLATFORM, platform: WEB_APP_PLATFORM,
...(threadId && { topMsgId: threadId }), ...(replyToMessageId && { replyTo: buildInputReplyToMessage(replyToMessageId, threadId) }),
...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }), ...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }),
})); }));
@ -294,8 +298,7 @@ export function prolongWebView({
peer: buildInputPeer(peer.id, peer.accessHash), peer: buildInputPeer(peer.id, peer.accessHash),
bot: buildInputPeer(bot.id, bot.accessHash), bot: buildInputPeer(bot.id, bot.accessHash),
queryId: BigInt(queryId), queryId: BigInt(queryId),
replyToMsgId: replyToMessageId, ...(replyToMessageId && { replyTo: buildInputReplyToMessage(replyToMessageId, threadId) }),
...(threadId && { topMsgId: threadId }),
...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }), ...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }),
})); }));
} }

View File

@ -1,5 +1,5 @@
import { import {
sessions, Api as GramJs, connection, sessions, Api as GramJs,
} from '../../../lib/gramjs'; } from '../../../lib/gramjs';
import TelegramClient from '../../../lib/gramjs/client/TelegramClient'; import TelegramClient from '../../../lib/gramjs/client/TelegramClient';
@ -26,7 +26,8 @@ import { buildApiUser, buildApiUserFullInfo } from '../apiBuilders/users';
import localDb, { clearLocalDb } from '../localDb'; import localDb, { clearLocalDb } from '../localDb';
import { buildApiPeerId } from '../apiBuilders/peers'; import { buildApiPeerId } from '../apiBuilders/peers';
import { import {
addMessageToLocalDb, addUserToLocalDb, isResponseUpdate, log, addEntitiesToLocalDb,
addMessageToLocalDb, addStoryToLocalDb, addUserToLocalDb, isResponseUpdate, log,
} from '../helpers'; } from '../helpers';
import { ChatAbortController } from '../ChatAbortController'; import { ChatAbortController } from '../ChatAbortController';
import { import {
@ -52,7 +53,6 @@ const ABORT_CONTROLLERS = new Map<string, AbortController>();
let onUpdate: OnApiUpdate; let onUpdate: OnApiUpdate;
let client: TelegramClient; let client: TelegramClient;
let isConnected = false;
let currentUserId: string | undefined; let currentUserId: string | undefined;
export async function init(_onUpdate: OnApiUpdate, initialArgs: ApiInitialArgs) { 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) { export function handleGramJsUpdate(update: any) {
processUpdate(update); processUpdate(update);
if (update instanceof connection.UpdateConnectionState) { if (update instanceof GramJs.UpdatesTooLong) {
isConnected = update.state === connection.UpdateConnectionState.connected;
} else if (update instanceof GramJs.UpdatesTooLong) {
void handleTerminatedSession(); void handleTerminatedSession();
} else { } else {
const updates = 'updates' in update ? update.updates : [update]; const updates = 'updates' in update ? update.updates : [update];
@ -314,16 +312,18 @@ export function invokeRequestBeacon<T extends GramJs.AnyRequest>(
} }
export async function downloadMedia( 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, onProgress?: ApiOnProgress,
) { ) {
try { try {
return (await downloadMediaWithClient(args, client, isConnected, onProgress)); return (await downloadMediaWithClient(args, client, onProgress));
} catch (err: any) { } catch (err: any) {
if (err.message.startsWith('FILE_REFERENCE')) { if (err.message.startsWith('FILE_REFERENCE')) {
const isFileReferenceRepaired = await repairFileReference({ url: args.url }); const isFileReferenceRepaired = await repairFileReference({ url: args.url });
if (isFileReferenceRepaired) { if (isFileReferenceRepaired) {
return downloadMediaWithClient(args, client, isConnected, onProgress); return downloadMediaWithClient(args, client, onProgress);
} }
if (DEBUG) { if (DEBUG) {
@ -439,8 +439,21 @@ export async function repairFileReference({
entityType, entityId, mediaMatchType, entityType, entityId, mediaMatchType,
} = parsed; } = parsed;
if (mediaMatchType === 'file') { if (mediaMatchType === 'document' || mediaMatchType === 'photo') {
return false; 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') { if (entityType === 'msg') {
@ -470,6 +483,8 @@ export async function repairFileReference({
const message = result.messages[0]; const message = result.messages[0];
if (message instanceof GramJs.MessageEmpty) return false; if (message instanceof GramJs.MessageEmpty) return false;
addEntitiesToLocalDb(result.users);
addEntitiesToLocalDb(result.chats);
addMessageToLocalDb(message); addMessageToLocalDb(message);
return true; return true;
} }

View File

@ -39,6 +39,7 @@ export {
export { export {
fetchFullUser, fetchNearestCountry, fetchTopUsers, fetchContactList, fetchUsers, fetchFullUser, fetchNearestCountry, fetchTopUsers, fetchContactList, fetchUsers,
updateContact, importContact, deleteContact, fetchProfilePhotos, fetchCommonChats, reportSpam, updateEmojiStatus, updateContact, importContact, deleteContact, fetchProfilePhotos, fetchCommonChats, reportSpam, updateEmojiStatus,
saveCloseFriends,
} from './users'; } from './users';
export { export {
@ -107,3 +108,9 @@ export {
export { export {
broadcastLocalDbUpdateFull, broadcastLocalDbUpdateFull,
} from '../localDb'; } from '../localDb';
export {
fetchAllStories, fetchUserStories, fetchUserPinnedStories, fetchUserStoriesByIds, viewStory, markStoryRead,
deleteStory, toggleStoryPinned, fetchStorySeenBy, fetchStoryLink, fetchStoriesArchive, reportStory, editStoryPrivacy,
toggleStoriesHidden, fetchStoriesMaxIds,
} from './stories';

View File

@ -27,12 +27,11 @@ export default async function downloadMedia(
url: string; mediaFormat: ApiMediaFormat; start?: number; end?: number; isHtmlAllowed?: boolean; url: string; mediaFormat: ApiMediaFormat; start?: number; end?: number; isHtmlAllowed?: boolean;
}, },
client: TelegramClient, client: TelegramClient,
isConnected: boolean,
onProgress?: ApiOnProgress, onProgress?: ApiOnProgress,
) { ) {
const { const {
data, mimeType, fullSize, data, mimeType, fullSize,
} = await download(url, client, isConnected, onProgress, start, end, mediaFormat, isHtmlAllowed) || {}; } = await download(url, client, onProgress, start, end, mediaFormat, isHtmlAllowed) || {};
if (!data) { if (!data) {
return undefined; return undefined;
@ -71,7 +70,6 @@ export type EntityType = (
async function download( async function download(
url: string, url: string,
client: TelegramClient, client: TelegramClient,
isConnected: boolean,
onProgress?: ApiOnProgress, onProgress?: ApiOnProgress,
start?: number, start?: number,
end?: number, end?: number,
@ -218,14 +216,17 @@ function getMessageMediaMimeType(message: GramJs.Message, sizeType?: string) {
return 'image/png'; return 'image/png';
} }
if (message.media instanceof GramJs.MessageMediaDocument && message.media.document instanceof GramJs.Document) { if (message.media instanceof GramJs.MessageMediaDocument) {
if (sizeType) { const document = message.media.document;
return message.media.document!.attributes.some((a) => a instanceof GramJs.DocumentAttributeSticker) if (document instanceof GramJs.Document) {
? 'image/webp' if (sizeType) {
: 'image/jpeg'; 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 if (message.media instanceof GramJs.MessageMediaWebPage

View File

@ -17,6 +17,9 @@ import type {
ApiContact, ApiContact,
ApiPoll, ApiPoll,
ApiFormattedText, ApiFormattedText,
ApiTypeReplyTo,
ApiStory,
ApiStorySkipped,
} from '../../types'; } from '../../types';
import { import {
MAIN_THREAD_ID, MAIN_THREAD_ID,
@ -57,6 +60,8 @@ import {
buildInputPollFromExisting, buildInputPollFromExisting,
buildInputTextWithEntities, buildInputTextWithEntities,
buildMessageFromUpdate, buildMessageFromUpdate,
buildInputStory,
buildInputReplyTo,
} from '../gramjsBuilders'; } from '../gramjsBuilders';
import { buildApiChatFromPreview, buildApiSendAsPeerId } from '../apiBuilders/chats'; import { buildApiChatFromPreview, buildApiSendAsPeerId } from '../apiBuilders/chats';
import { fetchFile } from '../../../util/files'; import { fetchFile } from '../../../util/files';
@ -233,9 +238,9 @@ export function sendMessage(
text, text,
entities, entities,
replyingTo, replyingTo,
replyingToTopId,
attachment, attachment,
sticker, sticker,
story,
gif, gif,
poll, poll,
contact, contact,
@ -250,10 +255,10 @@ export function sendMessage(
lastMessageId?: number; lastMessageId?: number;
text?: string; text?: string;
entities?: ApiMessageEntity[]; entities?: ApiMessageEntity[];
replyingTo?: number; replyingTo?: ApiTypeReplyTo;
replyingToTopId?: number;
attachment?: ApiAttachment; attachment?: ApiAttachment;
sticker?: ApiSticker; sticker?: ApiSticker;
story?: ApiStory | ApiStorySkipped;
gif?: ApiVideo; gif?: ApiVideo;
poll?: ApiNewPoll; poll?: ApiNewPoll;
contact?: ApiContact; contact?: ApiContact;
@ -271,7 +276,6 @@ export function sendMessage(
text, text,
entities, entities,
replyingTo, replyingTo,
replyingToTopId,
attachment, attachment,
sticker, sticker,
gif, gif,
@ -280,6 +284,7 @@ export function sendMessage(
groupedId, groupedId,
scheduledAt, scheduledAt,
sendAs, sendAs,
story,
); );
onUpdate({ onUpdate({
@ -310,7 +315,6 @@ export function sendMessage(
text, text,
entities, entities,
replyingTo, replyingTo,
replyingToTopId,
attachment: attachment!, attachment: attachment!,
groupedId, groupedId,
isSilent, isSilent,
@ -339,6 +343,8 @@ export function sendMessage(
media = buildInputMediaDocument(gif); media = buildInputMediaDocument(gif);
} else if (poll) { } else if (poll) {
media = buildInputPoll(poll, randomId); media = buildInputPoll(poll, randomId);
} else if (story) {
media = buildInputStory(story);
} else if (contact) { } else if (contact) {
media = new GramJs.InputMediaContact({ media = new GramJs.InputMediaContact({
phoneNumber: contact.phoneNumber, phoneNumber: contact.phoneNumber,
@ -349,6 +355,7 @@ export function sendMessage(
} }
const RequestClass = media ? GramJs.messages.SendMedia : GramJs.messages.SendMessage; const RequestClass = media ? GramJs.messages.SendMedia : GramJs.messages.SendMessage;
const replyTo = replyingTo ? buildInputReplyTo(replyingTo) : undefined;
try { try {
const update = await invokeRequest(new RequestClass({ const update = await invokeRequest(new RequestClass({
@ -359,8 +366,7 @@ export function sendMessage(
randomId, randomId,
...(isSilent && { silent: isSilent }), ...(isSilent && { silent: isSilent }),
...(scheduledAt && { scheduleDate: scheduledAt }), ...(scheduledAt && { scheduleDate: scheduledAt }),
...(replyingTo && { replyToMsgId: replyingTo }), ...(replyTo && { replyTo }),
...(replyingToTopId && { topMsgId: replyingToTopId }),
...(media && { media }), ...(media && { media }),
...(noWebPage && { noWebpage: noWebPage }), ...(noWebPage && { noWebpage: noWebPage }),
...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }), ...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }),
@ -396,7 +402,6 @@ function sendGroupedMedia(
text, text,
entities, entities,
replyingTo, replyingTo,
replyingToTopId,
attachment, attachment,
groupedId, groupedId,
isSilent, isSilent,
@ -406,8 +411,7 @@ function sendGroupedMedia(
chat: ApiChat; chat: ApiChat;
text?: string; text?: string;
entities?: ApiMessageEntity[]; entities?: ApiMessageEntity[];
replyingTo?: number; replyingTo?: ApiTypeReplyTo;
replyingToTopId?: number;
attachment: ApiAttachment; attachment: ApiAttachment;
groupedId: string; groupedId: string;
isSilent?: boolean; isSilent?: boolean;
@ -479,13 +483,13 @@ function sendGroupedMedia(
const { singleMediaByIndex, localMessages } = groupedUploads[groupedId]; const { singleMediaByIndex, localMessages } = groupedUploads[groupedId];
delete groupedUploads[groupedId]; delete groupedUploads[groupedId];
const replyTo = replyingTo ? buildInputReplyTo(replyingTo) : undefined;
const update = await invokeRequest(new GramJs.messages.SendMultiMedia({ const update = await invokeRequest(new GramJs.messages.SendMultiMedia({
clearDraft: true, clearDraft: true,
peer: buildInputPeer(chat.id, chat.accessHash), peer: buildInputPeer(chat.id, chat.accessHash),
multiMedia: Object.values(singleMediaByIndex), // Object keys are usually ordered multiMedia: Object.values(singleMediaByIndex), // Object keys are usually ordered
replyToMsgId: replyingTo, ...(replyingTo && { replyTo }),
...(replyingToTopId && { topMsgId: replyingToTopId }),
...(isSilent && { silent: isSilent }), ...(isSilent && { silent: isSilent }),
...(scheduledAt && { scheduleDate: scheduledAt }), ...(scheduledAt && { scheduleDate: scheduledAt }),
...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }), ...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }),

View File

@ -22,9 +22,8 @@ import {
buildApiSession, buildApiSession,
buildApiWallpaper, buildApiWallpaper,
buildApiWebSession, buildLangPack, buildLangPackString, buildApiWebSession, buildLangPack, buildLangPackString,
buildPrivacyRules,
} from '../apiBuilders/misc'; } from '../apiBuilders/misc';
import { buildPrivacyRules } from '../apiBuilders/messages';
import { buildApiPhoto } from '../apiBuilders/common'; import { buildApiPhoto } from '../apiBuilders/common';
import { buildApiUser } from '../apiBuilders/users'; import { buildApiUser } from '../apiBuilders/users';
import { buildApiChatFromPreview } from '../apiBuilders/chats'; import { buildApiChatFromPreview } from '../apiBuilders/chats';
@ -620,7 +619,7 @@ export async function updateGlobalPrivacySettings({ shouldArchiveAndMuteNewNonCo
}) { }) {
const result = await invokeRequest(new GramJs.account.SetGlobalPrivacySettings({ const result = await invokeRequest(new GramJs.account.SetGlobalPrivacySettings({
settings: new GramJs.GlobalPrivacySettings({ settings: new GramJs.GlobalPrivacySettings({
archiveAndMuteNewNoncontactPeers: shouldArchiveAndMuteNewNonContact, ...(shouldArchiveAndMuteNewNonContact && { archiveAndMuteNewNoncontactPeers: true }),
}), }),
})); }));

View File

@ -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<string, ApiUserStories>; hasMore?: true; state: string }> {
const params: ConstructorParameters<typeof GramJs.stories.GetAllStories>[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<Record<string, ApiUserStories>>((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<Record<string, ApiTypeStory>>((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<Record<string, number>>((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,
};
}

View File

@ -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)) { function updateLocalDb(result: (GramJs.photos.Photos | GramJs.photos.PhotosSlice | GramJs.messages.Chats)) {
if ('chats' in result) { if ('chats' in result) {
addEntitiesToLocalDb(result.chats); addEntitiesToLocalDb(result.chats);

View File

@ -2,6 +2,7 @@ import type { GroupCallConnectionData } from '../../lib/secret-sauce';
import { Api as GramJs, connection } from '../../lib/gramjs'; import { Api as GramJs, connection } from '../../lib/gramjs';
import type { import type {
ApiMessage, ApiMessageExtendedMediaPreview, ApiUpdate, ApiUpdateConnectionStateType, OnApiUpdate, ApiMessage, ApiMessageExtendedMediaPreview, ApiUpdate, ApiUpdateConnectionStateType, OnApiUpdate,
ApiStory, ApiStorySkipped,
} from '../types'; } from '../types';
import { DEBUG, GENERAL_TOPIC_ID } from '../../config'; import { DEBUG, GENERAL_TOPIC_ID } from '../../config';
@ -18,6 +19,8 @@ import {
buildMessageDraft, buildMessageDraft,
buildMessageReactions, buildMessageReactions,
buildApiMessageExtendedMediaPreview, buildApiMessageExtendedMediaPreview,
buildApiStory,
buildPrivacyRules,
} from './apiBuilders/messages'; } from './apiBuilders/messages';
import { import {
buildChatMember, buildChatMember,
@ -49,12 +52,12 @@ import {
log, log,
swapLocalInvoiceMedia, swapLocalInvoiceMedia,
isChatFolder, isChatFolder,
addStoryToLocalDb,
} from './helpers'; } from './helpers';
import { import {
buildApiNotifyException, buildApiNotifyException,
buildApiNotifyExceptionTopic, buildApiNotifyExceptionTopic,
buildPrivacyKey, buildPrivacyKey,
buildPrivacyRules,
} from './apiBuilders/misc'; } from './apiBuilders/misc';
import { buildApiPhoto, buildApiUsernames } from './apiBuilders/common'; import { buildApiPhoto, buildApiUsernames } from './apiBuilders/common';
import { import {
@ -300,7 +303,9 @@ export function updater(update: Update) {
}); });
} }
} else if (action instanceof GramJs.MessageActionTopicEdit) { } else if (action instanceof GramJs.MessageActionTopicEdit) {
const { replyTo } = update.message; const replyTo = update.message.replyTo instanceof GramJs.MessageReplyHeader
? update.message.replyTo
: undefined;
const { const {
replyToMsgId, replyToTopId, forumTopic: isTopicReply, replyToMsgId, replyToTopId, forumTopic: isTopicReply,
} = replyTo || {}; } = replyTo || {};
@ -1050,6 +1055,37 @@ export function updater(update: Update) {
}); });
} else if (update instanceof GramJs.UpdateRecentEmojiStatuses) { } else if (update instanceof GramJs.UpdateRecentEmojiStatuses) {
onUpdate({ '@type': '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) { } else if (DEBUG) {
const params = typeof update === 'object' && 'className' in update ? update.className : update; const params = typeof update === 'object' && 'className' in update ? update.className : update;
log('UNEXPECTED UPDATE', params); log('UNEXPECTED UPDATE', params);

View File

@ -35,7 +35,6 @@ const savedLocalDb: LocalDb = {
stickerSets: {}, stickerSets: {},
photos: {}, photos: {},
webDocuments: {}, webDocuments: {},
commonBoxState: {}, commonBoxState: {},
channelPtsById: {}, channelPtsById: {},
}; };

View File

@ -9,3 +9,4 @@ export * from './bots';
export * from './misc'; export * from './misc';
export * from './calls'; export * from './calls';
export * from './statistics'; export * from './statistics';
export * from './stories';

View File

@ -1,6 +1,7 @@
import type { ApiWebDocument } from './bots'; import type { ApiWebDocument } from './bots';
import type { ApiGroupCall, PhoneCallAction } from './calls'; import type { ApiGroupCall, PhoneCallAction } from './calls';
import type { ApiChat } from './chats'; import type { ApiChat } from './chats';
import type { ApiMessageStoryData, ApiWebPageStoryData } from './stories';
export interface ApiDimensions { export interface ApiDimensions {
width: number; width: number;
@ -95,6 +96,7 @@ export interface ApiVideo {
blobUrl?: string; blobUrl?: string;
previewBlobUrl?: string; previewBlobUrl?: string;
size: number; size: number;
noSound?: boolean;
} }
export interface ApiAudio { export interface ApiAudio {
@ -293,6 +295,19 @@ export interface ApiWebPage {
duration?: number; duration?: number;
document?: ApiDocument; document?: ApiDocument;
video?: ApiVideo; 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 { export interface ApiMessageForwardInfo {
@ -383,6 +398,7 @@ export interface ApiMessage {
text?: ApiFormattedText; text?: ApiFormattedText;
photo?: ApiPhoto; photo?: ApiPhoto;
video?: ApiVideo; video?: ApiVideo;
altVideo?: ApiVideo;
document?: ApiDocument; document?: ApiDocument;
sticker?: ApiSticker; sticker?: ApiSticker;
contact?: ApiContact; contact?: ApiContact;
@ -394,6 +410,7 @@ export interface ApiMessage {
invoice?: ApiInvoice; invoice?: ApiInvoice;
location?: ApiLocation; location?: ApiLocation;
game?: ApiGame; game?: ApiGame;
storyData?: ApiMessageStoryData;
}; };
date: number; date: number;
isOutgoing: boolean; isOutgoing: boolean;
@ -402,6 +419,8 @@ export interface ApiMessage {
replyToMessageId?: number; replyToMessageId?: number;
replyToTopMessageId?: number; replyToTopMessageId?: number;
isTopicReply?: true; isTopicReply?: true;
replyToStoryUserId?: string;
replyToStoryId?: number;
sendingState?: 'messageSendingStatePending' | 'messageSendingStateFailed'; sendingState?: 'messageSendingStatePending' | 'messageSendingStateFailed';
forwardInfo?: ApiMessageForwardInfo; forwardInfo?: ApiMessageForwardInfo;
isDeleting?: boolean; isDeleting?: boolean;

View File

@ -109,7 +109,7 @@ export type ApiNotification = {
title?: string; title?: string;
message: string; message: string;
actionText?: string; actionText?: string;
action?: CallbackAction; action?: CallbackAction | CallbackAction[];
className?: string; className?: string;
duration?: number; duration?: number;
}; };
@ -191,6 +191,10 @@ export interface ApiAppConfig {
hiddenMembersMinCount: number; hiddenMembersMinCount: number;
limits: Record<ApiLimitType, readonly [number, number]>; limits: Record<ApiLimitType, readonly [number, number]>;
canDisplayAutoarchiveSetting: boolean; canDisplayAutoarchiveSetting: boolean;
areStoriesHidden?: boolean;
storyExpirePeriod: number;
storyViewersExpirePeriod: number;
storyChangelogUserId: string;
} }
export interface ApiConfig { export interface ApiConfig {

59
src/api/types/stories.ts Normal file
View File

@ -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<number, ApiTypeStory>;
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;
};

View File

@ -33,6 +33,7 @@ import type {
} from './calls'; } from './calls';
import type { ApiBotMenuButton } from './bots'; import type { ApiBotMenuButton } from './bots';
import type { ApiPrivacyKey, PrivacyVisibility } from '../../types'; import type { ApiPrivacyKey, PrivacyVisibility } from '../../types';
import type { ApiStory, ApiStorySkipped } from './stories';
export type ApiUpdateReady = { export type ApiUpdateReady = {
'@type': 'updateApiReady'; '@type': 'updateApiReady';
@ -624,6 +625,24 @@ export type ApiRequestReconnectApi = {
'@type': 'requestReconnectApi'; '@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 = { export type ApiRequestSync = {
'@type': 'requestSync'; '@type': 'requestSync';
}; };
@ -654,7 +673,8 @@ export type ApiUpdate = (
ApiUpdatePhoneCallConnectionState | ApiUpdateBotMenuButton | ApiUpdateTranscribedAudio | ApiUpdateUserEmojiStatus | ApiUpdatePhoneCallConnectionState | ApiUpdateBotMenuButton | ApiUpdateTranscribedAudio | ApiUpdateUserEmojiStatus |
ApiUpdateMessageExtendedMedia | ApiUpdateConfig | ApiUpdateTopicNotifyExceptions | ApiUpdatePinnedTopic | ApiUpdateMessageExtendedMedia | ApiUpdateConfig | ApiUpdateTopicNotifyExceptions | ApiUpdatePinnedTopic |
ApiUpdatePinnedTopicsOrder | ApiUpdateTopic | ApiUpdateTopics | ApiUpdateRecentEmojiStatuses | ApiUpdatePinnedTopicsOrder | ApiUpdateTopic | ApiUpdateTopics | ApiUpdateRecentEmojiStatuses |
ApiUpdateRecentReactions | ApiRequestReconnectApi | ApiRequestSync | ApiUpdateFetchingDifference ApiUpdateRecentReactions | ApiUpdateStory | ApiUpdateReadStories | ApiUpdateDeleteStory |
ApiRequestReconnectApi | ApiRequestSync | ApiUpdateFetchingDifference | ApiUpdateChannelMessages
); );
export type OnApiUpdate = (update: ApiUpdate) => void; export type OnApiUpdate = (update: ApiUpdate) => void;

View File

@ -8,7 +8,9 @@ export interface ApiUser {
isSelf?: true; isSelf?: true;
isVerified?: true; isVerified?: true;
isPremium?: boolean; isPremium?: boolean;
isCloseFriend?: boolean;
isContact?: true; isContact?: true;
isSupport?: true;
type: ApiUserType; type: ApiUserType;
firstName?: string; firstName?: string;
lastName?: string; lastName?: string;
@ -29,6 +31,10 @@ export interface ApiUser {
fakeType?: ApiFakeType; fakeType?: ApiFakeType;
isAttachBot?: boolean; isAttachBot?: boolean;
emojiStatus?: ApiEmojiStatus; emojiStatus?: ApiEmojiStatus;
areStoriesHidden?: boolean;
hasStories?: boolean;
hasUnreadStories?: boolean;
maxStoryId?: number;
} }
export interface ApiUserFullInfo { export interface ApiUserFullInfo {
@ -43,6 +49,7 @@ export interface ApiUserFullInfo {
noVoiceMessages?: boolean; noVoiceMessages?: boolean;
premiumGifts?: ApiPremiumGiftOption[]; premiumGifts?: ApiPremiumGiftOption[];
isTranslationDisabled?: true; isTranslationDisabled?: true;
hasPinnedStories?: boolean;
} }
export type ApiFakeType = 'fake' | 'scam'; export type ApiFakeType = 'fake' | 'scam';

Binary file not shown.

Binary file not shown.

View File

@ -1,4 +1,5 @@
export { default as MediaViewer } from '../components/mediaViewer/MediaViewer'; 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 ForwardRecipientPicker } from '../components/main/ForwardRecipientPicker';
export { default as DraftRecipientPicker } from '../components/main/DraftRecipientPicker'; export { default as DraftRecipientPicker } from '../components/main/DraftRecipientPicker';

View File

@ -11,6 +11,8 @@
} }
:global(.Avatar) { :global(.Avatar) {
--radius: 0;
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;

View File

@ -1,20 +1,33 @@
.Avatar { .Avatar {
--color-user: var(--color-primary); --color-user: var(--color-primary);
--radius: 50%; --radius: 50%;
flex: none; flex: none;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 3.375rem; width: 3.375rem;
height: 3.375rem; height: 3.375rem;
border-radius: var(--radius); border-radius: var(--radius);
background-image: linear-gradient(var(--color-white) -125%, var(--color-user));
color: white; color: white;
font-weight: bold; font-weight: bold;
display: flex; display: flex;
white-space: nowrap; white-space: nowrap;
user-select: none; user-select: none;
position: relative; 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 { &__media {
width: 100%; width: 100%;
@ -120,6 +133,68 @@
cursor: var(--custom-cursor, pointer); 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 { .poster {
position: absolute; position: absolute;
left: 0; left: 0;

View File

@ -1,12 +1,9 @@
import type { MouseEvent as ReactMouseEvent } from 'react'; import type { MouseEvent as ReactMouseEvent } from 'react';
import React, { import React, { memo, useRef } from '../../lib/teact/teact';
memo, useRef, import { getActions } from '../../global';
} from '../../lib/teact/teact';
import type { FC, TeactNode } from '../../lib/teact/teact'; import type { FC, TeactNode } from '../../lib/teact/teact';
import type { import type { ApiChat, ApiPhoto, ApiUser } from '../../api/types';
ApiChat, ApiPhoto, ApiUser,
} from '../../api/types';
import type { ObserveFn } from '../../hooks/useIntersectionObserver'; import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import { ApiMediaFormat } from '../../api/types'; import { ApiMediaFormat } from '../../api/types';
@ -27,10 +24,11 @@ import renderText from './helpers/renderText';
import useMedia from '../../hooks/useMedia'; import useMedia from '../../hooks/useMedia';
import useMediaTransition from '../../hooks/useMediaTransition'; import useMediaTransition from '../../hooks/useMediaTransition';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
import { useFastClick } from '../../hooks/useFastClick';
import useLastCallback from '../../hooks/useLastCallback'; import useLastCallback from '../../hooks/useLastCallback';
import { useFastClick } from '../../hooks/useFastClick';
import OptimizedVideo from '../ui/OptimizedVideo'; import OptimizedVideo from '../ui/OptimizedVideo';
import AvatarStoryCircle from './AvatarStoryCircle';
import './Avatar.scss'; import './Avatar.scss';
@ -50,6 +48,10 @@ type OwnProps = {
text?: string; text?: string;
isSavedMessages?: boolean; isSavedMessages?: boolean;
withVideo?: boolean; withVideo?: boolean;
withStory?: boolean;
withStoryGap?: boolean;
withStorySolid?: boolean;
storyViewerMode?: 'full' | 'single-user' | 'disabled';
loopIndefinitely?: boolean; loopIndefinitely?: boolean;
noPersonalPhoto?: boolean; noPersonalPhoto?: boolean;
observeIntersection?: ObserveFn; observeIntersection?: ObserveFn;
@ -64,10 +66,16 @@ const Avatar: FC<OwnProps> = ({
text, text,
isSavedMessages, isSavedMessages,
withVideo, withVideo,
withStory,
withStoryGap,
withStorySolid,
storyViewerMode = 'single-user',
loopIndefinitely, loopIndefinitely,
noPersonalPhoto, noPersonalPhoto,
onClick, onClick,
}) => { }) => {
const { openStoryViewer } = getActions();
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const videoLoopCountRef = useRef(0); const videoLoopCountRef = useRef(0);
@ -163,6 +171,7 @@ const Avatar: FC<OwnProps> = ({
className={buildClassName(cn.media, 'avatar-media', transitionClassNames, videoBlobUrl && 'poster')} className={buildClassName(cn.media, 'avatar-media', transitionClassNames, videoBlobUrl && 'poster')}
alt={author} alt={author}
decoding="async" decoding="async"
draggable={false}
/> />
{shouldPlayVideo && ( {shouldPlayVideo && (
<OptimizedVideo <OptimizedVideo
@ -174,6 +183,7 @@ const Avatar: FC<OwnProps> = ({
autoPlay autoPlay
disablePictureInPicture disablePictureInPicture
playsInline playsInline
draggable={false}
onEnded={handleVideoEnded} onEnded={handleVideoEnded}
/> />
)} )}
@ -197,6 +207,9 @@ const Avatar: FC<OwnProps> = ({
isDeleted && 'deleted-account', isDeleted && 'deleted-account',
isReplies && 'replies-bot-account', isReplies && 'replies-bot-account',
isForum && 'forum', isForum && 'forum',
withStory && user?.hasStories && 'with-story-circle',
withStorySolid && user?.hasStories && 'with-story-solid',
withStorySolid && user?.hasUnreadStories && 'has-unread-story',
onClick && 'interactive', onClick && 'interactive',
(!isSavedMessages && !imgBlobUrl) && 'no-photo', (!isSavedMessages && !imgBlobUrl) && 'no-photo',
); );
@ -204,6 +217,13 @@ const Avatar: FC<OwnProps> = ({
const hasMedia = Boolean(isSavedMessages || imgBlobUrl); const hasMedia = Boolean(isSavedMessages || imgBlobUrl);
const { handleClick, handleMouseDown } = useFastClick((e: ReactMouseEvent<HTMLDivElement, MouseEvent>) => { const { handleClick, handleMouseDown } = useFastClick((e: ReactMouseEvent<HTMLDivElement, MouseEvent>) => {
if (withStory && storyViewerMode !== 'disabled' && user?.hasStories) {
e.stopPropagation();
openStoryViewer({ userId: user.id, isSingleUser: storyViewerMode === 'single-user' });
return;
}
if (onClick) { if (onClick) {
onClick(e, hasMedia); onClick(e, hasMedia);
} }
@ -218,7 +238,12 @@ const Avatar: FC<OwnProps> = ({
onClick={handleClick} onClick={handleClick}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
> >
{typeof content === 'string' ? renderText(content, [size === 'jumbo' ? 'hq_emoji' : 'emoji']) : content} <div className="inner">
{typeof content === 'string' ? renderText(content, [size === 'jumbo' ? 'hq_emoji' : 'emoji']) : content}
</div>
{withStory && user?.hasStories && (
<AvatarStoryCircle userId={user.id} size={size} withExtraGap={withStoryGap} />
)}
</div> </div>
); );
}; };

View File

@ -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<AvatarSize, number> = {
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<HTMLCanvasElement>(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 (
<canvas
ref={ref}
className={buildClassName('story-circle', size, className)}
style={`max-width: ${maxSize}px; max-height: ${maxSize}px;`}
/>
);
}
export default memo(withGlobal<OwnProps>((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();
});
}

View File

@ -75,6 +75,7 @@ const ChatExtra: FC<OwnProps & StateProps> = ({
showNotification, showNotification,
updateChatMutedState, updateChatMutedState,
updateTopicMutedState, updateTopicMutedState,
loadUserStories,
} = getActions(); } = getActions();
const { const {
@ -95,6 +96,7 @@ const ChatExtra: FC<OwnProps & StateProps> = ({
useEffect(() => { useEffect(() => {
if (!userId) return; if (!userId) return;
loadFullUser({ userId }); loadFullUser({ userId });
loadUserStories({ userId });
}, [userId]); }, [userId]);
const isTopicInfo = Boolean(topicId && topicId !== MAIN_THREAD_ID); const isTopicInfo = Boolean(topicId && topicId !== MAIN_THREAD_ID);

View File

@ -10,6 +10,7 @@ import { REM } from './helpers/mediaDimensions';
import { CHAT_HEIGHT_PX } from '../../config'; import { CHAT_HEIGHT_PX } from '../../config';
import renderText from './helpers/renderText'; import renderText from './helpers/renderText';
import { getCanPostInChat, isUserId } from '../../global/helpers'; import { getCanPostInChat, isUserId } from '../../global/helpers';
import buildClassName from '../../util/buildClassName';
import useLastCallback from '../../hooks/useLastCallback'; import useLastCallback from '../../hooks/useLastCallback';
import useInfiniteScroll from '../../hooks/useInfiniteScroll'; import useInfiniteScroll from '../../hooks/useInfiniteScroll';
@ -37,6 +38,7 @@ export type OwnProps = {
isOpen: boolean; isOpen: boolean;
searchPlaceholder: string; searchPlaceholder: string;
search: string; search: string;
className?: string;
loadMore?: NoneToVoidFunction; loadMore?: NoneToVoidFunction;
onSearchChange: (search: string) => void; onSearchChange: (search: string) => void;
onSelectChatOrUser: (chatOrUserId: string, threadId?: number) => void; onSelectChatOrUser: (chatOrUserId: string, threadId?: number) => void;
@ -55,6 +57,7 @@ const ChatOrUserPicker: FC<OwnProps> = ({
chatsById, chatsById,
search, search,
searchPlaceholder, searchPlaceholder,
className,
loadMore, loadMore,
onSearchChange, onSearchChange,
onSelectChatOrUser, onSelectChatOrUser,
@ -264,7 +267,7 @@ const ChatOrUserPicker: FC<OwnProps> = ({
return ( return (
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}
className="ChatOrUserPicker" className={buildClassName('ChatOrUserPicker', className)}
onClose={onClose} onClose={onClose}
onCloseAnimationEnd={onCloseAnimationEnd} onCloseAnimationEnd={onCloseAnimationEnd}
> >

View File

@ -1,4 +1,6 @@
.Composer { .Composer {
--base-height: 3.5rem;
align-items: flex-end; align-items: flex-end;
.select-mode-active + .middle-column-footer & { .select-mode-active + .middle-column-footer & {
@ -40,7 +42,7 @@
to { to {
/* stylelint-disable-next-line plugin/no-low-performance-animation-properties */ /* stylelint-disable-next-line plugin/no-low-performance-animation-properties */
width: 3.5rem; width: var(--base-height);
transform: scale(1); transform: scale(1);
} }
} }
@ -48,6 +50,8 @@
> .Button { > .Button {
flex-shrink: 0; flex-shrink: 0;
margin-left: 0.5rem; margin-left: 0.5rem;
width: var(--base-height);
height: var(--base-height);
&:not(.danger) { &:not(.danger) {
color: var(--color-composer-button); color: var(--color-composer-button);
@ -60,6 +64,7 @@
.icon-send, .icon-send,
.icon-schedule, .icon-schedule,
.icon-forward,
.icon-microphone-alt, .icon-microphone-alt,
.icon-check { .icon-check {
position: absolute; position: absolute;
@ -69,6 +74,7 @@
&:not(:active):not(:focus):not(:hover) { &:not(:active):not(:focus):not(:hover) {
.icon-send, .icon-send,
.icon-schedule, .icon-schedule,
.icon-forward,
.icon-check { .icon-check {
color: var(--color-primary); color: var(--color-primary);
} }
@ -79,6 +85,7 @@
&:not(:active):not(:focus) { &:not(:active):not(:focus) {
.icon-send, .icon-send,
.icon-schedule, .icon-schedule,
.icon-forward,
.icon-check { .icon-check {
color: var(--color-primary); color: var(--color-primary);
} }
@ -107,6 +114,7 @@
} }
.icon-microphone-alt, .icon-microphone-alt,
.icon-forward,
.icon-check, .icon-check,
.icon-schedule { .icon-schedule {
animation: hide-icon 0.4s forwards ease-out; animation: hide-icon 0.4s forwards ease-out;
@ -120,6 +128,7 @@
.icon-microphone-alt, .icon-microphone-alt,
.icon-check, .icon-check,
.icon-forward,
.icon-send { .icon-send {
animation: hide-icon 0.4s forwards ease-out; animation: hide-icon 0.4s forwards ease-out;
} }
@ -132,6 +141,7 @@
.icon-send, .icon-send,
.icon-check, .icon-check,
.icon-forward,
.icon-schedule { .icon-schedule {
animation: hide-icon 0.4s forwards ease-out; animation: hide-icon 0.4s forwards ease-out;
} }
@ -143,6 +153,24 @@
} }
.icon-send, .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-microphone-alt,
.icon-schedule { .icon-schedule {
animation: hide-icon 0.4s forwards ease-out; animation: hide-icon 0.4s forwards ease-out;
@ -172,6 +200,32 @@
animation: 0.25s ease-in-out forwards show-send-as-button; animation: 0.25s ease-in-out forwards show-send-as-button;
transform-origin: right; 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 { .mobile-symbol-menu-button {
@ -225,17 +279,20 @@
} }
} }
.composer-wrapper {
#message-compose {
flex-grow: 1; flex-grow: 1;
max-width: calc(100% - 4rem); max-width: calc(100% - 4rem);
background: var(--color-background); background: var(--color-background);
border-radius: var(--border-radius-messages); border-radius: var(--border-radius-default-small);
border-bottom-right-radius: 0;
box-shadow: 0 1px 2px var(--color-default-shadow);
position: relative; position: relative;
z-index: 1; 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 { .svg-appendix {
position: absolute; position: absolute;
bottom: -0.1875rem; bottom: -0.1875rem;
@ -274,8 +331,8 @@
> .Button { > .Button {
flex-shrink: 0; flex-shrink: 0;
background: none !important; background: none !important;
width: 3.5rem; width: var(--base-height, 3.5rem);
height: 3.5rem; height: var(--base-height, 3.5rem);
margin: 0; margin: 0;
padding: 0; padding: 0;
align-self: flex-end; align-self: flex-end;
@ -380,8 +437,8 @@
.recording-state { .recording-state {
display: inline-block; display: inline-block;
position: relative; position: relative;
line-height: 3.5rem; line-height: var(--base-height);
height: 3.5rem; height: var(--base-height);
padding: 0 3.125rem 0 1rem; padding: 0 3.125rem 0 1rem;
font-family: var(--font-family); font-family: var(--font-family);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
@ -424,7 +481,7 @@
} }
.input-scroller { .input-scroller {
min-height: 3.5rem; min-height: var(--base-height, 3.5rem);
max-height: 26rem; max-height: 26rem;
overflow: hidden; overflow: hidden;
@ -474,12 +531,13 @@
} }
#message-input-text, #message-input-text,
#story-input-text,
#caption-input-text { #caption-input-text {
position: relative; position: relative;
flex-grow: 1; flex-grow: 1;
.form-control { .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; overflow: hidden;
height: auto; height: auto;
line-height: 1.375; line-height: 1.375;
@ -495,8 +553,9 @@
caret-color: var(--color-text); caret-color: var(--color-text);
&.touched { &.touched {
& + .placeholder-text { & ~ .placeholder-text {
display: none; opacity: 0;
transform: translateX(1rem);
} }
} }
@ -520,6 +579,7 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
max-width: 100%; max-width: 100%;
transition: opacity 200ms ease-out, transform 200ms ease-out;
&.with-icon { &.with-icon {
display: inline-flex; display: inline-flex;
@ -544,8 +604,14 @@
} }
} }
&[dir="rtl"] .placeholder-text { &[dir="rtl"] {
right: 0; .placeholder-text {
right: 0;
}
.touched ~ .placeholder-text {
transform: translateX(-1rem);
}
} }
.text-entity-link { .text-entity-link {
@ -561,7 +627,7 @@
} }
.spoiler { .spoiler {
background-image: url("../../../assets/spoiler-dots-black.png"); background-image: url("../../assets/spoiler-dots-black.png");
background-size: auto min(100%, 1.125rem); background-size: auto min(100%, 1.125rem);
border-radius: 0.5rem; border-radius: 0.5rem;
padding: 0 0.3125rem 0.125rem 0.3125rem; padding: 0 0.3125rem 0.125rem 0.3125rem;
@ -570,7 +636,7 @@
} }
html.theme-dark & .spoiler { html.theme-dark & .spoiler {
background-image: url("../../../assets/spoiler-dots-white.png"); background-image: url("../../assets/spoiler-dots-white.png");
} }
.clone { .clone {
@ -590,7 +656,7 @@
.form-control { .form-control {
margin-bottom: 0; margin-bottom: 0;
line-height: 1.3125; 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; white-space: pre-wrap;
height: auto; height: auto;
@ -601,7 +667,7 @@
.forced-placeholder, .forced-placeholder,
.placeholder-text { .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) { @media (max-width: 600px) {
top: calc((2.875rem - var(--composer-text-size, 1rem) * 1.3125) / 2); top: calc((2.875rem - var(--composer-text-size, 1rem) * 1.3125) / 2);
@ -627,10 +693,11 @@
} }
} }
#story-input-text,
#caption-input-text { #caption-input-text {
--margin-for-scrollbar: 5rem; --margin-for-scrollbar: 2rem;
.input-scroller { .input-scroller {
min-height: 3.5rem; min-height: var(--base-height, 3.5rem);
max-height: 10rem; max-height: 10rem;
margin-right: calc((var(--margin-for-scrollbar) + 1rem) * -1); margin-right: calc((var(--margin-for-scrollbar) + 1rem) * -1);
@ -646,8 +713,8 @@
.placeholder-text { .placeholder-text {
top: auto; top: auto;
bottom: 1.125rem; bottom: 0.875rem;
left: 0.9375rem; left: 0.875rem;
} }
} }

View File

@ -153,6 +153,7 @@ const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
: Object.values(pickTruthy(customEmojisById!, recentCustomEmojiIds!)); : Object.values(pickTruthy(customEmojisById!, recentCustomEmojiIds!));
}, [customEmojisById, isStatusPicker, recentCustomEmojiIds, recentStatusEmojis]); }, [customEmojisById, isStatusPicker, recentCustomEmojiIds, recentStatusEmojis]);
const prefix = `${idPrefix}-custom-emoji`;
const { const {
activeSetIndex, activeSetIndex,
observeIntersectionForSet, observeIntersectionForSet,
@ -160,7 +161,7 @@ const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
observeIntersectionForShowingItems, observeIntersectionForShowingItems,
observeIntersectionForCovers, observeIntersectionForCovers,
selectStickerSet, selectStickerSet,
} = useStickerPickerObservers(containerRef, headerRef, idPrefix, isHidden); } = useStickerPickerObservers(containerRef, headerRef, prefix, isHidden);
const lang = useLang(); const lang = useLang();
@ -401,7 +402,7 @@ const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
stickerSet={stickerSet} stickerSet={stickerSet}
loadAndPlay={Boolean(canAnimate && loadAndPlay)} loadAndPlay={Boolean(canAnimate && loadAndPlay)}
index={i} index={i}
idPrefix={idPrefix} idPrefix={prefix}
observeIntersection={observeIntersectionForSet} observeIntersection={observeIntersectionForSet}
observeIntersectionForPlayingItems={observeIntersectionForPlayingItems} observeIntersectionForPlayingItems={observeIntersectionForPlayingItems}
observeIntersectionForShowingItems={observeIntersectionForShowingItems} observeIntersectionForShowingItems={observeIntersectionForShowingItems}

View File

@ -69,6 +69,12 @@
font-size: calc(var(--message-text-size, 1rem) - 0.125rem); 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 { .message-text {
overflow: hidden; overflow: hidden;
margin-inline-start: 0.5rem; margin-inline-start: 0.5rem;
@ -113,6 +119,10 @@
border-radius: 0; border-radius: 0;
} }
} }
&.with-message-color {
color: var(--accent-color);
}
} }
.embedded-action-message { .embedded-action-message {

View File

@ -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<OwnProps> = ({
story,
sender,
noUserColors,
isProtected,
observeIntersectionForLoading,
onClick,
}) => {
const { showNotification } = getActions();
const lang = useLang();
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(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 (
<div
ref={ref}
className={buildClassName(
'EmbeddedMessage',
sender && !noUserColors && `color-${getUserColorKey(sender)}`,
)}
onClick={handleClick}
onMouseDown={handleMouseDown}
>
{pictogramUrl && renderPictogram(pictogramUrl, isProtected)}
<div className={buildClassName('message-text', isExpiredStory && 'with-message-color')}>
<p dir="auto">
{isExpiredStory && (
<i className="icon icon-story-expired" aria-hidden />
)}
{lang(title)}
</p>
<div className="message-title" dir="auto">{renderText(senderTitle || NBSP)}</div>
</div>
</div>
);
};
function renderPictogram(
srcUrl: string,
isProtected?: boolean,
) {
const { width, height } = getPictogramDimensions();
return (
<div className="embedded-thumb">
<img
src={srcUrl}
width={width}
height={height}
alt=""
className="pictogram"
draggable={false}
/>
{isProtected && <span className="protector" />}
</div>
);
}
export default EmbeddedStory;

View File

@ -58,12 +58,12 @@ const InviteLink: FC<OwnProps> = ({
color="translucent" color="translucent"
className={isOpen ? 'active' : ''} className={isOpen ? 'active' : ''}
onClick={onTrigger} onClick={onTrigger}
ariaLabel="Actions" ariaLabel={lang('AccDescrOpenMenu2')}
> >
<i className="icon icon-more" /> <i className="icon icon-more" />
</Button> </Button>
); );
}, [isMobile]); }, [isMobile, lang]);
return ( return (
<div className="settings-item"> <div className="settings-item">

View File

@ -66,7 +66,7 @@ function MessageSummary({
function renderMessageText() { function renderMessageText() {
return ( return (
<MessageText <MessageText
message={message} messageOrStory={message}
translatedText={translatedText} translatedText={translatedText}
highlight={highlight} highlight={highlight}
isSimple isSimple

View File

@ -2,7 +2,7 @@ import React, {
memo, useMemo, useRef, memo, useMemo, useRef,
} from '../../lib/teact/teact'; } from '../../lib/teact/teact';
import type { ApiFormattedText, ApiMessage } from '../../api/types'; import type { ApiFormattedText, ApiMessage, ApiStory } from '../../api/types';
import type { ObserveFn } from '../../hooks/useIntersectionObserver'; import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import { ApiMessageEntityTypes } from '../../api/types'; import { ApiMessageEntityTypes } from '../../api/types';
@ -12,7 +12,7 @@ import { renderTextWithEntities } from './helpers/renderTextWithEntities';
import useSyncEffect from '../../hooks/useSyncEffect'; import useSyncEffect from '../../hooks/useSyncEffect';
interface OwnProps { interface OwnProps {
message: ApiMessage; messageOrStory: ApiMessage | ApiStory;
translatedText?: ApiFormattedText; translatedText?: ApiFormattedText;
isForAnimation?: boolean; isForAnimation?: boolean;
emojiSize?: number; emojiSize?: number;
@ -30,7 +30,7 @@ interface OwnProps {
const MIN_CUSTOM_EMOJIS_FOR_SHARED_CANVAS = 3; const MIN_CUSTOM_EMOJIS_FOR_SHARED_CANVAS = 3;
function MessageText({ function MessageText({
message, messageOrStory,
translatedText, translatedText,
isForAnimation, isForAnimation,
emojiSize, emojiSize,
@ -51,7 +51,7 @@ function MessageText({
const textCacheBusterRef = useRef(0); const textCacheBusterRef = useRef(0);
const formattedText = translatedText || extractMessageText(message, inChatList); const formattedText = translatedText || extractMessageText(messageOrStory, inChatList);
const adaptedFormattedText = isForAnimation && formattedText ? stripCustomEmoji(formattedText) : formattedText; const adaptedFormattedText = isForAnimation && formattedText ? stripCustomEmoji(formattedText) : formattedText;
const { text, entities } = adaptedFormattedText || {}; const { text, entities } = adaptedFormattedText || {};
@ -70,7 +70,7 @@ function MessageText({
}, [entities]) || 0; }, [entities]) || 0;
if (!text) { if (!text) {
const contentNotSupportedText = getMessageText(message); const contentNotSupportedText = getMessageText(messageOrStory);
return contentNotSupportedText ? [trimText(contentNotSupportedText, truncateLength)] : undefined as any; return contentNotSupportedText ? [trimText(contentNotSupportedText, truncateLength)] : undefined as any;
} }
@ -85,7 +85,7 @@ function MessageText({
highlight, highlight,
emojiSize, emojiSize,
shouldRenderAsHtml, shouldRenderAsHtml,
messageId: message.id, messageId: messageOrStory.id,
isSimple, isSimple,
isProtected, isProtected,
observeIntersectionForLoading, observeIntersectionForLoading,

View File

@ -37,6 +37,7 @@ type OwnProps = {
isSearchable?: boolean; isSearchable?: boolean;
isRoundCheckbox?: boolean; isRoundCheckbox?: boolean;
lockedIds?: string[]; lockedIds?: string[];
forceShowSelf?: boolean;
onSelectedIdsChange?: (ids: string[]) => void; onSelectedIdsChange?: (ids: string[]) => void;
onFilterChange?: (value: string) => void; onFilterChange?: (value: string) => void;
onDisabledClick?: (id: string) => void; onDisabledClick?: (id: string) => void;
@ -61,6 +62,7 @@ const Picker: FC<OwnProps> = ({
isSearchable, isSearchable,
isRoundCheckbox, isRoundCheckbox,
lockedIds, lockedIds,
forceShowSelf,
onSelectedIdsChange, onSelectedIdsChange,
onFilterChange, onFilterChange,
onDisabledClick, onDisabledClick,
@ -134,6 +136,7 @@ const Picker: FC<OwnProps> = ({
<PickerSelectedItem <PickerSelectedItem
chatOrUserId={id} chatOrUserId={id}
isMinimized={shouldMinimize && i < selectedIds.length - ALWAYS_FULL_ITEMS_COUNT} isMinimized={shouldMinimize && i < selectedIds.length - ALWAYS_FULL_ITEMS_COUNT}
forceShowSelf={forceShowSelf}
onClick={handleItemClick} onClick={handleItemClick}
clickArg={id} clickArg={id}
/> />
@ -189,7 +192,7 @@ const Picker: FC<OwnProps> = ({
> >
{!isRoundCheckbox ? renderCheckbox() : undefined} {!isRoundCheckbox ? renderCheckbox() : undefined}
{isUserId(id) ? ( {isUserId(id) ? (
<PrivateChatInfo userId={id} /> <PrivateChatInfo forceShowSelf={forceShowSelf} userId={id} />
) : ( ) : (
<GroupChatInfo chatId={id} /> <GroupChatInfo chatId={id} />
)} )}

View File

@ -20,15 +20,16 @@ type OwnProps = {
title?: string; title?: string;
isMinimized?: boolean; isMinimized?: boolean;
canClose?: boolean; canClose?: boolean;
onClick: (arg: any) => void; forceShowSelf?: boolean;
clickArg: any; clickArg: any;
className?: string; className?: string;
onClick: (arg: any) => void;
}; };
type StateProps = { type StateProps = {
chat?: ApiChat; chat?: ApiChat;
user?: ApiUser; user?: ApiUser;
currentUserId?: string; isSavedMessages?: boolean;
}; };
const PickerSelectedItem: FC<OwnProps & StateProps> = ({ const PickerSelectedItem: FC<OwnProps & StateProps> = ({
@ -40,7 +41,7 @@ const PickerSelectedItem: FC<OwnProps & StateProps> = ({
chat, chat,
user, user,
className, className,
currentUserId, isSavedMessages,
onClick, onClick,
}) => { }) => {
const lang = useLang(); const lang = useLang();
@ -61,13 +62,13 @@ const PickerSelectedItem: FC<OwnProps & StateProps> = ({
<Avatar <Avatar
peer={user || chat} peer={user || chat}
size="small" size="small"
isSavedMessages={user?.isSelf} isSavedMessages={isSavedMessages}
/> />
); );
const name = !chat || (user && !user.isSelf) const name = !chat || (user && !isSavedMessages)
? getUserFirstOrLastName(user) ? getUserFirstOrLastName(user)
: getChatTitle(lang, chat, chat.id === currentUserId); : getChatTitle(lang, chat, isSavedMessages);
titleText = name ? renderText(name) : undefined; titleText = name ? renderText(name) : undefined;
} }
@ -103,18 +104,19 @@ const PickerSelectedItem: FC<OwnProps & StateProps> = ({
}; };
export default memo(withGlobal<OwnProps>( export default memo(withGlobal<OwnProps>(
(global, { chatOrUserId }): StateProps => { (global, { chatOrUserId, forceShowSelf }): StateProps => {
if (!chatOrUserId) { if (!chatOrUserId) {
return {}; return {};
} }
const chat = chatOrUserId ? selectChat(global, chatOrUserId) : undefined; const chat = chatOrUserId ? selectChat(global, chatOrUserId) : undefined;
const user = isUserId(chatOrUserId) ? selectUser(global, chatOrUserId) : undefined; const user = isUserId(chatOrUserId) ? selectUser(global, chatOrUserId) : undefined;
const isSavedMessages = !forceShowSelf && user && user.isSelf;
return { return {
chat, chat,
user, user,
currentUserId: global.currentUserId, isSavedMessages,
}; };
}, },
)(PickerSelectedItem)); )(PickerSelectedItem));

View File

@ -23,6 +23,7 @@ import Avatar from './Avatar';
import TypingStatus from './TypingStatus'; import TypingStatus from './TypingStatus';
import DotAnimation from './DotAnimation'; import DotAnimation from './DotAnimation';
import FullNameTitle from './FullNameTitle'; import FullNameTitle from './FullNameTitle';
import RippleEffect from '../ui/RippleEffect';
type OwnProps = { type OwnProps = {
userId: string; userId: string;
@ -31,9 +32,11 @@ type OwnProps = {
forceShowSelf?: boolean; forceShowSelf?: boolean;
status?: string; status?: string;
statusIcon?: string; statusIcon?: string;
ripple?: boolean;
withDots?: boolean; withDots?: boolean;
withMediaViewer?: boolean; withMediaViewer?: boolean;
withUsername?: boolean; withUsername?: boolean;
withStory?: boolean;
withFullInfo?: boolean; withFullInfo?: boolean;
withUpdatingStatus?: boolean; withUpdatingStatus?: boolean;
noEmojiStatus?: boolean; noEmojiStatus?: boolean;
@ -59,6 +62,7 @@ const PrivateChatInfo: FC<OwnProps & StateProps> = ({
withDots, withDots,
withMediaViewer, withMediaViewer,
withUsername, withUsername,
withStory,
withFullInfo, withFullInfo,
withUpdatingStatus, withUpdatingStatus,
emojiStatusSize, emojiStatusSize,
@ -70,6 +74,7 @@ const PrivateChatInfo: FC<OwnProps & StateProps> = ({
isSavedMessages, isSavedMessages,
areMessagesLoaded, areMessagesLoaded,
adminMember, adminMember,
ripple,
}) => { }) => {
const { const {
loadFullUser, loadFullUser,
@ -177,12 +182,15 @@ const PrivateChatInfo: FC<OwnProps & StateProps> = ({
size={avatarSize} size={avatarSize}
peer={user} peer={user}
isSavedMessages={isSavedMessages} isSavedMessages={isSavedMessages}
withStory={withStory}
storyViewerMode="single-user"
onClick={withMediaViewer ? handleAvatarViewerOpen : undefined} onClick={withMediaViewer ? handleAvatarViewerOpen : undefined}
/> />
<div className="info"> <div className="info">
{renderNameTitle()} {renderNameTitle()}
{(status || (!isSavedMessages && !noStatusOrTyping)) && renderStatusOrTyping()} {(status || (!isSavedMessages && !noStatusOrTyping)) && renderStatusOrTyping()}
</div> </div>
{ripple && <RippleEffect />}
</div> </div>
); );
}; };

View File

@ -24,6 +24,7 @@ import useCurrentOrPrev from '../../hooks/useCurrentOrPrev';
export type OwnProps = { export type OwnProps = {
isOpen: boolean; isOpen: boolean;
searchPlaceholder: string; searchPlaceholder: string;
className?: string;
filter?: ApiChatType[]; filter?: ApiChatType[];
loadMore?: NoneToVoidFunction; loadMore?: NoneToVoidFunction;
onSelectRecipient: (peerId: string, threadId?: number) => void; onSelectRecipient: (peerId: string, threadId?: number) => void;
@ -49,6 +50,7 @@ const RecipientPicker: FC<OwnProps & StateProps> = ({
pinnedIds, pinnedIds,
contactIds, contactIds,
filter = API_CHAT_TYPES, filter = API_CHAT_TYPES,
className,
searchPlaceholder, searchPlaceholder,
loadMore, loadMore,
onSelectRecipient, onSelectRecipient,
@ -93,6 +95,7 @@ const RecipientPicker: FC<OwnProps & StateProps> = ({
return ( return (
<ChatOrUserPicker <ChatOrUserPicker
isOpen={isOpen} isOpen={isOpen}
className={className}
chatOrUserIds={renderingIds} chatOrUserIds={renderingIds}
chatsById={chatsById} chatsById={chatsById}
searchPlaceholder={searchPlaceholder} searchPlaceholder={searchPlaceholder}

View File

@ -6,6 +6,7 @@ import { getActions } from '../../global';
import type { ApiPhoto, ApiReportReason } from '../../api/types'; import type { ApiPhoto, ApiReportReason } from '../../api/types';
import buildClassName from '../../util/buildClassName';
import useLastCallback from '../../hooks/useLastCallback'; import useLastCallback from '../../hooks/useLastCallback';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
@ -16,10 +17,12 @@ import InputText from '../ui/InputText';
export type OwnProps = { export type OwnProps = {
isOpen: boolean; isOpen: boolean;
subject?: 'peer' | 'messages' | 'media'; subject?: 'peer' | 'messages' | 'media' | 'story';
chatId?: string; chatId?: string;
userId?: string;
photo?: ApiPhoto; photo?: ApiPhoto;
messageIds?: number[]; messageIds?: number[];
storyId?: number;
onClose: () => void; onClose: () => void;
onCloseAnimationEnd?: () => void; onCloseAnimationEnd?: () => void;
}; };
@ -28,8 +31,10 @@ const ReportModal: FC<OwnProps> = ({
isOpen, isOpen,
subject = 'messages', subject = 'messages',
chatId, chatId,
userId,
photo, photo,
messageIds, messageIds,
storyId,
onClose, onClose,
onCloseAnimationEnd, onCloseAnimationEnd,
}) => { }) => {
@ -37,6 +42,7 @@ const ReportModal: FC<OwnProps> = ({
reportMessages, reportMessages,
reportPeer, reportPeer,
reportProfilePhoto, reportProfilePhoto,
reportStory,
exitMessageSelectMode, exitMessageSelectMode,
} = getActions(); } = getActions();
@ -57,6 +63,10 @@ const ReportModal: FC<OwnProps> = ({
chatId, photo, reason: selectedReason, description, chatId, photo, reason: selectedReason, description,
}); });
break; break;
case 'story':
reportStory({
userId: userId!, storyId: storyId!, reason: selectedReason, description,
});
} }
onClose(); onClose();
}); });
@ -86,6 +96,7 @@ const ReportModal: FC<OwnProps> = ({
(subject === 'messages' && !messageIds) (subject === 'messages' && !messageIds)
|| (subject === 'peer' && !chatId) || (subject === 'peer' && !chatId)
|| (subject === 'media' && (!chatId || !photo)) || (subject === 'media' && (!chatId || !photo))
|| (subject === 'story' && (!storyId || !userId))
) { ) {
return undefined; return undefined;
} }
@ -100,7 +111,7 @@ const ReportModal: FC<OwnProps> = ({
onClose={onClose} onClose={onClose}
onEnter={isOpen ? handleReport : undefined} onEnter={isOpen ? handleReport : undefined}
onCloseAnimationEnd={onCloseAnimationEnd} onCloseAnimationEnd={onCloseAnimationEnd}
className="narrow" className={buildClassName('narrow', subject === 'story' && 'component-theme-dark')}
title={title} title={title}
> >
<RadioGroup <RadioGroup

View File

@ -41,7 +41,7 @@ type OwnProps = {
stickerSet: StickerSetOrReactionsSetOrRecent; stickerSet: StickerSetOrReactionsSetOrRecent;
loadAndPlay: boolean; loadAndPlay: boolean;
index: number; index: number;
idPrefix?: string; idPrefix: string;
isNearActive: boolean; isNearActive: boolean;
favoriteStickers?: ApiSticker[]; favoriteStickers?: ApiSticker[];
isSavedMessages?: boolean; isSavedMessages?: boolean;
@ -53,6 +53,7 @@ type OwnProps = {
withDefaultTopicIcon?: boolean; withDefaultTopicIcon?: boolean;
withDefaultStatusIcon?: boolean; withDefaultStatusIcon?: boolean;
isTranslucent?: boolean; isTranslucent?: boolean;
noContextMenus?: boolean;
observeIntersection?: ObserveFn; observeIntersection?: ObserveFn;
observeIntersectionForPlayingItems: ObserveFn; observeIntersectionForPlayingItems: ObserveFn;
observeIntersectionForShowingItems: ObserveFn; observeIntersectionForShowingItems: ObserveFn;
@ -90,6 +91,7 @@ const StickerSet: FC<OwnProps> = ({
selectedReactionIds, selectedReactionIds,
withDefaultStatusIcon, withDefaultStatusIcon,
isTranslucent, isTranslucent,
noContextMenus,
observeIntersection, observeIntersection,
observeIntersectionForPlayingItems, observeIntersectionForPlayingItems,
observeIntersectionForShowingItems, observeIntersectionForShowingItems,
@ -243,7 +245,7 @@ const StickerSet: FC<OwnProps> = ({
<div <div
ref={ref} ref={ref}
key={stickerSet.id} key={stickerSet.id}
id={`${idPrefix || 'sticker-set'}-${index}`} id={`${idPrefix}-${index}`}
className={ className={
buildClassName('symbol-set', isLocked && 'symbol-set-locked') buildClassName('symbol-set', isLocked && 'symbol-set-locked')
} }
@ -343,6 +345,7 @@ const StickerSet: FC<OwnProps> = ({
isSavedMessages={isSavedMessages} isSavedMessages={isSavedMessages}
isStatusPicker={isStatusPicker} isStatusPicker={isStatusPicker}
canViewSet canViewSet
noContextMenu={noContextMenus}
isCurrentUserPremium={isCurrentUserPremium} isCurrentUserPremium={isCurrentUserPremium}
sharedCanvasRef={canvasRef} sharedCanvasRef={canvasRef}
withTranslucentThumb={isTranslucent} withTranslucentThumb={isTranslucent}

View File

@ -8,7 +8,8 @@
html.theme-dark &, html.theme-dark &,
html.theme-light .ListItem.selected &, html.theme-light .ListItem.selected &,
.ActionMessage &, .ActionMessage &,
.MediaViewerFooter & { .MediaViewerFooter &,
#StoryViewer & {
background-image: url('../../../assets/spoiler-dots-white.png'); background-image: url('../../../assets/spoiler-dots-white.png');
} }

View File

@ -2,8 +2,13 @@
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
.chat-list { .left-header {
height: calc(100% - var(--header-height)); position: relative;
z-index: var(--z-left-header);
}
.left-header-shadow {
box-shadow: 0 2px 2px var(--color-light-shadow);
} }
.DropdownMenuFiller { .DropdownMenuFiller {
@ -32,4 +37,29 @@
.archived-chats-more-menu { .archived-chats-more-menu {
margin-left: auto !important; 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);
}
}
} }

View File

@ -20,6 +20,8 @@ import ChatList from './main/ChatList';
import ForumPanel from './main/ForumPanel'; import ForumPanel from './main/ForumPanel';
import DropdownMenu from '../ui/DropdownMenu'; import DropdownMenu from '../ui/DropdownMenu';
import MenuItem from '../ui/MenuItem'; import MenuItem from '../ui/MenuItem';
import StoryRibbon from '../story/StoryRibbon';
import StoryToggler from '../story/StoryToggler';
import './ArchivedChats.scss'; import './ArchivedChats.scss';
@ -27,6 +29,7 @@ export type OwnProps = {
isActive: boolean; isActive: boolean;
isForumPanelOpen?: boolean; isForumPanelOpen?: boolean;
archiveSettings: GlobalState['archiveSettings']; archiveSettings: GlobalState['archiveSettings'];
isStoryRibbonShown?: boolean;
onReset: () => void; onReset: () => void;
onTopicSearch: NoneToVoidFunction; onTopicSearch: NoneToVoidFunction;
onSettingsScreenSelect: (screen: SettingsScreens) => void; onSettingsScreenSelect: (screen: SettingsScreens) => void;
@ -38,6 +41,7 @@ const ArchivedChats: FC<OwnProps> = ({
isActive, isActive,
isForumPanelOpen, isForumPanelOpen,
archiveSettings, archiveSettings,
isStoryRibbonShown,
onReset, onReset,
onTopicSearch, onTopicSearch,
onSettingsScreenSelect, onSettingsScreenSelect,
@ -72,9 +76,15 @@ const ArchivedChats: FC<OwnProps> = ({
} = useForumPanelRender(isForumPanelOpen); } = useForumPanelRender(isForumPanelOpen);
const isForumPanelVisible = isForumPanelOpen && isAnimationStarted; const isForumPanelVisible = isForumPanelOpen && isAnimationStarted;
const {
shouldRender: shouldRenderStoryRibbon,
transitionClassNames: storyRibbonClassNames,
isClosing: isStoryRibbonClosing,
} = useShowTransition(isStoryRibbonShown, undefined, undefined, '');
return ( return (
<div className="ArchivedChats"> <div className="ArchivedChats">
<div className="left-header"> <div className={buildClassName('left-header', !shouldRenderStoryRibbon && 'left-header-shadow')}>
{lang.isRtl && <div className="DropdownMenuFiller" />} {lang.isRtl && <div className="DropdownMenuFiller" />}
<Button <Button
round round
@ -92,6 +102,9 @@ const ArchivedChats: FC<OwnProps> = ({
<i className="icon icon-arrow-left" /> <i className="icon icon-arrow-left" />
</Button> </Button>
{shouldRenderTitle && <h3 className={titleClassNames}>{lang('ArchivedChats')}</h3>} {shouldRenderTitle && <h3 className={titleClassNames}>{lang('ArchivedChats')}</h3>}
<div className="story-toggler-wrapper">
<StoryToggler canShow isArchived />
</div>
{archiveSettings.isHidden && ( {archiveSettings.isHidden && (
<DropdownMenu <DropdownMenu
className="archived-chats-more-menu" className="archived-chats-more-menu"
@ -104,15 +117,20 @@ const ArchivedChats: FC<OwnProps> = ({
</DropdownMenu> </DropdownMenu>
)} )}
</div> </div>
<ChatList <div className={buildClassName('chat-list-wrapper', storyRibbonClassNames)}>
folderType="archived" {shouldRenderStoryRibbon && (
isActive={isActive} <StoryRibbon isArchived className="left-header-shadow" isClosing={isStoryRibbonClosing} />
isForumPanelOpen={isForumPanelVisible} )}
onSettingsScreenSelect={onSettingsScreenSelect} <ChatList
onLeftColumnContentChange={onLeftColumnContentChange} folderType="archived"
foldersDispatch={foldersDispatch} isActive={isActive}
archiveSettings={archiveSettings} isForumPanelOpen={isForumPanelVisible}
/> onSettingsScreenSelect={onSettingsScreenSelect}
onLeftColumnContentChange={onLeftColumnContentChange}
foldersDispatch={foldersDispatch}
archiveSettings={archiveSettings}
/>
</div>
{shouldRenderForumPanel && ( {shouldRenderForumPanel && (
<ForumPanel <ForumPanel
isOpen={isForumPanelOpen} isOpen={isForumPanelOpen}

View File

@ -45,6 +45,7 @@ type StateProps = {
forumPanelChatId?: string; forumPanelChatId?: string;
isClosingSearch?: boolean; isClosingSearch?: boolean;
archiveSettings: GlobalState['archiveSettings']; archiveSettings: GlobalState['archiveSettings'];
isArchivedStoryRibbonShown?: boolean;
}; };
enum ContentType { enum ContentType {
@ -77,6 +78,7 @@ function LeftColumn({
forumPanelChatId, forumPanelChatId,
isClosingSearch, isClosingSearch,
archiveSettings, archiveSettings,
isArchivedStoryRibbonShown,
}: OwnProps & StateProps) { }: OwnProps & StateProps) {
const { const {
setGlobalSearchQuery, setGlobalSearchQuery,
@ -439,6 +441,7 @@ function LeftColumn({
onLeftColumnContentChange={setContent} onLeftColumnContentChange={setContent}
isForumPanelOpen={isForumPanelOpen} isForumPanelOpen={isForumPanelOpen}
archiveSettings={archiveSettings} archiveSettings={archiveSettings}
isStoryRibbonShown={isArchivedStoryRibbonShown}
/> />
); );
case ContentType.Settings: case ContentType.Settings:
@ -525,6 +528,9 @@ export default memo(withGlobal<OwnProps>(
activeChatFolder, activeChatFolder,
nextSettingsScreen, nextSettingsScreen,
nextFoldersAction, nextFoldersAction,
storyViewer: {
isArchivedRibbonShown,
},
} = tabState; } = tabState;
const { const {
currentUserId, currentUserId,
@ -555,6 +561,7 @@ export default memo(withGlobal<OwnProps>(
forumPanelChatId, forumPanelChatId,
isClosingSearch: tabState.globalSearch.isClosing, isClosingSearch: tabState.globalSearch.isClosing,
archiveSettings, archiveSettings,
isArchivedStoryRibbonShown: isArchivedRibbonShown,
}; };
}, },
)(LeftColumn)); )(LeftColumn));

View File

@ -2,6 +2,11 @@
--background-color: var(--color-background); --background-color: var(--color-background);
--thumbs-background: var(--background-color); --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-ios &,
body.is-macos & { body.is-macos & {
--color-text-meta: var(--color-text-meta-apple); --color-text-meta: var(--color-text-meta-apple);
@ -168,7 +173,7 @@
} }
background: var(--color-primary); background: var(--color-primary);
z-index: 1; z-index: var(--z-forum-indicator);
border-start-end-radius: var(--border-radius-default); border-start-end-radius: var(--border-radius-default);
border-end-end-radius: var(--border-radius-default); border-end-end-radius: var(--border-radius-default);
@ -191,7 +196,7 @@
} }
.ripple-container { .ripple-container {
z-index: 2; z-index: var(--z-ripple);
} }
.status { .status {
@ -199,15 +204,15 @@
align-self: stretch; align-self: stretch;
display: flex; display: flex;
align-items: center; align-items: center;
z-index: 1; z-index: var(--z-status);
background: var(--background-color); background-color: var(--background-color);
} }
.avatar-badge-wrapper { .avatar-badge-wrapper {
position: absolute; position: absolute;
bottom: 0; bottom: 0;
right: 0.5rem; right: 0.5rem;
z-index: 2; z-index: var(--z-badge);
--outline-color: var(--color-background); --outline-color: var(--color-background);
@ -312,8 +317,14 @@
} }
} }
.colon { .colon, .forward {
margin-inline-end: 0.25rem; margin-inline-end: 0.1875rem;
}
.forward {
font-size: 0.875rem;
display: inline-block;
transform: translateY(1px);
} }
.media-preview-spoiler { .media-preview-spoiler {

View File

@ -259,10 +259,13 @@ const Chat: FC<OwnProps & StateProps> = ({
onDragEnter={handleDragEnter} onDragEnter={handleDragEnter}
withPortalForMenu withPortalForMenu
> >
<div className="status"> <div className={buildClassName('status', 'status-clickable')}>
<Avatar <Avatar
peer={peer} peer={peer}
isSavedMessages={user?.isSelf} isSavedMessages={user?.isSelf}
withStory={user && !user?.isSelf}
withStoryGap={isAvatarOnlineShown}
storyViewerMode="single-user"
/> />
<div className="avatar-badge-wrapper"> <div className="avatar-badge-wrapper">
<div className={buildClassName('avatar-online', isAvatarOnlineShown && 'avatar-online-shown')} /> <div className={buildClassName('avatar-online', isAvatarOnlineShown && 'avatar-online-shown')} />

View File

@ -1,6 +1,6 @@
import type { FC } from '../../../lib/teact/teact'; import type { FC } from '../../../lib/teact/teact';
import React, { import React, {
memo, useEffect, useMemo, useRef, memo, useEffect, useLayoutEffect, useMemo, useRef, useState,
} from '../../../lib/teact/teact'; } from '../../../lib/teact/teact';
import { getActions, getGlobal, withGlobal } from '../../../global'; 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 { GlobalState } from '../../../global/types';
import type { TabWithProperties } from '../../ui/TabList'; 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 { IS_TOUCH_ENV } from '../../../util/windowEnvironment';
import { MEMO_EMPTY_ARRAY } from '../../../util/memo'; import { MEMO_EMPTY_ARRAY } from '../../../util/memo';
import { captureEvents, SwipeDirection } from '../../../util/captureEvents'; import { captureEvents, SwipeDirection } from '../../../util/captureEvents';
@ -28,6 +28,7 @@ import { useFolderManagerForUnreadCounters } from '../../../hooks/useFolderManag
import Transition from '../../ui/Transition'; import Transition from '../../ui/Transition';
import TabList from '../../ui/TabList'; import TabList from '../../ui/TabList';
import ChatList from './ChatList'; import ChatList from './ChatList';
import StoryRibbon from '../../story/StoryRibbon';
type OwnProps = { type OwnProps = {
onSettingsScreenSelect: (screen: SettingsScreens) => void; onSettingsScreenSelect: (screen: SettingsScreens) => void;
@ -48,11 +49,14 @@ type StateProps = {
maxChatLists: number; maxChatLists: number;
maxFolderInvites: number; maxFolderInvites: number;
hasArchivedChats?: boolean; hasArchivedChats?: boolean;
hasArchivedStories?: boolean;
archiveSettings: GlobalState['archiveSettings']; archiveSettings: GlobalState['archiveSettings'];
isStoryRibbonShown?: boolean;
}; };
const SAVED_MESSAGES_HOTKEY = '0'; const SAVED_MESSAGES_HOTKEY = '0';
const FIRST_FOLDER_INDEX = 0; const FIRST_FOLDER_INDEX = 0;
const STORY_RIBBON_APPEARANCE_DURATION_MS = 200 + ANIMATION_END_DELAY;
const ChatFolders: FC<OwnProps & StateProps> = ({ const ChatFolders: FC<OwnProps & StateProps> = ({
foldersDispatch, foldersDispatch,
@ -70,7 +74,9 @@ const ChatFolders: FC<OwnProps & StateProps> = ({
folderInvitesById, folderInvitesById,
maxFolderInvites, maxFolderInvites,
hasArchivedChats, hasArchivedChats,
hasArchivedStories,
archiveSettings, archiveSettings,
isStoryRibbonShown,
}) => { }) => {
const { const {
loadChatFolders, loadChatFolders,
@ -86,11 +92,34 @@ const ChatFolders: FC<OwnProps & StateProps> = ({
const transitionRef = useRef<HTMLDivElement>(null); const transitionRef = useRef<HTMLDivElement>(null);
const lang = useLang(); const lang = useLang();
const [isStoryRibbonAnimated, setIsStoryRibbonAnimated] = useState(false);
useEffect(() => { useEffect(() => {
loadChatFolders(); 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(() => { const allChatsFolder: ApiChatFolder = useMemo(() => {
return { return {
id: ALL_FOLDER_ID, id: ALL_FOLDER_ID,
@ -285,7 +314,7 @@ const ChatFolders: FC<OwnProps & StateProps> = ({
foldersDispatch={foldersDispatch} foldersDispatch={foldersDispatch}
onSettingsScreenSelect={onSettingsScreenSelect} onSettingsScreenSelect={onSettingsScreenSelect}
onLeftColumnContentChange={onLeftColumnContentChange} onLeftColumnContentChange={onLeftColumnContentChange}
canDisplayArchive={hasArchivedChats && !archiveSettings.isHidden} canDisplayArchive={(hasArchivedChats || hasArchivedStories) && !archiveSettings.isHidden}
archiveSettings={archiveSettings} archiveSettings={archiveSettings}
/> />
); );
@ -298,8 +327,11 @@ const ChatFolders: FC<OwnProps & StateProps> = ({
className={buildClassName( className={buildClassName(
'ChatFolders', 'ChatFolders',
shouldRenderFolders && shouldHideFolderTabs && 'ChatFolders--tabs-hidden', shouldRenderFolders && shouldHideFolderTabs && 'ChatFolders--tabs-hidden',
shouldRenderStoryRibbon && !isStoryRibbonAnimated && 'withStoryRibbon',
storyRibbonClassNames,
)} )}
> >
{shouldRenderStoryRibbon && <StoryRibbon isClosing={isStoryRibbonClosing} />}
{shouldRenderFolders ? ( {shouldRenderFolders ? (
<TabList <TabList
contextRootElementSelector="#LeftColumn" contextRootElementSelector="#LeftColumn"
@ -336,10 +368,16 @@ export default memo(withGlobal<OwnProps>(
archived, archived,
}, },
}, },
stories: {
orderedUserIds: {
archived: archivedStories,
},
},
currentUserId, currentUserId,
archiveSettings, archiveSettings,
} = global; } = global;
const { shouldSkipHistoryAnimations, activeChatFolder } = selectTabState(global); const { shouldSkipHistoryAnimations, activeChatFolder } = selectTabState(global);
const { storyViewer: { isRibbonShown: isStoryRibbonShown } } = selectTabState(global);
return { return {
chatFoldersById, chatFoldersById,
@ -349,10 +387,12 @@ export default memo(withGlobal<OwnProps>(
currentUserId, currentUserId,
shouldSkipHistoryAnimations, shouldSkipHistoryAnimations,
hasArchivedChats: Boolean(archived?.length), hasArchivedChats: Boolean(archived?.length),
hasArchivedStories: Boolean(archivedStories?.length),
maxFolders: selectCurrentLimit(global, 'dialogFilters'), maxFolders: selectCurrentLimit(global, 'dialogFilters'),
maxFolderInvites: selectCurrentLimit(global, 'chatlistInvites'), maxFolderInvites: selectCurrentLimit(global, 'chatlistInvites'),
maxChatLists: selectCurrentLimit(global, 'chatlistJoined'), maxChatLists: selectCurrentLimit(global, 'chatlistJoined'),
archiveSettings, archiveSettings,
isStoryRibbonShown,
}; };
}, },
)(ChatFolders)); )(ChatFolders));

View File

@ -27,12 +27,14 @@ import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver'
import { useHotkeys } from '../../../hooks/useHotkeys'; import { useHotkeys } from '../../../hooks/useHotkeys';
import useDebouncedCallback from '../../../hooks/useDebouncedCallback'; import useDebouncedCallback from '../../../hooks/useDebouncedCallback';
import useOrderDiff from './hooks/useOrderDiff'; import useOrderDiff from './hooks/useOrderDiff';
import useUserStoriesPolling from '../../../hooks/polling/useUserStoriesPolling';
import InfiniteScroll from '../../ui/InfiniteScroll'; import InfiniteScroll from '../../ui/InfiniteScroll';
import Loading from '../../ui/Loading'; import Loading from '../../ui/Loading';
import Chat from './Chat'; import Chat from './Chat';
import EmptyFolder from './EmptyFolder'; import EmptyFolder from './EmptyFolder';
import Archive from './Archive'; import Archive from './Archive';
import useTopOverscroll from '../../../hooks/scroll/useTopOverscroll';
type OwnProps = { type OwnProps = {
folderType: 'all' | 'archived' | 'folder'; folderType: 'all' | 'archived' | 'folder';
@ -41,6 +43,8 @@ type OwnProps = {
canDisplayArchive?: boolean; canDisplayArchive?: boolean;
archiveSettings: GlobalState['archiveSettings']; archiveSettings: GlobalState['archiveSettings'];
isForumPanelOpen?: boolean; isForumPanelOpen?: boolean;
isStoryRibbonShown?: boolean;
className?: string;
foldersDispatch: FolderEditDispatch; foldersDispatch: FolderEditDispatch;
onSettingsScreenSelect: (screen: SettingsScreens) => void; onSettingsScreenSelect: (screen: SettingsScreens) => void;
onLeftColumnContentChange: (content: LeftColumnContent) => void; onLeftColumnContentChange: (content: LeftColumnContent) => void;
@ -61,18 +65,25 @@ const ChatList: FC<OwnProps> = ({
onSettingsScreenSelect, onSettingsScreenSelect,
onLeftColumnContentChange, onLeftColumnContentChange,
}) => { }) => {
const { openChat, openNextChat, closeForumPanel } = getActions(); const {
openChat,
openNextChat,
closeForumPanel,
toggleStoryRibbon,
} = getActions();
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const shouldIgnoreDragRef = useRef(false); const shouldIgnoreDragRef = useRef(false);
const isArchived = folderType === 'archived';
const resolvedFolderId = ( 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 shouldDisplayArchive = folderType === 'all' && canDisplayArchive;
const orderedIds = useFolderManagerForOrderedIds(resolvedFolderId); const orderedIds = useFolderManagerForOrderedIds(resolvedFolderId);
useUserStoriesPolling(orderedIds);
const chatsHeight = (orderedIds?.length || 0) * CHAT_HEIGHT_PX; const chatsHeight = (orderedIds?.length || 0) * CHAT_HEIGHT_PX;
const archiveHeight = shouldDisplayArchive const archiveHeight = shouldDisplayArchive
@ -162,6 +173,16 @@ const ChatList: FC<OwnProps> = ({
shouldIgnoreDragRef.current = true; 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() { function renderChats() {
const viewportOffset = orderedIds!.indexOf(viewportIds![0]); const viewportOffset = orderedIds!.indexOf(viewportIds![0]);
@ -196,6 +217,7 @@ const ChatList: FC<OwnProps> = ({
itemSelector=".ListItem:not(.chat-item-archive)" itemSelector=".ListItem:not(.chat-item-archive)"
preloadBackwards={CHAT_LIST_SLICE} preloadBackwards={CHAT_LIST_SLICE}
withAbsolutePositioning withAbsolutePositioning
beforeChildren={renderedOverflowTrigger}
maxHeight={chatsHeight + archiveHeight} maxHeight={chatsHeight + archiveHeight}
onLoadMore={getMore} onLoadMore={getMore}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}

View File

@ -71,12 +71,11 @@ const ContactList: FC<OwnProps & StateProps> = ({
viewportIds.map((id) => ( viewportIds.map((id) => (
<ListItem <ListItem
key={id} key={id}
className="chat-item-clickable" className="chat-item-clickable contact-list-item"
// eslint-disable-next-line react/jsx-no-bind // eslint-disable-next-line react/jsx-no-bind
onClick={() => handleClick(id)} onClick={() => handleClick(id)}
ripple={!isMobile}
> >
<PrivateChatInfo userId={id} forceShowSelf avatarSize="large" /> <PrivateChatInfo userId={id} forceShowSelf avatarSize="large" withStory ripple={!isMobile} />
</ListItem> </ListItem>
)) ))
) : viewportIds && !viewportIds.length ? ( ) : viewportIds && !viewportIds.length ? (

View File

@ -16,6 +16,23 @@
flex-direction: column; flex-direction: column;
overflow: hidden; 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 { .tabs-placeholder {
height: 2.625rem; height: 2.625rem;
/* stylelint-disable-next-line plugin/no-low-performance-animation-properties */ /* stylelint-disable-next-line plugin/no-low-performance-animation-properties */

View File

@ -61,6 +61,7 @@ import ShowTransition from '../../ui/ShowTransition';
import ConnectionStatusOverlay from '../ConnectionStatusOverlay'; import ConnectionStatusOverlay from '../ConnectionStatusOverlay';
import StatusButton from './StatusButton'; import StatusButton from './StatusButton';
import Toggle from '../../ui/Toggle'; import Toggle from '../../ui/Toggle';
import StoryToggler from '../../story/StoryToggler';
import './LeftMainHeader.scss'; import './LeftMainHeader.scss';
@ -132,6 +133,7 @@ const LeftMainHeader: FC<OwnProps & StateProps> = ({
}) => { }) => {
const { const {
openChat, openChat,
openChatWithInfo,
setGlobalSearchDate, setGlobalSearchDate,
setSettingOption, setSettingOption,
setGlobalSearchChatId, setGlobalSearchChatId,
@ -173,6 +175,10 @@ const LeftMainHeader: FC<OwnProps & StateProps> = ({
} }
}); });
const handleOpenMyStories = useLastCallback(() => {
openChatWithInfo({ id: currentUserId, shouldReplaceHistory: true, profileTab: 'stories' });
});
useHotkeys(canSetPasscode ? { useHotkeys(canSetPasscode ? {
'Ctrl+Shift+L': handleLockScreenHotkey, 'Ctrl+Shift+L': handleLockScreenHotkey,
'Alt+Shift+L': handleLockScreenHotkey, 'Alt+Shift+L': handleLockScreenHotkey,
@ -317,6 +323,12 @@ const LeftMainHeader: FC<OwnProps & StateProps> = ({
> >
{lang('Contacts')} {lang('Contacts')}
</MenuItem> </MenuItem>
<MenuItem
icon="play-story"
onClick={handleOpenMyStories}
>
{lang('Settings.MyStories')}
</MenuItem>
<MenuItem <MenuItem
icon="settings" icon="settings"
onClick={onSelectSettings} onClick={onSelectSettings}
@ -453,6 +465,7 @@ const LeftMainHeader: FC<OwnProps & StateProps> = ({
onSpinnerClick={connectionStatusPosition === 'minimized' ? toggleConnectionStatus : undefined} onSpinnerClick={connectionStatusPosition === 'minimized' ? toggleConnectionStatus : undefined}
> >
{searchContent} {searchContent}
<StoryToggler canShow={!isSearchFocused && !selectedSearchDate && !globalSearchChatId} />
</SearchInput> </SearchInput>
{isCurrentUserPremium && <StatusButton />} {isCurrentUserPremium && <StatusButton />}
{hasPasscode && ( {hasPasscode && (

View File

@ -13,7 +13,7 @@
:global(body:not(.no-menu-blur)) & { :global(body:not(.no-menu-blur)) & {
--color-background: var(--color-background-compact-menu); --color-background: var(--color-background-compact-menu);
backdrop-filter: blur(10px); backdrop-filter: blur(25px);
} }
@media (max-width: 26rem) { @media (max-width: 26rem) {

View File

@ -151,6 +151,7 @@ export default function useChatListEntry({
<span className="colon">:</span> <span className="colon">:</span>
</> </>
)} )}
{lastMessage.forwardInfo && (<i className="icon icon-share-filled forward" />)}
{renderSummary(lang, lastMessage, observeIntersection, mediaBlobUrl || mediaThumbnail, isRoundVideo)} {renderSummary(lang, lastMessage, observeIntersection, mediaBlobUrl || mediaThumbnail, isRoundVideo)}
</p> </p>
); );

View File

@ -117,6 +117,10 @@
.ListItem.search-result { .ListItem.search-result {
.ChatInfo { .ChatInfo {
// Fix for overflow hidden and stories indicator
padding: 0.0625rem;
margin: -0.0625rem;
.handle { .handle {
unicode-bidi: plaintext; unicode-bidi: plaintext;
color: var(--color-primary); color: var(--color-primary);

View File

@ -85,7 +85,7 @@ const LeftSearchResultChat: FC<OwnProps & StateProps> = ({
buttonRef={buttonRef} buttonRef={buttonRef}
> >
{isUserId(chatId) ? ( {isUserId(chatId) ? (
<PrivateChatInfo userId={chatId} withUsername={withUsername} avatarSize="large" /> <PrivateChatInfo userId={chatId} withUsername={withUsername} withStory avatarSize="large" />
) : ( ) : (
<GroupChatInfo chatId={chatId} withUsername={withUsername} avatarSize="large" /> <GroupChatInfo chatId={chatId} withUsername={withUsername} avatarSize="large" />
)} )}

View File

@ -2,13 +2,15 @@ import type { FC } from '../../lib/teact/teact';
import React, { import React, {
memo, useCallback, useEffect, memo, useCallback, useEffect,
} from '../../lib/teact/teact'; } 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 useLang from '../../hooks/useLang';
import useFlag from '../../hooks/useFlag'; import useFlag from '../../hooks/useFlag';
import RecipientPicker from '../common/RecipientPicker'; import RecipientPicker from '../common/RecipientPicker';
import usePrevious from '../../hooks/usePrevious';
export type OwnProps = { export type OwnProps = {
isOpen: boolean; isOpen: boolean;
@ -17,22 +19,26 @@ export type OwnProps = {
interface StateProps { interface StateProps {
currentUserId?: string; currentUserId?: string;
isManyMessages?: boolean; isManyMessages?: boolean;
isStory?: boolean;
} }
const ForwardRecipientPicker: FC<OwnProps & StateProps> = ({ const ForwardRecipientPicker: FC<OwnProps & StateProps> = ({
isOpen, isOpen,
currentUserId, currentUserId,
isManyMessages, isManyMessages,
isStory,
}) => { }) => {
const { const {
setForwardChatOrTopic, setForwardChatOrTopic,
exitForwardMode, exitForwardMode,
forwardToSavedMessages, forwardToSavedMessages,
forwardStory,
showNotification, showNotification,
} = getActions(); } = getActions();
const lang = useLang(); const lang = useLang();
const renderingIsStory = usePrevious(isStory, true);
const [isShown, markIsShown, unmarkIsShown] = useFlag(); const [isShown, markIsShown, unmarkIsShown] = useFlag();
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
@ -41,17 +47,43 @@ const ForwardRecipientPicker: FC<OwnProps & StateProps> = ({
}, [isOpen, markIsShown]); }, [isOpen, markIsShown]);
const handleSelectRecipient = useCallback((recipientId: string, threadId?: number) => { const handleSelectRecipient = useCallback((recipientId: string, threadId?: number) => {
if (recipientId === currentUserId) { const isSelf = recipientId === currentUserId;
forwardToSavedMessages(); if (isStory) {
showNotification({ forwardStory({ toChatId: recipientId });
message: lang(isManyMessages 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.Many'
: 'Conversation.ForwardTooltip.SavedMessages.One'), : 'Conversation.ForwardTooltip.SavedMessages.One',
}); );
forwardToSavedMessages();
showNotification({ message });
} else { } else {
setForwardChatOrTopic({ chatId: recipientId, topicId: threadId }); setForwardChatOrTopic({ chatId: recipientId, topicId: threadId });
} }
}, [currentUserId, forwardToSavedMessages, isManyMessages, lang, setForwardChatOrTopic, showNotification]); }, [currentUserId, isManyMessages, isStory, lang]);
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
exitForwardMode(); exitForwardMode();
@ -64,6 +96,7 @@ const ForwardRecipientPicker: FC<OwnProps & StateProps> = ({
return ( return (
<RecipientPicker <RecipientPicker
isOpen={isOpen} isOpen={isOpen}
className={renderingIsStory ? 'component-theme-dark' : undefined}
searchPlaceholder={lang('ForwardTo')} searchPlaceholder={lang('ForwardTo')}
onSelectRecipient={handleSelectRecipient} onSelectRecipient={handleSelectRecipient}
onClose={handleClose} onClose={handleClose}
@ -73,8 +106,10 @@ const ForwardRecipientPicker: FC<OwnProps & StateProps> = ({
}; };
export default memo(withGlobal<OwnProps>((global): StateProps => { export default memo(withGlobal<OwnProps>((global): StateProps => {
const { messageIds, storyId } = selectTabState(global).forwardMessages;
return { return {
currentUserId: global.currentUserId, currentUserId: global.currentUserId,
isManyMessages: (selectTabState(global).forwardMessages.messageIds?.length || 0) > 1, isManyMessages: (messageIds?.length || 0) > 1,
isStory: Boolean(storyId),
}; };
})(ForwardRecipientPicker)); })(ForwardRecipientPicker));

View File

@ -40,10 +40,6 @@
background-color: var(--color-background); background-color: var(--color-background);
@media (max-width: 600px) {
height: calc(var(--vh, 1vh) * 100);
}
@media (min-width: 926px) { @media (min-width: 926px) {
--left-column-max-width: 40vw; --left-column-max-width: 40vw;
} }
@ -115,6 +111,7 @@
} }
@media (max-width: 600px) { @media (max-width: 600px) {
height: calc(var(--vh, 1vh) * 100);
max-width: none; max-width: none;
--left-column-max-width: calc(100vw - env(safe-area-inset-left)); --left-column-max-width: calc(100vw - env(safe-area-inset-left));
transform: translate3d(-20vw, 0, 0); transform: translate3d(-20vw, 0, 0);
@ -126,6 +123,18 @@
left: 0 !important; left: 0 !important;
width: 100vw !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;
}
} }
} }

View File

@ -36,6 +36,7 @@ import {
selectPerformanceSettingsValue, selectPerformanceSettingsValue,
selectCanAnimateInterface, selectCanAnimateInterface,
selectChatFolder, selectChatFolder,
selectIsStoryViewerOpen,
} from '../../global/selectors'; } from '../../global/selectors';
import { getUserFullName } from '../../global/helpers'; import { getUserFullName } from '../../global/helpers';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
@ -57,7 +58,6 @@ import useInterval from '../../hooks/useInterval';
import { useFullscreenStatus } from '../../hooks/useFullscreen'; import { useFullscreenStatus } from '../../hooks/useFullscreen';
import useAppLayout from '../../hooks/useAppLayout'; import useAppLayout from '../../hooks/useAppLayout';
import useTimeout from '../../hooks/useTimeout'; import useTimeout from '../../hooks/useTimeout';
import useFlag from '../../hooks/useFlag';
import StickerSetModal from '../common/StickerSetModal.async'; import StickerSetModal from '../common/StickerSetModal.async';
import UnreadCount from '../common/UnreadCounter'; import UnreadCount from '../common/UnreadCounter';
@ -94,6 +94,7 @@ import DraftRecipientPicker from './DraftRecipientPicker.async';
import AttachBotRecipientPicker from './AttachBotRecipientPicker.async'; import AttachBotRecipientPicker from './AttachBotRecipientPicker.async';
import ReactionPicker from '../middle/message/ReactionPicker.async'; import ReactionPicker from '../middle/message/ReactionPicker.async';
import ChatlistModal from '../modals/chatlist/ChatlistModal.async'; import ChatlistModal from '../modals/chatlist/ChatlistModal.async';
import StoryViewer from '../story/StoryViewer.async';
import './Main.scss'; import './Main.scss';
@ -108,6 +109,7 @@ type StateProps = {
isMiddleColumnOpen: boolean; isMiddleColumnOpen: boolean;
isRightColumnOpen: boolean; isRightColumnOpen: boolean;
isMediaViewerOpen: boolean; isMediaViewerOpen: boolean;
isStoryViewerOpen: boolean;
isForwardModalOpen: boolean; isForwardModalOpen: boolean;
hasNotifications: boolean; hasNotifications: boolean;
hasDialogs: boolean; hasDialogs: boolean;
@ -152,7 +154,6 @@ type StateProps = {
const APP_OUTDATED_TIMEOUT_MS = 5 * 60 * 1000; // 5 min const APP_OUTDATED_TIMEOUT_MS = 5 * 60 * 1000; // 5 min
const CALL_BUNDLE_LOADING_DELAY_MS = 5000; // 5 sec 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 // eslint-disable-next-line @typescript-eslint/naming-convention
let DEBUG_isLogged = false; let DEBUG_isLogged = false;
@ -163,6 +164,7 @@ const Main: FC<OwnProps & StateProps> = ({
isMiddleColumnOpen, isMiddleColumnOpen,
isRightColumnOpen, isRightColumnOpen,
isMediaViewerOpen, isMediaViewerOpen,
isStoryViewerOpen,
isForwardModalOpen, isForwardModalOpen,
hasNotifications, hasNotifications,
hasDialogs, hasDialogs,
@ -256,9 +258,6 @@ const Main: FC<OwnProps & StateProps> = ({
void loadBundle(Bundles.Calls); void loadBundle(Bundles.Calls);
}, CALL_BUNDLE_LOADING_DELAY_MS); }, 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 // eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
@ -503,7 +502,7 @@ const Main: FC<OwnProps & StateProps> = ({
// Online status and browser tab indicators // Online status and browser tab indicators
useBackgroundMode(handleBlur, handleFocus, !!IS_ELECTRON); useBackgroundMode(handleBlur, handleFocus, !!IS_ELECTRON);
useBeforeUnload(handleBlur); useBeforeUnload(handleBlur);
usePreventPinchZoomGesture(isMediaViewerOpen); usePreventPinchZoomGesture(isMediaViewerOpen || isStoryViewerOpen);
return ( return (
<div ref={containerRef} id="Main" className={className}> <div ref={containerRef} id="Main" className={className}>
@ -511,6 +510,7 @@ const Main: FC<OwnProps & StateProps> = ({
<MiddleColumn leftColumnRef={leftColumnRef} isMobile={isMobile} /> <MiddleColumn leftColumnRef={leftColumnRef} isMobile={isMobile} />
<RightColumn isMobile={isMobile} /> <RightColumn isMobile={isMobile} />
<MediaViewer isOpen={isMediaViewerOpen} /> <MediaViewer isOpen={isMediaViewerOpen} />
<StoryViewer isOpen={isStoryViewerOpen} />
<ForwardRecipientPicker isOpen={isForwardModalOpen} /> <ForwardRecipientPicker isOpen={isForwardModalOpen} />
<DraftRecipientPicker requestedDraft={requestedDraft} /> <DraftRecipientPicker requestedDraft={requestedDraft} />
<Notifications isOpen={hasNotifications} /> <Notifications isOpen={hasNotifications} />
@ -556,7 +556,7 @@ const Main: FC<OwnProps & StateProps> = ({
<PaymentModal isOpen={isPaymentModalOpen} onClose={closePaymentModal} /> <PaymentModal isOpen={isPaymentModalOpen} onClose={closePaymentModal} />
<ReceiptModal isOpen={isReceiptModalOpen} onClose={clearReceipt} /> <ReceiptModal isOpen={isReceiptModalOpen} onClose={clearReceipt} />
<DeleteFolderDialog folder={deleteFolderDialog} /> <DeleteFolderDialog folder={deleteFolderDialog} />
<ReactionPicker isOpen={isReactionPickerOpen} shouldLoad={shouldLoadReactionPicker} /> <ReactionPicker isOpen={isReactionPickerOpen} />
</div> </div>
); );
}; };
@ -616,6 +616,7 @@ export default memo(withGlobal<OwnProps>(
isMiddleColumnOpen: Boolean(chatId), isMiddleColumnOpen: Boolean(chatId),
isRightColumnOpen: selectIsRightColumnShown(global, isMobile), isRightColumnOpen: selectIsRightColumnShown(global, isMobile),
isMediaViewerOpen: selectIsMediaViewerOpen(global), isMediaViewerOpen: selectIsMediaViewerOpen(global),
isStoryViewerOpen: selectIsStoryViewerOpen(global),
isForwardModalOpen: selectIsForwardModalOpen(global), isForwardModalOpen: selectIsForwardModalOpen(global),
isReactionPickerOpen: selectIsReactionPickerOpen(global), isReactionPickerOpen: selectIsReactionPickerOpen(global),
hasNotifications: Boolean(notifications.length), hasNotifications: Boolean(notifications.length),

View File

@ -8,8 +8,8 @@ import type { ApiDimensions } from '../../api/types';
import useLastCallback from '../../hooks/useLastCallback'; import useLastCallback from '../../hooks/useLastCallback';
import useSignal from '../../hooks/useSignal'; import useSignal from '../../hooks/useSignal';
import useCurrentTimeSignal from '../../hooks/useCurrentTimeSignal';
import { useThrottledSignal } from '../../hooks/useAsyncResolvers'; import { useThrottledSignal } from '../../hooks/useAsyncResolvers';
import useCurrentTimeSignal from './hooks/useCurrentTimeSignal';
import useVideoWaitingSignal from './hooks/useVideoWaitingSignal'; import useVideoWaitingSignal from './hooks/useVideoWaitingSignal';
import { captureEvents } from '../../util/captureEvents'; import { captureEvents } from '../../util/captureEvents';

View File

@ -18,7 +18,7 @@ import usePictureInPicture from '../../hooks/usePictureInPicture';
import useShowTransition from '../../hooks/useShowTransition'; import useShowTransition from '../../hooks/useShowTransition';
import useVideoCleanup from '../../hooks/useVideoCleanup'; import useVideoCleanup from '../../hooks/useVideoCleanup';
import useAppLayout from '../../hooks/useAppLayout'; import useAppLayout from '../../hooks/useAppLayout';
import useCurrentTimeSignal from './hooks/useCurrentTimeSignal'; import useCurrentTimeSignal from '../../hooks/useCurrentTimeSignal';
import useControlsSignal from './hooks/useControlsSignal'; import useControlsSignal from './hooks/useControlsSignal';
import useVideoWaitingSignal from './hooks/useVideoWaitingSignal'; import useVideoWaitingSignal from './hooks/useVideoWaitingSignal';
import useUnsupportedMedia from '../../hooks/media/useUnsupportedMedia'; import useUnsupportedMedia from '../../hooks/media/useUnsupportedMedia';

View File

@ -12,7 +12,7 @@ import useFlag from '../../hooks/useFlag';
import useAppLayout from '../../hooks/useAppLayout'; import useAppLayout from '../../hooks/useAppLayout';
import useDerivedState from '../../hooks/useDerivedState'; import useDerivedState from '../../hooks/useDerivedState';
import useSignal from '../../hooks/useSignal'; import useSignal from '../../hooks/useSignal';
import useCurrentTimeSignal from './hooks/useCurrentTimeSignal'; import useCurrentTimeSignal from '../../hooks/useCurrentTimeSignal';
import useControlsSignal from './hooks/useControlsSignal'; import useControlsSignal from './hooks/useControlsSignal';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';

View File

@ -123,6 +123,7 @@ type StateProps = {
const MESSAGE_REACTIONS_POLLING_INTERVAL = 15 * 1000; const MESSAGE_REACTIONS_POLLING_INTERVAL = 15 * 1000;
const MESSAGE_COMMENTS_POLLING_INTERVAL = 15 * 1000; const MESSAGE_COMMENTS_POLLING_INTERVAL = 15 * 1000;
const MESSAGE_STORY_POLLING_INTERVAL = 5 * 60 * 1000;
const BOTTOM_THRESHOLD = 50; const BOTTOM_THRESHOLD = 50;
const UNREAD_DIVIDER_TOP = 10; const UNREAD_DIVIDER_TOP = 10;
const UNREAD_DIVIDER_TOP_WITH_TOOLS = 60; const UNREAD_DIVIDER_TOP_WITH_TOOLS = 60;
@ -173,7 +174,7 @@ const MessageList: FC<OwnProps & StateProps> = ({
}) => { }) => {
const { const {
loadViewportMessages, setScrollOffset, loadSponsoredMessages, loadMessageReactions, copyMessagesByIds, loadViewportMessages, setScrollOffset, loadSponsoredMessages, loadMessageReactions, copyMessagesByIds,
loadMessageViews, loadMessageViews, loadUserStoriesByIds,
} = getActions(); } = getActions();
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
@ -259,6 +260,28 @@ const MessageList: FC<OwnProps & StateProps> = ({
loadMessageReactions({ chatId, ids }); loadMessageReactions({ chatId, ids });
}, MESSAGE_REACTIONS_POLLING_INTERVAL); }, 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<string, number[]>);
Object.entries(storiesByUserIds).forEach(([userId, storyIds]) => {
loadUserStoriesByIds({ userId, storyIds });
});
}, MESSAGE_STORY_POLLING_INTERVAL);
useInterval(() => { useInterval(() => {
if (!messageIds || !messagesById || threadId !== MAIN_THREAD_ID || type === 'scheduled') { if (!messageIds || !messagesById || threadId !== MAIN_THREAD_ID || type === 'scheduled') {
return; return;

View File

@ -35,7 +35,7 @@
} }
.Composer { .Composer {
#message-compose { .composer-wrapper {
transform: scaleX(1) translateX(0); transform: scaleX(1) translateX(0);
transition: transform var(--select-transition), border-bottom-right-radius var(--select-transition); transition: transform var(--select-transition), border-bottom-right-radius var(--select-transition);
@ -83,7 +83,7 @@
height: 0; height: 0;
} }
#message-compose { .composer-wrapper {
transform: scaleX(var(--composer-hidden-scale, 1)) translateX(var(--composer-translate-x, 0)); transform: scaleX(var(--composer-hidden-scale, 1)) translateX(var(--composer-translate-x, 0));
border-bottom-right-radius: var(--border-radius-messages); border-bottom-right-radius: var(--border-radius-messages);

View File

@ -21,6 +21,8 @@ import {
GENERAL_TOPIC_ID, GENERAL_TOPIC_ID,
TMP_CHAT_ID, TMP_CHAT_ID,
MAX_SCREEN_WIDTH_FOR_EXPAND_PINNED_MESSAGES, MAX_SCREEN_WIDTH_FOR_EXPAND_PINNED_MESSAGES,
EDITABLE_INPUT_ID,
EDITABLE_INPUT_CSS_SELECTOR,
} from '../../config'; } from '../../config';
import { import {
IS_ANDROID, IS_IOS, IS_TRANSLATION_SUPPORTED, MASK_IMAGE_DISABLED, IS_ANDROID, IS_IOS, IS_TRANSLATION_SUPPORTED, MASK_IMAGE_DISABLED,
@ -78,7 +80,6 @@ import Transition from '../ui/Transition';
import MiddleHeader from './MiddleHeader'; import MiddleHeader from './MiddleHeader';
import MessageList from './MessageList'; import MessageList from './MessageList';
import FloatingActionButtons from './FloatingActionButtons'; import FloatingActionButtons from './FloatingActionButtons';
import Composer from './composer/Composer';
import Button from '../ui/Button'; import Button from '../ui/Button';
import MobileSearch from './MobileSearch.async'; import MobileSearch from './MobileSearch.async';
import MessageSelectToolbar from './MessageSelectToolbar.async'; import MessageSelectToolbar from './MessageSelectToolbar.async';
@ -88,6 +89,7 @@ import EmojiInteractionAnimation from './EmojiInteractionAnimation.async';
import ReactorListModal from './ReactorListModal.async'; import ReactorListModal from './ReactorListModal.async';
import GiftPremiumModal from '../main/premium/GiftPremiumModal.async'; import GiftPremiumModal from '../main/premium/GiftPremiumModal.async';
import ChatLanguageModal from './ChatLanguageModal.async'; import ChatLanguageModal from './ChatLanguageModal.async';
import Composer from '../common/Composer';
import './MiddleColumn.scss'; import './MiddleColumn.scss';
@ -529,6 +531,7 @@ function MiddleColumn({
<div className={footerClassName}> <div className={footerClassName}>
{renderingCanPost && ( {renderingCanPost && (
<Composer <Composer
type="messageList"
chatId={renderingChatId!} chatId={renderingChatId!}
threadId={renderingThreadId!} threadId={renderingThreadId!}
messageListType={renderingMessageListType!} messageListType={renderingMessageListType!}
@ -536,6 +539,9 @@ function MiddleColumn({
onDropHide={handleHideDropArea} onDropHide={handleHideDropArea}
isReady={isReady} isReady={isReady}
isMobile={isMobile} isMobile={isMobile}
editableInputId={EDITABLE_INPUT_ID}
editableInputCssSelector={EDITABLE_INPUT_CSS_SELECTOR}
inputId="message-input-text"
/> />
)} )}
{isPinnedMessageList && canUnpin && ( {isPinnedMessageList && canUnpin && (

View File

@ -31,10 +31,12 @@
background: var(--color-background); background: var(--color-background);
position: relative; position: relative;
z-index: var(--z-middle-header); z-index: var(--z-middle-header);
padding-top: 0.5rem; padding-top: 0.25rem;
padding-bottom: 0.5rem; padding-bottom: 0.25rem;
padding-left: max(1.5rem, env(safe-area-inset-left)); padding-left: max(1.4375rem, env(safe-area-inset-left));
padding-right: max(0.8125rem, env(safe-area-inset-right)); padding-right: max(0.8125rem, env(safe-area-inset-right));
flex-shrink: 0;
height: 3.5rem;
@media (max-width: 600px) { @media (max-width: 600px) {
position: relative; position: relative;
@ -214,6 +216,8 @@
cursor: var(--custom-cursor, pointer); cursor: var(--custom-cursor, pointer);
display: flex; display: flex;
align-items: center; align-items: center;
// Space for unread story circle
padding: 0.0625rem 0 0.0625rem 0.0625rem;
@media (max-width: 600px) { @media (max-width: 600px) {
user-select: none; user-select: none;
@ -266,6 +270,11 @@
.custom-emoji { .custom-emoji {
color: var(--color-primary); color: var(--color-primary);
} }
.story-circle {
max-width: 2.625rem !important;
max-height: 2.625rem !important;
}
} }
.Avatar, .topic-header-icon { .Avatar, .topic-header-icon {

View File

@ -367,6 +367,7 @@ const MiddleHeader: FC<OwnProps & StateProps> = ({
withDots={Boolean(connectionStatusText)} withDots={Boolean(connectionStatusText)}
withFullInfo withFullInfo
withMediaViewer withMediaViewer
withStory={!isChatWithSelf}
withUpdatingStatus withUpdatingStatus
emojiStatusSize={EMOJI_STATUS_SIZE} emojiStatusSize={EMOJI_STATUS_SIZE}
noRtl noRtl

View File

@ -40,11 +40,13 @@ export type OwnProps = {
canSendDocuments: boolean; canSendDocuments: boolean;
canSendAudios: boolean; canSendAudios: boolean;
isScheduled?: boolean; isScheduled?: boolean;
attachBots: GlobalState['attachMenu']['bots']; attachBots?: GlobalState['attachMenu']['bots'];
peerType?: ApiAttachMenuPeerType; peerType?: ApiAttachMenuPeerType;
shouldCollectDebugLogs?: boolean; shouldCollectDebugLogs?: boolean;
onFileSelect: (files: File[], shouldSuggestCompression?: boolean) => void; onFileSelect: (files: File[], shouldSuggestCompression?: boolean) => void;
onPollCreate: () => void; onPollCreate: NoneToVoidFunction;
onMenuOpen: NoneToVoidFunction;
onMenuClose: NoneToVoidFunction;
theme: ISettings['theme']; theme: ISettings['theme'];
}; };
@ -62,6 +64,8 @@ const AttachMenu: FC<OwnProps> = ({
peerType, peerType,
isScheduled, isScheduled,
onFileSelect, onFileSelect,
onMenuOpen,
onMenuClose,
onPollCreate, onPollCreate,
theme, theme,
shouldCollectDebugLogs, shouldCollectDebugLogs,
@ -73,12 +77,22 @@ const AttachMenu: FC<OwnProps> = ({
const canSendVideoOrPhoto = canSendPhotos || canSendVideos; const canSendVideoOrPhoto = canSendPhotos || canSendVideos;
const [isAttachmentBotMenuOpen, markAttachmentBotMenuOpen, unmarkAttachmentBotMenuOpen] = useFlag(); const [isAttachmentBotMenuOpen, markAttachmentBotMenuOpen, unmarkAttachmentBotMenuOpen] = useFlag();
const isMenuOpen = isAttachMenuOpen || isAttachmentBotMenuOpen;
useEffect(() => { useEffect(() => {
if (isAttachMenuOpen) { if (isAttachMenuOpen) {
markMouseInside(); markMouseInside();
} }
}, [isAttachMenuOpen, markMouseInside]); }, [isAttachMenuOpen, markMouseInside]);
useEffect(() => {
if (isMenuOpen) {
onMenuOpen();
} else {
onMenuClose();
}
}, [isMenuOpen, onMenuClose, onMenuOpen]);
const handleToggleAttachMenu = useLastCallback(() => { const handleToggleAttachMenu = useLastCallback(() => {
if (isAttachMenuOpen) { if (isAttachMenuOpen) {
closeAttachMenu(); closeAttachMenu();
@ -118,13 +132,15 @@ const AttachMenu: FC<OwnProps> = ({
}); });
const bots = useMemo(() => { const bots = useMemo(() => {
return Object.values(attachBots).filter((bot) => { return attachBots
if (!peerType) return false; ? Object.values(attachBots).filter((bot) => {
if (peerType === 'bots' && bot.id === chatId && bot.peerTypes.includes('self')) { if (!peerType) return false;
return true; if (peerType === 'bots' && bot.id === chatId && bot.peerTypes.includes('self')) {
} return true;
return bot.peerTypes.includes(peerType); }
}); return bot.peerTypes.includes(peerType);
})
: undefined;
}, [attachBots, chatId, peerType]); }, [attachBots, chatId, peerType]);
const lang = useLang(); const lang = useLang();
@ -149,7 +165,7 @@ const AttachMenu: FC<OwnProps> = ({
</ResponsiveHoverButton> </ResponsiveHoverButton>
<Menu <Menu
id="attach-menu-controls" id="attach-menu-controls"
isOpen={isAttachMenuOpen || isAttachmentBotMenuOpen} isOpen={isMenuOpen}
autoClose autoClose
positionX="right" positionX="right"
positionY="bottom" positionY="bottom"
@ -193,7 +209,7 @@ const AttachMenu: FC<OwnProps> = ({
<MenuItem icon="poll" onClick={onPollCreate}>{lang('Poll')}</MenuItem> <MenuItem icon="poll" onClick={onPollCreate}>{lang('Poll')}</MenuItem>
)} )}
{canAttachMedia && !isScheduled && bots.map((bot) => ( {canAttachMedia && !isScheduled && bots?.map((bot) => (
<AttachBotItem <AttachBotItem
bot={bot} bot={bot}
chatId={chatId} chatId={chatId}

View File

@ -64,11 +64,13 @@ export type OwnProps = {
getHtml: Signal<string>; getHtml: Signal<string>;
canShowCustomSendMenu?: boolean; canShowCustomSendMenu?: boolean;
isReady: boolean; isReady: boolean;
isForMessage?: boolean;
shouldSchedule?: boolean; shouldSchedule?: boolean;
shouldSuggestCompression?: boolean; shouldSuggestCompression?: boolean;
shouldForceCompression?: boolean; shouldForceCompression?: boolean;
shouldForceAsFile?: boolean; shouldForceAsFile?: boolean;
isForCurrentMessageList?: boolean; isForCurrentMessageList?: boolean;
forceDarkTheme?: boolean;
onCaptionUpdate: (html: string) => void; onCaptionUpdate: (html: string) => void;
onSend: (sendCompressed: boolean, sendGrouped: boolean) => void; onSend: (sendCompressed: boolean, sendGrouped: boolean) => void;
onFileAppend: (files: File[], isSpoiler?: boolean) => void; onFileAppend: (files: File[], isSpoiler?: boolean) => void;
@ -112,6 +114,7 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
recentEmojis, recentEmojis,
baseEmojiKeywords, baseEmojiKeywords,
emojiKeywords, emojiKeywords,
isForMessage,
shouldSchedule, shouldSchedule,
shouldSuggestCustomEmoji, shouldSuggestCustomEmoji,
customEmojiForEmoji, customEmojiForEmoji,
@ -120,6 +123,7 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
shouldForceCompression, shouldForceCompression,
shouldForceAsFile, shouldForceAsFile,
isForCurrentMessageList, isForCurrentMessageList,
forceDarkTheme,
onAttachmentsUpdate, onAttachmentsUpdate,
onCaptionUpdate, onCaptionUpdate,
onSend, onSend,
@ -194,7 +198,7 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
insertEmoji, insertEmoji,
closeEmojiTooltip, closeEmojiTooltip,
} = useEmojiTooltip( } = useEmojiTooltip(
Boolean(isReady && isForCurrentMessageList && renderingIsOpen), Boolean(isReady && (isForCurrentMessageList || !isForMessage) && renderingIsOpen),
getHtml, getHtml,
onCaptionUpdate, onCaptionUpdate,
EDITABLE_INPUT_MODAL_ID, EDITABLE_INPUT_MODAL_ID,
@ -208,7 +212,7 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
insertCustomEmoji, insertCustomEmoji,
closeCustomEmojiTooltip, closeCustomEmojiTooltip,
} = useCustomEmojiTooltip( } = useCustomEmojiTooltip(
Boolean(isReady && isForCurrentMessageList && renderingIsOpen && shouldSuggestCustomEmoji), Boolean(isReady && (isForCurrentMessageList || !isForMessage) && renderingIsOpen && shouldSuggestCustomEmoji),
getHtml, getHtml,
onCaptionUpdate, onCaptionUpdate,
getSelectionRange, getSelectionRange,
@ -256,7 +260,7 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
const sendAttachments = useLastCallback((isSilent?: boolean, shouldSendScheduled?: boolean) => { const sendAttachments = useLastCallback((isSilent?: boolean, shouldSendScheduled?: boolean) => {
if (isOpen) { if (isOpen) {
const send = (shouldSchedule || shouldSendScheduled) ? onSendScheduled const send = ((shouldSchedule || shouldSendScheduled) && isForMessage) ? onSendScheduled
: isSilent ? onSendSilent : onSend; : isSilent ? onSendSilent : onSend;
send(isSendingCompressed, shouldSendGrouped); send(isSendingCompressed, shouldSendGrouped);
updateAttachmentSettings({ updateAttachmentSettings({
@ -508,6 +512,7 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
!areAttachmentsNotScrolled && styles.headerBorder, !areAttachmentsNotScrolled && styles.headerBorder,
isMobile && styles.mobile, isMobile && styles.mobile,
isSymbolMenuOpen && styles.symbolMenuOpen, isSymbolMenuOpen && styles.symbolMenuOpen,
forceDarkTheme && 'component-theme-dark',
)} )}
noBackdropClose noBackdropClose
> >
@ -586,6 +591,7 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
isAttachmentModal isAttachmentModal
canSendPlainText canSendPlainText
className="attachment-modal-symbol-menu with-menu-transitions" className="attachment-modal-symbol-menu with-menu-transitions"
idPrefix="attachment"
/> />
<MessageInput <MessageInput
ref={inputRef} ref={inputRef}
@ -593,6 +599,7 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
chatId={chatId} chatId={chatId}
threadId={threadId} threadId={threadId}
isAttachmentModalInput isAttachmentModalInput
customEmojiPrefix="attachment"
isReady={isReady} isReady={isReady}
isActive={isOpen} isActive={isOpen}
getHtml={getHtml} getHtml={getHtml}
@ -618,6 +625,7 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
{canShowCustomSendMenu && ( {canShowCustomSendMenu && (
<CustomSendMenu <CustomSendMenu
isOpen={isCustomSendMenuOpen} isOpen={isCustomSendMenuOpen}
canSchedule={isForMessage}
onSendSilent={!isChatWithSelf ? handleSendSilent : undefined} onSendSilent={!isChatWithSelf ? handleSendSilent : undefined}
onSendSchedule={handleScheduleClick} onSendSchedule={handleScheduleClick}
onClose={handleContextMenuClose} onClose={handleContextMenuClose}

View File

@ -52,6 +52,7 @@ type StateProps = {
noCaptions?: boolean; noCaptions?: boolean;
forwardsHaveCaptions?: boolean; forwardsHaveCaptions?: boolean;
isCurrentUserPremium?: boolean; isCurrentUserPremium?: boolean;
isContextMenuDisabled?: boolean;
}; };
type OwnProps = { type OwnProps = {
@ -73,6 +74,7 @@ const ComposerEmbeddedMessage: FC<OwnProps & StateProps> = ({
forwardsHaveCaptions, forwardsHaveCaptions,
shouldForceShowEditing, shouldForceShowEditing,
isCurrentUserPremium, isCurrentUserPremium,
isContextMenuDisabled,
onClear, onClear,
}) => { }) => {
const { const {
@ -201,7 +203,7 @@ const ComposerEmbeddedMessage: FC<OwnProps & StateProps> = ({
customText={customText} customText={customText}
title={editingId ? lang('EditMessage') : noAuthors ? lang('HiddenSendersNameDescription') : undefined} title={editingId ? lang('EditMessage') : noAuthors ? lang('HiddenSendersNameDescription') : undefined}
onClick={handleMessageClick} onClick={handleMessageClick}
hasContextMenu={isForwarding} hasContextMenu={isForwarding && !isContextMenuDisabled}
/> />
<Button <Button
className="embedded-cancel" className="embedded-cancel"
@ -213,7 +215,7 @@ const ComposerEmbeddedMessage: FC<OwnProps & StateProps> = ({
> >
<i className="icon icon-close" /> <i className="icon icon-close" />
</Button> </Button>
{isForwarding && ( {isForwarding && !isContextMenuDisabled && (
<Menu <Menu
isOpen={isContextMenuOpen} isOpen={isContextMenuOpen}
transformOriginX={transformOriginX} transformOriginX={transformOriginX}
@ -338,6 +340,9 @@ export default memo(withGlobal<OwnProps>(
forward?.content.text && Object.keys(forward.content).length > 1 forward?.content.text && Object.keys(forward.content).length > 1
)); ));
const isContextMenuDisabled = isForwarding && forwardMessageIds!.length === 1
&& Boolean(message?.content.storyData);
return { return {
replyingToId, replyingToId,
editingId, editingId,
@ -349,6 +354,7 @@ export default memo(withGlobal<OwnProps>(
noCaptions, noCaptions,
forwardsHaveCaptions, forwardsHaveCaptions,
isCurrentUserPremium: selectIsCurrentUserPremium(global), isCurrentUserPremium: selectIsCurrentUserPremium(global),
isContextMenuDisabled,
}; };
}, },
)(ComposerEmbeddedMessage)); )(ComposerEmbeddedMessage));

View File

@ -17,6 +17,7 @@ export type OwnProps = {
isOpen: boolean; isOpen: boolean;
isOpenToBottom?: boolean; isOpenToBottom?: boolean;
isSavedMessages?: boolean; isSavedMessages?: boolean;
canSchedule?: boolean;
canScheduleUntilOnline?: boolean; canScheduleUntilOnline?: boolean;
onSendSilent?: NoneToVoidFunction; onSendSilent?: NoneToVoidFunction;
onSendSchedule?: NoneToVoidFunction; onSendSchedule?: NoneToVoidFunction;
@ -29,6 +30,7 @@ const CustomSendMenu: FC<OwnProps> = ({
isOpen, isOpen,
isOpenToBottom = false, isOpenToBottom = false,
isSavedMessages, isSavedMessages,
canSchedule,
canScheduleUntilOnline, canScheduleUntilOnline,
onSendSilent, onSendSilent,
onSendSchedule, onSendSchedule,
@ -62,12 +64,12 @@ const CustomSendMenu: FC<OwnProps> = ({
noCloseOnBackdrop={!IS_TOUCH_ENV} noCloseOnBackdrop={!IS_TOUCH_ENV}
> >
{onSendSilent && <MenuItem icon="mute" onClick={onSendSilent}>{lang('SendWithoutSound')}</MenuItem>} {onSendSilent && <MenuItem icon="mute" onClick={onSendSilent}>{lang('SendWithoutSound')}</MenuItem>}
{onSendSchedule && ( {canSchedule && onSendSchedule && (
<MenuItem icon="schedule" onClick={onSendSchedule}> <MenuItem icon="schedule" onClick={onSendSchedule}>
{lang(isSavedMessages ? 'SetReminder' : 'ScheduleMessage')} {lang(isSavedMessages ? 'SetReminder' : 'ScheduleMessage')}
</MenuItem> </MenuItem>
)} )}
{onSendSchedule && displayScheduleUntilOnline && ( {canSchedule && onSendSchedule && displayScheduleUntilOnline && (
<MenuItem icon="user-online" onClick={onSendWhenOnline}> <MenuItem icon="user-online" onClick={onSendWhenOnline}>
{lang('SendWhenOnline')} {lang('SendWhenOnline')}
</MenuItem> </MenuItem>

View File

@ -47,6 +47,8 @@ type OwnProps = {
chatId: string; chatId: string;
threadId: number; threadId: number;
isAttachmentModalInput?: boolean; isAttachmentModalInput?: boolean;
isStoryInput?: boolean;
customEmojiPrefix: string;
editableInputId?: string; editableInputId?: string;
isReady: boolean; isReady: boolean;
isActive: boolean; isActive: boolean;
@ -63,6 +65,8 @@ type OwnProps = {
onSend: () => void; onSend: () => void;
onScroll?: (event: React.UIEvent<HTMLElement>) => void; onScroll?: (event: React.UIEvent<HTMLElement>) => void;
captionLimit?: number; captionLimit?: number;
onFocus?: NoneToVoidFunction;
onBlur?: NoneToVoidFunction;
}; };
type StateProps = { type StateProps = {
@ -73,6 +77,7 @@ type StateProps = {
}; };
const MAX_ATTACHMENT_MODAL_INPUT_HEIGHT = 160; const MAX_ATTACHMENT_MODAL_INPUT_HEIGHT = 160;
const MAX_STORY_MODAL_INPUT_HEIGHT = 128;
const TAB_INDEX_PRIORITY_TIMEOUT = 2000; const TAB_INDEX_PRIORITY_TIMEOUT = 2000;
// Heuristics allowing the user to make a triple click // Heuristics allowing the user to make a triple click
const SELECTION_RECALCULATE_DELAY_MS = 260; const SELECTION_RECALCULATE_DELAY_MS = 260;
@ -102,6 +107,8 @@ const MessageInput: FC<OwnProps & StateProps> = ({
chatId, chatId,
captionLimit, captionLimit,
isAttachmentModalInput, isAttachmentModalInput,
isStoryInput,
customEmojiPrefix,
editableInputId, editableInputId,
isReady, isReady,
isActive, isActive,
@ -121,6 +128,8 @@ const MessageInput: FC<OwnProps & StateProps> = ({
onSuppressedFocus, onSuppressedFocus,
onSend, onSend,
onScroll, onScroll,
onFocus,
onBlur,
}) => { }) => {
const { const {
editLastMessage, editLastMessage,
@ -162,13 +171,15 @@ const MessageInput: FC<OwnProps & StateProps> = ({
sharedCanvasRef, sharedCanvasRef,
sharedCanvasHqRef, sharedCanvasHqRef,
absoluteContainerRef, absoluteContainerRef,
isAttachmentModalInput ? 'attachment' : 'composer', customEmojiPrefix,
canPlayAnimatedEmojis, canPlayAnimatedEmojis,
isReady, isReady,
isActive, 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) => { const updateInputHeight = useLastCallback((willSend = false) => {
requestForcedReflow(() => { requestForcedReflow(() => {
const scroller = inputRef.current!.closest<HTMLDivElement>(`.${SCROLLER_CLASS}`)!; const scroller = inputRef.current!.closest<HTMLDivElement>(`.${SCROLLER_CLASS}`)!;
@ -351,7 +362,6 @@ const MessageInput: FC<OwnProps & StateProps> = ({
const { isComposing } = e; const { isComposing } = e;
const html = getHtml(); const html = getHtml();
if (!isComposing && !html && (e.metaKey || e.ctrlKey)) { if (!isComposing && !html && (e.metaKey || e.ctrlKey)) {
const targetIndexDelta = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : undefined; const targetIndexDelta = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : undefined;
if (targetIndexDelta) { if (targetIndexDelta) {
@ -549,6 +559,8 @@ const MessageInput: FC<OwnProps & StateProps> = ({
onContextMenu={IS_ANDROID ? handleAndroidContextMenu : undefined} onContextMenu={IS_ANDROID ? handleAndroidContextMenu : undefined}
onTouchCancel={IS_ANDROID ? processSelectionWithTimeout : undefined} onTouchCancel={IS_ANDROID ? processSelectionWithTimeout : undefined}
aria-label={placeholder} aria-label={placeholder}
onFocus={onFocus}
onBlur={onBlur}
/> />
{!forcedPlaceholder && ( {!forcedPlaceholder && (
<span <span

View File

@ -53,6 +53,8 @@ type OwnProps = {
isTranslucent?: boolean; isTranslucent?: boolean;
loadAndPlay: boolean; loadAndPlay: boolean;
canSendStickers?: boolean; canSendStickers?: boolean;
noContextMenus?: boolean;
idPrefix: string;
onStickerSelect: ( onStickerSelect: (
sticker: ApiSticker, isSilent?: boolean, shouldSchedule?: boolean, canUpdateStickerSetsOrder?: boolean, sticker: ApiSticker, isSilent?: boolean, shouldSchedule?: boolean, canUpdateStickerSetsOrder?: boolean,
) => void; ) => void;
@ -90,6 +92,8 @@ const StickerPicker: FC<OwnProps & StateProps> = ({
canAnimate, canAnimate,
isSavedMessages, isSavedMessages,
isCurrentUserPremium, isCurrentUserPremium,
noContextMenus,
idPrefix,
onStickerSelect, onStickerSelect,
}) => { }) => {
const { const {
@ -114,6 +118,7 @@ const StickerPicker: FC<OwnProps & StateProps> = ({
const sendMessageAction = useSendMessageAction(chat!.id, threadId); const sendMessageAction = useSendMessageAction(chat!.id, threadId);
const prefix = `${idPrefix}-sticker-set`;
const { const {
activeSetIndex, activeSetIndex,
observeIntersectionForSet, observeIntersectionForSet,
@ -121,7 +126,7 @@ const StickerPicker: FC<OwnProps & StateProps> = ({
observeIntersectionForShowingItems, observeIntersectionForShowingItems,
observeIntersectionForCovers, observeIntersectionForCovers,
selectStickerSet, selectStickerSet,
} = useStickerPickerObservers(containerRef, headerRef, 'sticker-set', isHidden); } = useStickerPickerObservers(containerRef, headerRef, prefix, isHidden);
const lang = useLang(); const lang = useLang();
@ -355,7 +360,9 @@ const StickerPicker: FC<OwnProps & StateProps> = ({
key={stickerSet.id} key={stickerSet.id}
stickerSet={stickerSet} stickerSet={stickerSet}
loadAndPlay={Boolean(canAnimate && loadAndPlay)} loadAndPlay={Boolean(canAnimate && loadAndPlay)}
noContextMenus={noContextMenus}
index={i} index={i}
idPrefix={prefix}
observeIntersection={observeIntersectionForSet} observeIntersection={observeIntersectionForSet}
observeIntersectionForPlayingItems={observeIntersectionForPlayingItems} observeIntersectionForPlayingItems={observeIntersectionForPlayingItems}
observeIntersectionForShowingItems={observeIntersectionForShowingItems} observeIntersectionForShowingItems={observeIntersectionForShowingItems}

View File

@ -51,7 +51,7 @@
transition: none; transition: none;
} }
&.left-column-open { &.left-column-open.in-middle-column {
transform: translate3d(100vw, 0, 0) !important; transform: translate3d(100vw, 0, 0) !important;
} }
} }
@ -154,7 +154,7 @@
body:not(.no-menu-blur) & { body:not(.no-menu-blur) & {
background: var(--color-background-compact-menu); background: var(--color-background-compact-menu);
backdrop-filter: blur(10px); backdrop-filter: blur(25px);
} }
&:not(.open) { &:not(.open) {
@ -224,6 +224,7 @@
&-external { &-external {
color: var(--color-text); color: var(--color-text);
text-align: start;
} }
} }

View File

@ -39,6 +39,8 @@ export type OwnProps = {
isOpen: boolean; isOpen: boolean;
canSendStickers?: boolean; canSendStickers?: boolean;
canSendGifs?: boolean; canSendGifs?: boolean;
isMessageComposer?: boolean;
idPrefix: string;
onLoad: () => void; onLoad: () => void;
onClose: () => void; onClose: () => void;
onEmojiSelect: (emoji: string) => void; onEmojiSelect: (emoji: string) => void;
@ -79,27 +81,29 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
isOpen, isOpen,
canSendStickers, canSendStickers,
canSendGifs, canSendGifs,
isMessageComposer,
isLeftColumnShown, isLeftColumnShown,
isCurrentUserPremium, isCurrentUserPremium,
onLoad, idPrefix,
onClose,
onEmojiSelect,
isAttachmentModal, isAttachmentModal,
canSendPlainText, canSendPlainText,
onCustomEmojiSelect,
onStickerSelect,
className, className,
onGifSelect,
onRemoveSymbol,
onSearchOpen,
addRecentEmoji,
addRecentCustomEmoji,
positionX, positionX,
positionY, positionY,
transformOriginX, transformOriginX,
transformOriginY, transformOriginY,
style, style,
isBackgroundTranslucent, isBackgroundTranslucent,
onLoad,
onClose,
onEmojiSelect,
onCustomEmojiSelect,
onStickerSelect,
onGifSelect,
onRemoveSymbol,
onSearchOpen,
addRecentEmoji,
addRecentCustomEmoji,
}) => { }) => {
const { loadPremiumSetStickers } = getActions(); const { loadPremiumSetStickers } = getActions();
const [activeTab, setActiveTab] = useState<number>(0); const [activeTab, setActiveTab] = useState<number>(0);
@ -218,6 +222,7 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
<CustomEmojiPicker <CustomEmojiPicker
className="picker-tab" className="picker-tab"
isHidden={!isOpen || !isActive} isHidden={!isOpen || !isActive}
idPrefix={idPrefix}
loadAndPlay={isOpen && (isActive || isFrom)} loadAndPlay={isOpen && (isActive || isFrom)}
chatId={chatId} chatId={chatId}
isTranslucent={!isMobile && isBackgroundTranslucent} isTranslucent={!isMobile && isBackgroundTranslucent}
@ -230,7 +235,9 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
className="picker-tab" className="picker-tab"
isHidden={!isOpen || !isActive} isHidden={!isOpen || !isActive}
loadAndPlay={canSendStickers ? isOpen && (isActive || isFrom) : false} loadAndPlay={canSendStickers ? isOpen && (isActive || isFrom) : false}
idPrefix={idPrefix}
canSendStickers={canSendStickers} canSendStickers={canSendStickers}
noContextMenus={!isMessageComposer}
chatId={chatId} chatId={chatId}
threadId={threadId} threadId={threadId}
isTranslucent={!isMobile && isBackgroundTranslucent} isTranslucent={!isMobile && isBackgroundTranslucent}
@ -285,6 +292,7 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
activeTab={activeTab} activeTab={activeTab}
onSwitchTab={setActiveTab} onSwitchTab={setActiveTab}
onRemoveSymbol={onRemoveSymbol} onRemoveSymbol={onRemoveSymbol}
canSearch={isMessageComposer}
onSearchOpen={handleSearch} onSearchOpen={handleSearch}
isAttachmentModal={isAttachmentModal} isAttachmentModal={isAttachmentModal}
canSendPlainText={canSendPlainText} canSendPlainText={canSendPlainText}
@ -302,6 +310,7 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
transitionClassNames, transitionClassNames,
isLeftColumnShown && 'left-column-open', isLeftColumnShown && 'left-column-open',
isAttachmentModal && 'in-attachment-modal', isAttachmentModal && 'in-attachment-modal',
isMessageComposer && 'in-middle-column',
); );
if (isAttachmentModal) { if (isAttachmentModal) {

View File

@ -27,6 +27,8 @@ type OwnProps = {
isSymbolMenuOpen?: boolean; isSymbolMenuOpen?: boolean;
canSendGifs?: boolean; canSendGifs?: boolean;
canSendStickers?: boolean; canSendStickers?: boolean;
isMessageComposer?: boolean;
idPrefix: string;
openSymbolMenu: VoidFunction; openSymbolMenu: VoidFunction;
closeSymbolMenu: VoidFunction; closeSymbolMenu: VoidFunction;
onCustomEmojiSelect: (emoji: ApiSticker) => void; onCustomEmojiSelect: (emoji: ApiSticker) => void;
@ -46,6 +48,7 @@ type OwnProps = {
isAttachmentModal?: boolean; isAttachmentModal?: boolean;
canSendPlainText?: boolean; canSendPlainText?: boolean;
className?: string; className?: string;
inputCssSelector?: string;
}; };
const SymbolMenuButton: FC<OwnProps> = ({ const SymbolMenuButton: FC<OwnProps> = ({
@ -54,21 +57,24 @@ const SymbolMenuButton: FC<OwnProps> = ({
isMobile, isMobile,
canSendGifs, canSendGifs,
canSendStickers, canSendStickers,
isMessageComposer,
isReady, isReady,
isSymbolMenuOpen, isSymbolMenuOpen,
idPrefix,
isAttachmentModal,
canSendPlainText,
isSymbolMenuForced,
className,
inputCssSelector = EDITABLE_INPUT_CSS_SELECTOR,
openSymbolMenu, openSymbolMenu,
closeSymbolMenu, closeSymbolMenu,
onCustomEmojiSelect, onCustomEmojiSelect,
onStickerSelect, onStickerSelect,
onGifSelect, onGifSelect,
isAttachmentModal,
canSendPlainText,
onRemoveSymbol, onRemoveSymbol,
onEmojiSelect, onEmojiSelect,
closeBotCommandMenu, closeBotCommandMenu,
closeSendAsMenu, closeSendAsMenu,
isSymbolMenuForced,
className,
}) => { }) => {
const { const {
setStickerSearchQuery, setStickerSearchQuery,
@ -113,7 +119,7 @@ const SymbolMenuButton: FC<OwnProps> = ({
const handleSymbolMenuOpen = useLastCallback(() => { const handleSymbolMenuOpen = useLastCallback(() => {
const messageInput = document.querySelector<HTMLDivElement>( const messageInput = document.querySelector<HTMLDivElement>(
isAttachmentModal ? EDITABLE_INPUT_MODAL_CSS_SELECTOR : EDITABLE_INPUT_CSS_SELECTOR, isAttachmentModal ? EDITABLE_INPUT_MODAL_CSS_SELECTOR : inputCssSelector,
); );
if (!isMobile || messageInput !== document.activeElement) { if (!isMobile || messageInput !== document.activeElement) {
@ -176,6 +182,8 @@ const SymbolMenuButton: FC<OwnProps> = ({
isOpen={isSymbolMenuOpen || Boolean(isSymbolMenuForced)} isOpen={isSymbolMenuOpen || Boolean(isSymbolMenuForced)}
canSendGifs={canSendGifs} canSendGifs={canSendGifs}
canSendStickers={canSendStickers} canSendStickers={canSendStickers}
isMessageComposer={isMessageComposer}
idPrefix={idPrefix}
onLoad={onSymbolMenuLoadingComplete} onLoad={onSymbolMenuLoadingComplete}
onClose={closeSymbolMenu} onClose={closeSymbolMenu}
onEmojiSelect={onEmojiSelect} onEmojiSelect={onEmojiSelect}

View File

@ -15,6 +15,7 @@ type OwnProps = {
onSearchOpen: (type: 'stickers' | 'gifs') => void; onSearchOpen: (type: 'stickers' | 'gifs') => void;
isAttachmentModal?: boolean; isAttachmentModal?: boolean;
canSendPlainText?: boolean; canSendPlainText?: boolean;
canSearch?: boolean;
}; };
export enum SymbolMenuTabs { export enum SymbolMenuTabs {
@ -40,7 +41,7 @@ const SYMBOL_MENU_TAB_ICONS = {
const SymbolMenuFooter: FC<OwnProps> = ({ const SymbolMenuFooter: FC<OwnProps> = ({
activeTab, onSwitchTab, onRemoveSymbol, onSearchOpen, isAttachmentModal, activeTab, onSwitchTab, onRemoveSymbol, onSearchOpen, isAttachmentModal,
canSendPlainText, canSendPlainText, canSearch,
}) => { }) => {
const lang = useLang(); const lang = useLang();
@ -70,7 +71,7 @@ const SymbolMenuFooter: FC<OwnProps> = ({
return ( return (
<div className="SymbolMenu-footer" onClick={stopPropagation} dir={lang.isRtl ? 'rtl' : undefined}> <div className="SymbolMenu-footer" onClick={stopPropagation} dir={lang.isRtl ? 'rtl' : undefined}>
{activeTab !== SymbolMenuTabs.Emoji && activeTab !== SymbolMenuTabs.CustomEmoji && ( {activeTab !== SymbolMenuTabs.Emoji && activeTab !== SymbolMenuTabs.CustomEmoji && canSearch && (
<Button <Button
className="symbol-search-button" className="symbol-search-button"
ariaLabel={activeTab === SymbolMenuTabs.Stickers ? 'Search Stickers' : 'Search GIFs'} ariaLabel={activeTab === SymbolMenuTabs.Stickers ? 'Search Stickers' : 'Search GIFs'}

View File

@ -55,7 +55,7 @@ export default function useCustomEmojiTooltip(
const hasCustomEmojis = Boolean(customEmojis?.length); const hasCustomEmojis = Boolean(customEmojis?.length);
useEffect(() => { useEffect(() => {
if (!isEnabled) return; if (!isEnabled || !isActive) return;
const lastEmoji = getLastEmoji(); const lastEmoji = getLastEmoji();
if (lastEmoji) { if (lastEmoji) {
@ -67,7 +67,7 @@ export default function useCustomEmojiTooltip(
} else { } else {
clearCustomEmojiForEmoji(); clearCustomEmojiForEmoji();
} }
}, [isEnabled, getLastEmoji, hasCustomEmojis, clearCustomEmojiForEmoji, loadCustomEmojiForEmoji]); }, [isEnabled, isActive, getLastEmoji, hasCustomEmojis, clearCustomEmojiForEmoji, loadCustomEmojiForEmoji]);
const insertCustomEmoji = useLastCallback((emoji: ApiSticker) => { const insertCustomEmoji = useLastCallback((emoji: ApiSticker) => {
const lastEmoji = getLastEmoji(); const lastEmoji = getLastEmoji();

View File

@ -37,13 +37,14 @@ const useDraft = (
getHtml: Signal<string>, getHtml: Signal<string>,
setHtml: (html: string) => void, setHtml: (html: string) => void,
editedMessage: ApiMessage | undefined, editedMessage: ApiMessage | undefined,
isDisabled: boolean | undefined,
) => { ) => {
const { saveDraft, clearDraft, loadCustomEmojis } = getActions(); const { saveDraft, clearDraft, loadCustomEmojis } = getActions();
const isEditing = Boolean(editedMessage); const isEditing = Boolean(editedMessage);
const updateDraft = useLastCallback((prevState: { chatId?: string; threadId?: number } = {}, shouldForce = false) => { const updateDraft = useLastCallback((prevState: { chatId?: string; threadId?: number } = {}, shouldForce = false) => {
if (isEditing) return; if (isDisabled || isEditing) return;
const html = getHtml(); const html = getHtml();
@ -68,6 +69,10 @@ const useDraft = (
// Restore draft on chat change // Restore draft on chat change
useEffectWithPrevDeps(([prevChatId, prevThreadId, prevDraft]) => { useEffectWithPrevDeps(([prevChatId, prevThreadId, prevDraft]) => {
if (isDisabled) {
return;
}
if (chatId === prevChatId && threadId === prevThreadId) { if (chatId === prevChatId && threadId === prevThreadId) {
if (!draft && prevDraft) { if (!draft && prevDraft) {
setHtml(''); setHtml('');
@ -97,10 +102,14 @@ const useDraft = (
} }
}); });
} }
}, [chatId, threadId, draft, setHtml, editedMessage, loadCustomEmojis]); }, [chatId, threadId, draft, setHtml, editedMessage, loadCustomEmojis, isDisabled]);
// Save draft on chat change // Save draft on chat change
useEffect(() => { useEffect(() => {
if (isDisabled) {
return undefined;
}
return () => { return () => {
// eslint-disable-next-line react-hooks-static-deps/exhaustive-deps // eslint-disable-next-line react-hooks-static-deps/exhaustive-deps
if (!isEditing) { if (!isEditing) {
@ -110,12 +119,12 @@ const useDraft = (
freeze(); freeze();
}; };
}, [chatId, threadId, isEditing, updateDraftRef]); }, [chatId, threadId, isEditing, updateDraftRef, isDisabled]);
const chatIdRef = useStateRef(chatId); const chatIdRef = useStateRef(chatId);
const threadIdRef = useStateRef(threadId); const threadIdRef = useStateRef(threadId);
useEffect(() => { useEffect(() => {
if (isFrozen) { if (isDisabled || isFrozen) {
return; return;
} }
@ -133,7 +142,7 @@ const useDraft = (
updateDraftRef.current(); updateDraftRef.current();
} }
}); });
}, [chatIdRef, getHtml, runDebouncedForSaveDraft, threadIdRef, updateDraftRef]); }, [chatIdRef, getHtml, isDisabled, runDebouncedForSaveDraft, threadIdRef, updateDraftRef]);
function forceUpdateDraft() { function forceUpdateDraft() {
updateDraft(undefined, true); updateDraft(undefined, true);

View File

@ -48,7 +48,7 @@ export default function useStickerTooltip(
const hasStickers = Boolean(stickers?.length); const hasStickers = Boolean(stickers?.length);
useEffect(() => { useEffect(() => {
if (!isEnabled) return; if (!isEnabled || !isActive) return;
const singleEmoji = getSingleEmoji(); const singleEmoji = getSingleEmoji();
if (singleEmoji) { if (singleEmoji) {
@ -58,7 +58,7 @@ export default function useStickerTooltip(
} else { } else {
clearStickersForEmoji(); clearStickersForEmoji();
} }
}, [isEnabled, getSingleEmoji, hasStickers, loadStickersForEmoji, clearStickersForEmoji]); }, [isEnabled, isActive, getSingleEmoji, hasStickers, loadStickersForEmoji, clearStickersForEmoji]);
useEffect(unmarkManuallyClosed, [unmarkManuallyClosed, getHtml]); useEffect(unmarkManuallyClosed, [unmarkManuallyClosed, getHtml]);

View File

@ -31,7 +31,8 @@ const useVoiceRecording = () => {
if (recordButtonRef.current) { if (recordButtonRef.current) {
if (startRecordTimeRef.current && Date.now() % 4 === 0) { if (startRecordTimeRef.current && Date.now() % 4 === 0) {
requestMutation(() => { requestMutation(() => {
recordButtonRef.current!.style.boxShadow = `0 0 0 ${(tickVolume || 0) * 50}px rgba(0,0,0,.15)`; if (!recordButtonRef.current) return;
recordButtonRef.current.style.boxShadow = `0 0 0 ${(tickVolume || 0) * 50}px rgba(0,0,0,.15)`;
}); });
} }
setCurrentRecordTime(Date.now()); setCurrentRecordTime(Date.now());
@ -78,7 +79,7 @@ const useVoiceRecording = () => {
requestMutation(() => { requestMutation(() => {
if (recordButtonRef.current) { if (recordButtonRef.current) {
recordButtonRef.current!.style.boxShadow = 'none'; recordButtonRef.current.style.boxShadow = 'none';
} }
}); });

View File

@ -0,0 +1,53 @@
.root {
display: block !important;
width: 12rem;
height: 0;
// Aspect-ratio trick https://css-tricks.com/aspect-ratio-boxes/ (192:344)
padding-bottom: 179%;
overflow: hidden;
position: relative;
cursor: var(--custom-cursor, pointer);
}
.preview {
// Aspect ratio 12:11, preview in webpage
padding-bottom: 91.67%;
.linkPreview {
border-radius: 0 !important;
}
}
.media {
width: 100%;
height: auto;
object-fit: cover;
}
.linkPreview {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 75%;
}
.nonInteractive {
cursor: default;
:global(.message-content.story) &:global(.media-inner) {
margin-bottom: 2rem !important;
}
}
.expired {
height: auto;
padding-bottom: 0;
color: var(--accent-color);
}
.expiredIcon {
font-size: 1.25rem;
line-height: 0.9375rem;
vertical-align: -0.1875rem;
}

View File

@ -0,0 +1,108 @@
import React, { memo, useEffect } from '../../../lib/teact/teact';
import { getActions } from '../../../global';
import type { ApiMessageStoryData, ApiTypeStory } from '../../../api/types';
import { IS_CANVAS_FILTER_SUPPORTED } from '../../../util/windowEnvironment';
import { getStoryMediaHash } from '../../../global/helpers';
import buildClassName from '../../../util/buildClassName';
import { formatMediaDuration } from '../../../util/dateFormat';
import useMedia from '../../../hooks/useMedia';
import useLastCallback from '../../../hooks/useLastCallback';
import useLang from '../../../hooks/useLang';
import useCanvasBlur from '../../../hooks/useCanvasBlur';
import useAppLayout from '../../../hooks/useAppLayout';
import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev';
import useShowTransition from '../../../hooks/useShowTransition';
import styles from './BaseStory.module.scss';
interface OwnProps {
story?: ApiTypeStory | ApiMessageStoryData;
isPreview?: boolean;
isProtected?: boolean;
isConnected?: boolean;
}
function BaseStory({
story, isPreview, isProtected, isConnected,
}: OwnProps) {
const { openStoryViewer, loadUserStoriesByIds, showNotification } = getActions();
const lang = useLang();
const { isMobile } = useAppLayout();
const isExpired = story && 'isDeleted' in story;
const isLoaded = story && 'content' in story;
const video = isLoaded ? story.content.video : undefined;
const imageHash = isLoaded ? getStoryMediaHash(story) : undefined;
const imgBlobUrl = useMedia(imageHash);
const thumbnail = isLoaded ? (video ? video.thumbnail?.dataUri : story.content.photo?.thumbnail?.dataUri) : undefined;
const mediaUrl = useCurrentOrPrev(imgBlobUrl, true);
const { shouldRender, transitionClassNames } = useShowTransition(Boolean(mediaUrl));
const blurredBackgroundRef = useCanvasBlur(
thumbnail,
isExpired && !isPreview,
isMobile && !IS_CANVAS_FILTER_SUPPORTED,
);
const fullClassName = buildClassName(
styles.root,
'media-inner',
(!isConnected || isExpired) && styles.nonInteractive,
isExpired && styles.expired,
isPreview && styles.preview,
);
useEffect(() => {
if (story && !(isLoaded || isExpired)) {
loadUserStoriesByIds({ userId: story.userId, storyIds: [story.id] });
}
}, [story, isExpired, isLoaded]);
const handleClick = useLastCallback(() => {
if (isExpired) {
showNotification({
message: lang('StoryNotFound'),
});
return;
}
openStoryViewer({
userId: story!.userId,
storyId: story!.id,
isSingleUser: true,
isSingleStory: true,
});
});
return (
<div
className={fullClassName}
onClick={isConnected ? handleClick : undefined}
>
{!isExpired && isPreview && <canvas ref={blurredBackgroundRef} className="thumbnail blurred-bg" />}
{shouldRender && (
<img
src={mediaUrl}
alt=""
className={buildClassName(styles.media, isPreview && styles.linkPreview, transitionClassNames)}
/>
)}
{isExpired && (
<span>
<i className={buildClassName(styles.expiredIcon, 'icon icon-story-expired')} aria-hidden />
{lang('StoryExpiredSubtitle')}
</span>
)}
{Boolean(video?.duration) && (
<div className="message-media-duration">
{formatMediaDuration(video!.duration)}
</div>
)}
{isProtected && <span className="protector" />}
</div>
);
}
export default memo(BaseStory);

View File

@ -40,7 +40,7 @@ import {
getMessageVideo, getMessageVideo,
getChatMessageLink, getChatMessageLink,
} from '../../../global/helpers'; } from '../../../global/helpers';
import { SERVICE_NOTIFICATIONS_USER_ID } from '../../../config'; import { PREVIEW_AVATAR_COUNT, SERVICE_NOTIFICATIONS_USER_ID } from '../../../config';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import { copyTextToClipboard } from '../../../util/clipboard'; import { copyTextToClipboard } from '../../../util/clipboard';
@ -194,7 +194,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
requestMessageTranslation, requestMessageTranslation,
showOriginalMessage, showOriginalMessage,
openChatLanguageModal, openChatLanguageModal,
openReactionPicker, openMessageReactionPicker,
} = getActions(); } = getActions();
const lang = useLang(); const lang = useLang();
@ -245,14 +245,16 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
({ peerId }) => usersById[peerId] || chatsById[peerId], ({ peerId }) => usersById[peerId] || chatsById[peerId],
)); ));
return Array.from(uniqueReactors).filter(Boolean).slice(0, 3); return Array.from(uniqueReactors).filter(Boolean).slice(0, PREVIEW_AVATAR_COUNT);
} }
if (!message.seenByDates) { if (!message.seenByDates) {
return undefined; return undefined;
} }
return Object.keys(message.seenByDates).slice(0, 3).map((id) => usersById[id] || chatsById[id]).filter(Boolean); return Object.keys(message.seenByDates).slice(0, PREVIEW_AVATAR_COUNT)
.map((id) => usersById[id] || chatsById[id])
.filter(Boolean);
}, [message.reactions?.recentReactions, message.seenByDates]); }, [message.reactions?.recentReactions, message.seenByDates]);
const isDownloading = useMemo(() => { const isDownloading = useMemo(() => {
@ -435,7 +437,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
}); });
const handleReactionPickerOpen = useLastCallback((position: IAnchorPosition) => { const handleReactionPickerOpen = useLastCallback((position: IAnchorPosition) => {
openReactionPicker({ chatId: message.chatId, messageId: message.id, position }); openMessageReactionPicker({ chatId: message.chatId, messageId: message.id, position });
}); });
const handleTranslate = useLastCallback(() => { const handleTranslate = useLastCallback(() => {

View File

@ -26,12 +26,14 @@ const MentionLink: FC<OwnProps & StateProps> = ({
const { const {
openChat, openChat,
openChatByUsername, openChatByUsername,
closeStoryViewer,
} = getActions(); } = getActions();
const handleClick = () => { const handleClick = () => {
if (userOrChat) { if (userOrChat) {
openChat({ id: userOrChat.id }); openChat({ id: userOrChat.id });
} else if (username) { } else if (username) {
closeStoryViewer();
openChatByUsername({ username: username.substring(1) }); openChatByUsername({ username: username.substring(1) });
} }
}; };

View File

@ -254,6 +254,88 @@
} }
} }
&.is-story-mention {
--background-color: var(--pattern-color);
.message-content-wrapper {
margin-left: auto;
margin-right: auto;
}
.action-message-story-mention {
background-color: var(--background-color);
color: white;
font-size: calc(var(--message-text-size, 1rem) - 0.0625rem);
padding: 0.75rem 0.5rem;
border-radius: var(--border-radius-messages);
word-break: break-word;
text-align: center;
user-select: none;
position: relative;
z-index: 0;
display: inline-flex;
flex-direction: column;
max-width: 9.625rem;
align-items: center;
body.is-ios &,
body.is-macos & {
font-size: calc(var(--message-text-size, 1rem) - 0.125rem);
line-height: calc(var(--message-text-size, 1rem) + 0.5rem);
}
&.with-preview {
padding: 1.25rem 0.5rem 0.75rem;
cursor: var(--custom-cursor, pointer);
}
&.with-preview::before {
content: '';
position: absolute;
left: 50%;
top: 1rem;
transform: translateX(-50%);
width: 6rem;
height: 6rem;
border-radius: 50%;
padding: 0.0625rem;
/* stylelint-disable-next-line plugin/whole-pixel */
box-shadow: 0 0 0 0.03125rem rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.3);
mask: linear-gradient(to bottom, #fff 0%, #fff 100%) content-box, linear-gradient(to bottom, #fff 0%, #fff 100%);
mask-composite: exclude;
}
&.is-unread::before {
padding: 0.125rem;
background: linear-gradient(215.87deg, var(--color-message-story-mention-from) -1.61%, var(--color-message-story-mention-to) 97.44%);
box-shadow: none;
}
}
.story-media-wrapper {
width: 5.5rem;
height: 5.5rem;
margin: 0 auto 1rem;
border-radius: 50%;
overflow: hidden;
}
.story-media {
object-fit: cover;
width: 100%;
height: auto;
position: relative;
top: 50%;
transform: translateY(-50%);
}
.story-title {
line-height: 1.35;
}
}
&.has-views { &.has-views {
--meta-safe-area-extra-width: 4rem; --meta-safe-area-extra-width: 4rem;
} }

View File

@ -17,6 +17,7 @@ import type {
ApiMessageOutgoingStatus, ApiMessageOutgoingStatus,
ApiReaction, ApiReaction,
ApiStickerSet, ApiStickerSet,
ApiTypeStory,
ApiThreadInfo, ApiThreadInfo,
ApiTopic, ApiTopic,
ApiUser, ApiUser,
@ -56,6 +57,7 @@ import {
selectMessageIdsByGroupId, selectMessageIdsByGroupId,
selectOutgoingStatus, selectOutgoingStatus,
selectPerformanceSettingsValue, selectPerformanceSettingsValue,
selectUserStory,
selectReplySender, selectReplySender,
selectRequestedChatTranslationLanguage, selectRequestedChatTranslationLanguage,
selectRequestedMessageTranslationLanguage, selectRequestedMessageTranslationLanguage,
@ -123,6 +125,7 @@ import useMessageTranslation from './hooks/useMessageTranslation';
import usePrevious from '../../../hooks/usePrevious'; import usePrevious from '../../../hooks/usePrevious';
import useTextLanguage from '../../../hooks/useTextLanguage'; import useTextLanguage from '../../../hooks/useTextLanguage';
import useAuthorWidth from '../hooks/useAuthorWidth'; import useAuthorWidth from '../hooks/useAuthorWidth';
import useEnsureStory from '../../../hooks/useEnsureStory';
import { dispatchHeavyAnimationEvent } from '../../../hooks/useHeavyAnimationCheck'; import { dispatchHeavyAnimationEvent } from '../../../hooks/useHeavyAnimationCheck';
import useDetectChatLanguage from './hooks/useDetectChatLanguage'; import useDetectChatLanguage from './hooks/useDetectChatLanguage';
@ -158,6 +161,9 @@ import PremiumIcon from '../../common/PremiumIcon';
import FakeIcon from '../../common/FakeIcon'; import FakeIcon from '../../common/FakeIcon';
import MessageText from '../../common/MessageText'; import MessageText from '../../common/MessageText';
import TopicChip from '../../common/TopicChip'; import TopicChip from '../../common/TopicChip';
import EmbeddedStory from '../../common/EmbeddedStory';
import Story from './Story';
import StoryMention from './StoryMention';
import './Message.scss'; import './Message.scss';
@ -203,6 +209,8 @@ type StateProps = {
shouldHideReply?: boolean; shouldHideReply?: boolean;
replyMessage?: ApiMessage; replyMessage?: ApiMessage;
replyMessageSender?: ApiUser | ApiChat; replyMessageSender?: ApiUser | ApiChat;
replyStory?: ApiTypeStory;
storySender?: ApiUser;
outgoingStatus?: ApiMessageOutgoingStatus; outgoingStatus?: ApiMessageOutgoingStatus;
uploadProgress?: number; uploadProgress?: number;
isInDocumentGroup: boolean; isInDocumentGroup: boolean;
@ -255,6 +263,7 @@ type StateProps = {
requestedChatTranslationLanguage?: string; requestedChatTranslationLanguage?: string;
withReactionEffects?: boolean; withReactionEffects?: boolean;
withStickerEffects?: boolean; withStickerEffects?: boolean;
webPageStory?: ApiTypeStory;
isConnected: boolean; isConnected: boolean;
}; };
@ -311,6 +320,8 @@ const Message: FC<OwnProps & StateProps> = ({
shouldHideReply, shouldHideReply,
replyMessage, replyMessage,
replyMessageSender, replyMessageSender,
replyStory,
storySender,
outgoingStatus, outgoingStatus,
uploadProgress, uploadProgress,
isInDocumentGroup, isInDocumentGroup,
@ -361,6 +372,7 @@ const Message: FC<OwnProps & StateProps> = ({
requestedChatTranslationLanguage, requestedChatTranslationLanguage,
withReactionEffects, withReactionEffects,
withStickerEffects, withStickerEffects,
webPageStory,
isConnected, isConnected,
onPinnedIntersectionChange, onPinnedIntersectionChange,
getIsMessageListReady, getIsMessageListReady,
@ -446,6 +458,7 @@ const Message: FC<OwnProps & StateProps> = ({
const isOwn = isOwnMessage(message); const isOwn = isOwnMessage(message);
const isScheduled = messageListType === 'scheduled' || message.isScheduled; const isScheduled = messageListType === 'scheduled' || message.isScheduled;
const hasReply = isReplyMessage(message) && !shouldHideReply; const hasReply = isReplyMessage(message) && !shouldHideReply;
const hasStoryReply = Boolean(message.replyToStoryId);
const hasThread = Boolean(repliesThreadInfo) && messageListType === 'thread'; const hasThread = Boolean(repliesThreadInfo) && messageListType === 'thread';
const isCustomShape = getMessageCustomShape(message); const isCustomShape = getMessageCustomShape(message);
const hasAnimatedEmoji = isCustomShape && (animatedEmoji || animatedCustomEmoji); const hasAnimatedEmoji = isCustomShape && (animatedEmoji || animatedCustomEmoji);
@ -456,7 +469,8 @@ const Message: FC<OwnProps & StateProps> = ({
&& !isRepliesChat && !isRepliesChat
&& !forwardInfo.isLinkedChannelPost && !forwardInfo.isLinkedChannelPost
&& !isCustomShape && !isCustomShape
); ) || Boolean(message.content.storyData && !message.content.storyData.isMention);
const isStoryMention = message.content.storyData?.isMention;
const isAlbum = Boolean(album) && album!.messages.length > 1 const isAlbum = Boolean(album) && album!.messages.length > 1
&& !album?.messages.some((msg) => Object.keys(msg.content).length === 0); && !album?.messages.some((msg) => Object.keys(msg.content).length === 0);
const isInDocumentGroupNotFirst = isInDocumentGroup && !isFirstInDocumentGroup; const isInDocumentGroupNotFirst = isInDocumentGroup && !isFirstInDocumentGroup;
@ -465,6 +479,7 @@ const Message: FC<OwnProps & StateProps> = ({
const canShowActionButton = ( const canShowActionButton = (
!(isContextMenuShown || isInSelectMode || isForwarding) !(isContextMenuShown || isInSelectMode || isForwarding)
&& !isInDocumentGroupNotLast && !isInDocumentGroupNotLast
&& !isStoryMention
); );
const canForward = isChannel && !isScheduled && message.isForwardingAllowed && !isChatProtected; const canForward = isChannel && !isScheduled && message.isForwardingAllowed && !isChatProtected;
const canFocus = Boolean(isPinnedList const canFocus = Boolean(isPinnedList
@ -473,7 +488,7 @@ const Message: FC<OwnProps & StateProps> = ({
&& forwardInfo.fromMessageId && forwardInfo.fromMessageId
)); ));
const hasSubheader = hasTopicChip || hasReply; const hasSubheader = hasTopicChip || hasReply || hasStoryReply;
const selectMessage = useLastCallback((e?: React.MouseEvent<HTMLDivElement, MouseEvent>, groupedId?: string) => { const selectMessage = useLastCallback((e?: React.MouseEvent<HTMLDivElement, MouseEvent>, groupedId?: string) => {
toggleMessageSelection({ toggleMessageSelection({
@ -539,6 +554,7 @@ const Message: FC<OwnProps & StateProps> = ({
handleFocusForwarded, handleFocusForwarded,
handleDocumentGroupSelectAll, handleDocumentGroupSelectAll,
handleTopicChipClick, handleTopicChipClick,
handleStoryClick,
} = useInnerHandlers( } = useInnerHandlers(
lang, lang,
selectMessage, selectMessage,
@ -555,6 +571,7 @@ const Message: FC<OwnProps & StateProps> = ({
botSender, botSender,
messageTopic, messageTopic,
Boolean(requestedChatTranslationLanguage), Boolean(requestedChatTranslationLanguage),
replyStory && 'content' in replyStory ? replyStory : undefined,
); );
useEffect(() => { useEffect(() => {
@ -594,10 +611,14 @@ const Message: FC<OwnProps & StateProps> = ({
transitionClassNames, transitionClassNames,
isJustAdded && 'is-just-added', isJustAdded && 'is-just-added',
(Boolean(activeReactions) || hasActiveStickerEffect) && 'has-active-reaction', (Boolean(activeReactions) || hasActiveStickerEffect) && 'has-active-reaction',
isStoryMention && 'is-story-mention',
); );
const { const {
text, photo, video, audio, voice, document, sticker, contact, poll, webPage, invoice, location, action, game, text, photo, video, audio,
voice, document, sticker, contact,
poll, webPage, invoice, location,
action, game, storyData,
} = getMessageContent(message); } = getMessageContent(message);
const detectedLanguage = useTextLanguage( const detectedLanguage = useTextLanguage(
@ -625,7 +646,7 @@ const Message: FC<OwnProps & StateProps> = ({
const withCommentButton = repliesThreadInfo && !isInDocumentGroupNotLast && messageListType === 'thread' const withCommentButton = repliesThreadInfo && !isInDocumentGroupNotLast && messageListType === 'thread'
&& !noComments; && !noComments;
const withQuickReactionButton = !isTouchScreen && !phoneCall && !isInSelectMode && defaultReaction const withQuickReactionButton = !isTouchScreen && !phoneCall && !isInSelectMode && defaultReaction
&& !isInDocumentGroupNotLast; && !isInDocumentGroupNotLast && !isStoryMention;
const contentClassName = buildContentClassName(message, { const contentClassName = buildContentClassName(message, {
hasSubheader, hasSubheader,
@ -658,7 +679,7 @@ const Message: FC<OwnProps & StateProps> = ({
let reactionsPosition!: ReactionsPosition; let reactionsPosition!: ReactionsPosition;
if (hasReactions) { if (hasReactions) {
if (isCustomShape || ((photo || video || (location && location.type === 'geo')) && !hasText)) { if (isCustomShape || ((photo || video || storyData || (location && location.type === 'geo')) && !hasText)) {
reactionsPosition = 'outside'; reactionsPosition = 'outside';
} else if (asForwarded) { } else if (asForwarded) {
metaPosition = 'standalone'; metaPosition = 'standalone';
@ -679,6 +700,12 @@ const Message: FC<OwnProps & StateProps> = ({
message.id, message.id,
); );
useEnsureStory(
message.replyToStoryUserId ? message.replyToStoryUserId : chatId,
message.replyToStoryId,
replyStory,
);
useFocusMessage( useFocusMessage(
ref, chatId, isFocused, focusDirection, noFocusHighlight, isResizingContainer, isJustAdded, ref, chatId, isFocused, focusDirection, noFocusHighlight, isResizingContainer, isJustAdded,
); );
@ -818,7 +845,7 @@ const Message: FC<OwnProps & StateProps> = ({
function renderMessageText(isForAnimation?: boolean) { function renderMessageText(isForAnimation?: boolean) {
return ( return (
<MessageText <MessageText
message={message} messageOrStory={message}
translatedText={requestedTranslationLanguage ? currentTranslatedText : undefined} translatedText={requestedTranslationLanguage ? currentTranslatedText : undefined}
isForAnimation={isForAnimation} isForAnimation={isForAnimation}
emojiSize={emojiSize} emojiSize={emojiSize}
@ -932,6 +959,16 @@ const Message: FC<OwnProps & StateProps> = ({
onClick={handleReplyClick} onClick={handleReplyClick}
/> />
)} )}
{hasStoryReply && (
<EmbeddedStory
story={replyStory}
sender={storySender}
noUserColors={isOwn || isChannel}
isProtected={isProtected}
observeIntersectionForLoading={observeIntersectionForLoading}
onClick={handleStoryClick}
/>
)}
</div> </div>
)} )}
{sticker && ( {sticker && (
@ -1070,6 +1107,13 @@ const Message: FC<OwnProps & StateProps> = ({
isDownloading={isDownloading} isDownloading={isDownloading}
/> />
)} )}
{storyData && !isStoryMention && (
<Story
message={message}
isProtected={isProtected}
/>
)}
{isStoryMention && <StoryMention message={message} />}
{contact && ( {contact && (
<Contact contact={contact} /> <Contact contact={contact} />
)} )}
@ -1128,6 +1172,8 @@ const Message: FC<OwnProps & StateProps> = ({
isDownloading={isDownloading} isDownloading={isDownloading}
isProtected={isProtected} isProtected={isProtected}
theme={theme} theme={theme}
story={webPageStory}
isConnected={isConnected}
onMediaClick={handleMediaClick} onMediaClick={handleMediaClick}
onCancelMediaTransfer={handleCancelUpload} onCancelMediaTransfer={handleCancelUpload}
/> />
@ -1175,6 +1221,8 @@ const Message: FC<OwnProps & StateProps> = ({
} }
} else if (forwardInfo?.hiddenUserName) { } else if (forwardInfo?.hiddenUserName) {
senderTitle = forwardInfo.hiddenUserName; senderTitle = forwardInfo.hiddenUserName;
} else if (storyData && originSender) {
senderTitle = getSenderTitle(lang, originSender!);
} }
const senderEmojiStatus = senderPeer && 'emojiStatus' in senderPeer && senderPeer.emojiStatus; const senderEmojiStatus = senderPeer && 'emojiStatus' in senderPeer && senderPeer.emojiStatus;
const senderIsPremium = senderPeer && 'isPremium' in senderPeer && senderPeer.isPremium; const senderIsPremium = senderPeer && 'isPremium' in senderPeer && senderPeer.isPremium;
@ -1284,12 +1332,12 @@ const Message: FC<OwnProps & StateProps> = ({
> >
{asForwarded && !isInDocumentGroupNotFirst && ( {asForwarded && !isInDocumentGroupNotFirst && (
<div className="message-title"> <div className="message-title">
{lang('ForwardedMessage')} {lang(storyData ? 'ForwardedStory' : 'ForwardedMessage')}
{forwardAuthor && <span className="admin-title" dir="auto">{forwardAuthor}</span>} {forwardAuthor && <span className="admin-title" dir="auto">{forwardAuthor}</span>}
</div> </div>
)} )}
{renderContent()} {renderContent()}
{!isInDocumentGroupNotLast && metaPosition === 'standalone' && renderReactionsAndMeta()} {!isInDocumentGroupNotLast && metaPosition === 'standalone' && !isStoryMention && renderReactionsAndMeta()}
{canShowActionButton && canForward ? ( {canShowActionButton && canForward ? (
<Button <Button
className="message-action-button" className="message-action-button"
@ -1320,7 +1368,7 @@ const Message: FC<OwnProps & StateProps> = ({
{message.inlineButtons && ( {message.inlineButtons && (
<InlineButtons message={message} onClick={clickBotInlineButton} /> <InlineButtons message={message} onClick={clickBotInlineButton} />
)} )}
{reactionsPosition === 'outside' && ( {reactionsPosition === 'outside' && !isStoryMention && (
<Reactions <Reactions
message={reactionMessage!} message={reactionMessage!}
isOutside isOutside
@ -1387,8 +1435,8 @@ export default memo(withGlobal<OwnProps>(
message, album, withSenderName, withAvatar, threadId, messageListType, isLastInDocumentGroup, isFirstInGroup, message, album, withSenderName, withAvatar, threadId, messageListType, isLastInDocumentGroup, isFirstInGroup,
} = ownProps; } = ownProps;
const { const {
id, chatId, viaBotId, replyToChatId, replyToMessageId, isOutgoing, repliesThreadInfo, forwardInfo, id, chatId, viaBotId, replyToChatId, replyToMessageId, isOutgoing, forwardInfo,
transcriptionId, isPinned, transcriptionId, isPinned, replyToStoryUserId, replyToStoryId, repliesThreadInfo,
} = message; } = message;
const chat = selectChat(global, chatId); const chat = selectChat(global, chatId);
@ -1398,6 +1446,10 @@ export default memo(withGlobal<OwnProps>(
const isGroup = chat && isChatGroup(chat); const isGroup = chat && isChatGroup(chat);
const chatUsernames = chat?.usernames; const chatUsernames = chat?.usernames;
const chatFullInfo = !isUserId(chatId) ? selectChatFullInfo(global, chatId) : undefined; const chatFullInfo = !isUserId(chatId) ? selectChatFullInfo(global, chatId) : undefined;
const webPageStoryData = message.content.webPage?.story;
const webPageStory = webPageStoryData
? selectUserStory(global, webPageStoryData.userId, webPageStoryData.id)
: undefined;
const isForwarding = forwardMessages.messageIds && forwardMessages.messageIds.includes(id); const isForwarding = forwardMessages.messageIds && forwardMessages.messageIds.includes(id);
const forceSenderName = !isChatWithSelf && isAnonymousOwnMessage(message); const forceSenderName = !isChatWithSelf && isAnonymousOwnMessage(message);
@ -1418,6 +1470,10 @@ export default memo(withGlobal<OwnProps>(
: undefined; : undefined;
const replyMessageSender = replyMessage && selectReplySender(global, replyMessage, Boolean(forwardInfo)); const replyMessageSender = replyMessage && selectReplySender(global, replyMessage, Boolean(forwardInfo));
const isReplyToTopicStart = replyMessage?.content.action?.type === 'topicCreate'; const isReplyToTopicStart = replyMessage?.content.action?.type === 'topicCreate';
const replyStory = replyToStoryId && replyToStoryUserId
? selectUserStory(global, replyToStoryUserId, replyToStoryId)
: undefined;
const storySender = replyToStoryUserId ? selectUser(global, replyToStoryUserId) : undefined;
const uploadProgress = selectUploadProgress(global, message); const uploadProgress = selectUploadProgress(global, message);
const isFocused = messageListType === 'thread' && ( const isFocused = messageListType === 'thread' && (
@ -1485,6 +1541,8 @@ export default memo(withGlobal<OwnProps>(
isThreadTop, isThreadTop,
replyMessage, replyMessage,
replyMessageSender, replyMessageSender,
replyStory,
storySender,
isInDocumentGroup, isInDocumentGroup,
isProtected: selectIsMessageProtected(global, message), isProtected: selectIsMessageProtected(global, message),
isChatProtected: selectIsChatProtected(global, chatId), isChatProtected: selectIsChatProtected(global, chatId),
@ -1536,6 +1594,7 @@ export default memo(withGlobal<OwnProps>(
hasLinkedChat: Boolean(chatFullInfo?.linkedChatId), hasLinkedChat: Boolean(chatFullInfo?.linkedChatId),
withReactionEffects: selectPerformanceSettingsValue(global, 'reactionEffects'), withReactionEffects: selectPerformanceSettingsValue(global, 'reactionEffects'),
withStickerEffects: selectPerformanceSettingsValue(global, 'stickerEffects'), withStickerEffects: selectPerformanceSettingsValue(global, 'stickerEffects'),
webPageStory,
isConnected, isConnected,
...((canShowSender || isLocation) && { sender }), ...((canShowSender || isLocation) && { sender }),
...(isOutgoing && { outgoingStatus: selectOutgoingStatus(global, message, messageListType === 'scheduled') }), ...(isOutgoing && { outgoingStatus: selectOutgoingStatus(global, message, messageListType === 'scheduled') }),

View File

@ -302,7 +302,7 @@ const MessageContextMenu: FC<OwnProps> = ({
return enableScrolling; return enableScrolling;
}, [withScroll]); }, [withScroll]);
const handleOpenReactionPicker = useLastCallback((position: IAnchorPosition) => { const handleOpenMessageReactionPicker = useLastCallback((position: IAnchorPosition) => {
onReactionPickerOpen!(position); onReactionPickerOpen!(position);
hideItems(); hideItems();
}); });
@ -337,7 +337,7 @@ const MessageContextMenu: FC<OwnProps> = ({
canBuyPremium={canBuyPremium} canBuyPremium={canBuyPremium}
isCurrentUserPremium={isCurrentUserPremium} isCurrentUserPremium={isCurrentUserPremium}
canPlayAnimatedEmojis={canPlayAnimatedEmojis} canPlayAnimatedEmojis={canPlayAnimatedEmojis}
onShowMore={handleOpenReactionPicker} onShowMore={handleOpenMessageReactionPicker}
className={buildClassName(areItemsHidden && 'ReactionSelector-hidden')} className={buildClassName(areItemsHidden && 'ReactionSelector-hidden')}
/> />
)} )}

View File

@ -5,13 +5,9 @@ import type { OwnProps } from './ReactionPicker';
import { Bundles } from '../../../util/moduleLoader'; import { Bundles } from '../../../util/moduleLoader';
import useModuleLoader from '../../../hooks/useModuleLoader'; import useModuleLoader from '../../../hooks/useModuleLoader';
interface LocalOwnProps { const ReactionPickerAsync: FC<OwnProps> = (props) => {
shouldLoad?: boolean; const { isOpen } = props;
} const ReactionPicker = useModuleLoader(Bundles.Extra, 'ReactionPicker', !isOpen);
const ReactionPickerAsync: FC<OwnProps & LocalOwnProps> = (props) => {
const { isOpen, shouldLoad } = props;
const ReactionPicker = useModuleLoader(Bundles.Extra, 'ReactionPicker', !isOpen && !shouldLoad);
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
return ReactionPicker ? <ReactionPicker {...props} /> : undefined; return ReactionPicker ? <ReactionPicker {...props} /> : undefined;

View File

@ -15,7 +15,7 @@
:global(body:not(.no-menu-blur)) & { :global(body:not(.no-menu-blur)) & {
--color-background: var(--color-background-compact-menu); --color-background: var(--color-background-compact-menu);
backdrop-filter: blur(10px); backdrop-filter: blur(25px);
} }
width: calc(var(--symbol-menu-width) + var(--scrollbar-width)); width: calc(var(--symbol-menu-width) + var(--scrollbar-width));
@ -50,6 +50,25 @@
} }
} }
.storyMenu {
--color-background-compact-menu: rgba(0, 0, 0, 0.3);
--color-text-secondary: #fff;
--color-text-secondary-rgb: 255, 255, 255;
--color-default-shadow: rgba(0, 0, 0, 0.3);
:global(.StickerButton.custom-emoji), :global(.sticker-set-cover) {
color: #fff;
}
transform-origin: 70% 100% !important;
@media (max-width: 440px) {
&:global(.bubble) {
transform-origin: 30% 100% !important;
}
}
}
.onlyReactions { .onlyReactions {
height: auto; height: auto;
transform-origin: 9rem 1.75rem !important; transform-origin: 9rem 1.75rem !important;

View File

@ -1,21 +1,28 @@
import React, { memo, useMemo, useRef } from '../../../lib/teact/teact'; import React, { memo, useMemo, useRef } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global'; import { getActions, getGlobal, withGlobal } from '../../../global';
import type { FC } from '../../../lib/teact/teact'; import type { FC } from '../../../lib/teact/teact';
import type { import type {
ApiMessage, ApiReaction, ApiSticker, ApiReactionCustomEmoji, ApiMessage, ApiReaction, ApiSticker, ApiReactionCustomEmoji, ApiStory, ApiStorySkipped,
ApiMessageEntity,
} from '../../../api/types'; } from '../../../api/types';
import type { IAnchorPosition } from '../../../types'; import type { IAnchorPosition } from '../../../types';
import { REM } from '../../common/helpers/mediaDimensions';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import { isUserId } from '../../../global/helpers'; import { isUserId } from '../../../global/helpers';
import { import {
selectChat, selectChatFullInfo, selectChatMessage, selectIsContextMenuTranslucent, selectTabState, selectChat, selectChatFullInfo, selectChatMessage, selectIsContextMenuTranslucent, selectIsCurrentUserPremium,
selectTabState, selectUserStory,
} from '../../../global/selectors'; } from '../../../global/selectors';
import parseMessageInput from '../../../util/parseMessageInput';
import { buildCustomEmojiHtml } from '../composer/helpers/customEmoji';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback'; import useLastCallback from '../../../hooks/useLastCallback';
import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev'; import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev';
import useMenuPosition from '../../../hooks/useMenuPosition'; import useMenuPosition from '../../../hooks/useMenuPosition';
import { getIsMobile } from '../../../hooks/useAppLayout';
import CustomEmojiPicker from '../../common/CustomEmojiPicker'; import CustomEmojiPicker from '../../common/CustomEmojiPicker';
import ReactionPickerLimited from './ReactionPickerLimited'; import ReactionPickerLimited from './ReactionPickerLimited';
@ -30,24 +37,35 @@ export type OwnProps = {
interface StateProps { interface StateProps {
withCustomReactions?: boolean; withCustomReactions?: boolean;
message?: ApiMessage; message?: ApiMessage;
story?: ApiStory | ApiStorySkipped;
isCurrentUserPremium?: boolean;
position?: IAnchorPosition; position?: IAnchorPosition;
isTranslucent?: boolean; isTranslucent?: boolean;
} }
const FULL_PICKER_SHIFT_DELTA = { x: -23, y: -64 }; const FULL_PICKER_SHIFT_DELTA = { x: -23, y: -64 };
const LIMITED_PICKER_SHIFT_DELTA = { x: -21, y: -10 }; const LIMITED_PICKER_SHIFT_DELTA = { x: -21, y: -10 };
const REACTION_SELECTOR_WIDTH = 16.375 * REM;
const ReactionPicker: FC<OwnProps & StateProps> = ({ const ReactionPicker: FC<OwnProps & StateProps> = ({
isOpen, isOpen,
message, message,
story,
position, position,
isTranslucent, isTranslucent,
isCurrentUserPremium,
withCustomReactions, withCustomReactions,
}) => { }) => {
const { toggleReaction, closeReactionPicker } = getActions(); const {
toggleReaction, closeReactionPicker, sendMessage, showNotification,
} = getActions();
const lang = useLang();
const renderedMessageId = useCurrentOrPrev(message?.id, true); const renderedMessageId = useCurrentOrPrev(message?.id, true);
const renderedChatId = useCurrentOrPrev(message?.chatId, true); const renderedChatId = useCurrentOrPrev(message?.chatId, true);
const renderedStoryUserId = useCurrentOrPrev(story?.userId, true);
const renderedStoryId = useCurrentOrPrev(story?.id);
const storedPosition = useCurrentOrPrev(position, true); const storedPosition = useCurrentOrPrev(position, true);
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
@ -56,14 +74,24 @@ const ReactionPicker: FC<OwnProps & StateProps> = ({
return undefined; return undefined;
} }
if (renderedStoryId) {
return storedPosition;
}
return { return {
x: storedPosition.x + (withCustomReactions ? FULL_PICKER_SHIFT_DELTA.x : LIMITED_PICKER_SHIFT_DELTA.x), x: storedPosition.x + (withCustomReactions ? FULL_PICKER_SHIFT_DELTA.x : LIMITED_PICKER_SHIFT_DELTA.x),
y: storedPosition.y + (withCustomReactions ? FULL_PICKER_SHIFT_DELTA.y : LIMITED_PICKER_SHIFT_DELTA.y), y: storedPosition.y + (withCustomReactions ? FULL_PICKER_SHIFT_DELTA.y : LIMITED_PICKER_SHIFT_DELTA.y),
}; };
}, [storedPosition, withCustomReactions]); }, [renderedStoryId, storedPosition, withCustomReactions]);
const getMenuElement = useLastCallback(() => menuRef.current); const getMenuElement = useLastCallback(() => menuRef.current);
const getLayout = useLastCallback(() => ({ withPortal: true, isDense: true })); const getLayout = useLastCallback(() => ({
withPortal: true,
isDense: !renderedStoryUserId,
deltaX: !getIsMobile() && menuRef.current
? -(menuRef.current.offsetWidth - REACTION_SELECTOR_WIDTH) / 2 - FULL_PICKER_SHIFT_DELTA.x / 2
: 0,
}));
const { const {
positionX, positionY, transformOriginX, transformOriginY, style, positionX, positionY, transformOriginX, transformOriginY, style,
} = useMenuPosition(renderingPosition, getTriggerElement, getRootElement, getMenuElement, getLayout); } = useMenuPosition(renderingPosition, getTriggerElement, getRootElement, getMenuElement, getLayout);
@ -93,6 +121,41 @@ const ReactionPicker: FC<OwnProps & StateProps> = ({
closeReactionPicker(); closeReactionPicker();
}); });
const handleStoryReactionSelect = useLastCallback((reaction: ApiReaction | ApiSticker) => {
let text: string | undefined;
let entities: ApiMessageEntity[] | undefined;
if ('emoticon' in reaction) {
text = reaction.emoticon;
} else {
const sticker = 'documentId' in reaction ? getGlobal().customEmojis.byId[reaction.documentId] : reaction;
if (!sticker) {
return;
}
if (!sticker.isFree && !isCurrentUserPremium) {
showNotification({
message: lang('UnlockPremiumEmojiHint'),
action: {
action: 'openPremiumModal',
payload: { initialSection: 'animated_emoji' },
},
actionText: lang('PremiumMore'),
});
closeReactionPicker();
return;
}
const customEmojiMessage = parseMessageInput(buildCustomEmojiHtml(sticker));
text = customEmojiMessage.text;
entities = customEmojiMessage.entities;
}
sendMessage({ text, entities, isReaction: true });
closeReactionPicker();
});
const selectedReactionIds = useMemo(() => { const selectedReactionIds = useMemo(() => {
return (message?.reactions?.results || []).reduce<string[]>((acc, { chosenOrder, reaction }) => { return (message?.reactions?.results || []).reduce<string[]>((acc, { chosenOrder, reaction }) => {
if (chosenOrder !== undefined) { if (chosenOrder !== undefined) {
@ -108,7 +171,11 @@ const ReactionPicker: FC<OwnProps & StateProps> = ({
isOpen={isOpen} isOpen={isOpen}
ref={menuRef} ref={menuRef}
className={buildClassName(styles.menu, 'ReactionPicker')} className={buildClassName(styles.menu, 'ReactionPicker')}
bubbleClassName={buildClassName(styles.menuContent, !withCustomReactions && styles.onlyReactions)} bubbleClassName={buildClassName(
styles.menuContent,
!withCustomReactions && !renderedStoryId && styles.onlyReactions,
renderedStoryId && styles.storyMenu,
)}
withPortal withPortal
noCompact noCompact
positionX={positionX} positionX={positionX}
@ -121,20 +188,20 @@ const ReactionPicker: FC<OwnProps & StateProps> = ({
> >
<CustomEmojiPicker <CustomEmojiPicker
idPrefix="message-emoji-set-" idPrefix="message-emoji-set-"
isHidden={!isOpen || !withCustomReactions} isHidden={!isOpen || !(withCustomReactions || renderedStoryId)}
loadAndPlay={Boolean(isOpen && withCustomReactions)} loadAndPlay={Boolean(isOpen && withCustomReactions)}
isReactionPicker isReactionPicker
className={!withCustomReactions ? styles.hidden : undefined} className={!withCustomReactions && !renderedStoryId ? styles.hidden : undefined}
selectedReactionIds={selectedReactionIds} selectedReactionIds={selectedReactionIds}
isTranslucent={isTranslucent} isTranslucent={isTranslucent}
onCustomEmojiSelect={handleToggleCustomReaction} onCustomEmojiSelect={renderedStoryId ? handleStoryReactionSelect : handleToggleCustomReaction}
onReactionSelect={handleToggleReaction} onReactionSelect={renderedStoryId ? handleStoryReactionSelect : handleToggleReaction}
/> />
{!withCustomReactions && Boolean(renderedChatId) && ( {!withCustomReactions && Boolean(renderedChatId) && (
<ReactionPickerLimited <ReactionPickerLimited
chatId={renderedChatId} chatId={renderedChatId}
loadAndPlay={isOpen} loadAndPlay={isOpen}
onReactionSelect={handleToggleReaction} onReactionSelect={renderedStoryId ? handleStoryReactionSelect : handleToggleReaction}
selectedReactionIds={selectedReactionIds} selectedReactionIds={selectedReactionIds}
/> />
)} )}
@ -144,22 +211,29 @@ const ReactionPicker: FC<OwnProps & StateProps> = ({
export default memo(withGlobal<OwnProps>((global): StateProps => { export default memo(withGlobal<OwnProps>((global): StateProps => {
const state = selectTabState(global); const state = selectTabState(global);
const { chatId, messageId, position } = state.reactionPicker || {}; const {
chatId, messageId, storyUserId, storyId, position,
} = state.reactionPicker || {};
const story = storyUserId && storyId
? selectUserStory(global, storyUserId, storyId) as ApiStory | ApiStorySkipped
: undefined;
const chat = chatId ? selectChat(global, chatId) : undefined; const chat = chatId ? selectChat(global, chatId) : undefined;
const chatFullInfo = chatId ? selectChatFullInfo(global, chatId) : undefined; const chatFullInfo = chatId ? selectChatFullInfo(global, chatId) : undefined;
const message = chatId && messageId ? selectChatMessage(global, chatId, messageId) : undefined; const message = chatId && messageId ? selectChatMessage(global, chatId, messageId) : undefined;
const isPrivateChat = chatId ? isUserId(chatId) : false; const isPrivateChat = chatId ? isUserId(chatId) : Boolean(storyUserId);
const areSomeReactionsAllowed = chatFullInfo?.enabledReactions?.type === 'some'; const areSomeReactionsAllowed = chatFullInfo?.enabledReactions?.type === 'some';
const areCustomReactionsAllowed = chatFullInfo?.enabledReactions?.type === 'all' const areCustomReactionsAllowed = chatFullInfo?.enabledReactions?.type === 'all'
&& chatFullInfo?.enabledReactions?.areCustomAllowed; && chatFullInfo?.enabledReactions?.areCustomAllowed;
return { return {
message, message,
story,
position, position,
withCustomReactions: chat?.isForbidden || areSomeReactionsAllowed withCustomReactions: chat?.isForbidden || areSomeReactionsAllowed
? false ? false
: areCustomReactionsAllowed || isPrivateChat, : areCustomReactionsAllowed || isPrivateChat,
isTranslucent: selectIsContextMenuTranslucent(global), isTranslucent: selectIsContextMenuTranslucent(global),
isCurrentUserPremium: selectIsCurrentUserPremium(global),
}; };
})(ReactionPicker)); })(ReactionPicker));

View File

@ -28,7 +28,7 @@
body:not(.no-menu-blur) & { body:not(.no-menu-blur) & {
background: var(--color-background-compact-menu); background: var(--color-background-compact-menu);
backdrop-filter: blur(10px); backdrop-filter: blur(25px);
} }
body.is-safari & { body.is-safari & {

View File

@ -0,0 +1,43 @@
import React, { memo } from '../../../lib/teact/teact';
import { withGlobal } from '../../../global';
import type {
ApiMessage, ApiTypeStory,
} from '../../../api/types';
import { selectUserStory } from '../../../global/selectors';
import BaseStory from './BaseStory';
interface OwnProps {
message: ApiMessage;
isProtected?: boolean;
}
interface StateProps {
story?: ApiTypeStory;
isConnected?: boolean;
}
function Story({
message, story, isProtected, isConnected,
}: OwnProps & StateProps) {
const { storyData } = message.content;
return (
<BaseStory
story={story || storyData}
isProtected={isProtected}
isConnected={isConnected}
/>
);
}
export default memo(withGlobal<OwnProps>((global, { message }): StateProps => {
const { id, userId } = message.content.storyData!;
return {
story: selectUserStory(global, userId, id),
isConnected: global.connectionState === 'connectionStateReady',
};
})(Story));

View File

@ -0,0 +1,102 @@
import React, { memo } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type {
ApiMessage, ApiTypeStory, ApiUser,
} from '../../../api/types';
import { getStoryMediaHash, getUserFirstOrLastName } from '../../../global/helpers';
import {
selectUser, selectUserStories, selectUserStory,
} from '../../../global/selectors';
import renderText from '../../common/helpers/renderText';
import buildClassName from '../../../util/buildClassName';
import useLastCallback from '../../../hooks/useLastCallback';
import useEnsureStory from '../../../hooks/useEnsureStory';
import useMedia from '../../../hooks/useMedia';
import useLang from '../../../hooks/useLang';
interface OwnProps {
message: ApiMessage;
}
interface StateProps {
story?: ApiTypeStory;
user?: ApiUser;
targetUser?: ApiUser;
isUnread?: boolean;
}
function StoryMention({
message, story, user, isUnread, targetUser,
}: OwnProps & StateProps) {
const { openStoryViewer } = getActions();
const lang = useLang();
const { storyData } = message.content;
const handleClick = useLastCallback(() => {
openStoryViewer({
userId: story!.userId,
storyId: story!.id,
isSingleUser: true,
isSingleStory: true,
});
});
const isDeleted = story && 'isDeleted' in story;
const isLoaded = story && 'content' in story;
const video = isLoaded ? story.content.video : undefined;
const imageHash = isLoaded
? getStoryMediaHash(story, 'pictogram')
: undefined;
const imgBlobUrl = useMedia(imageHash);
const thumbUrl = imgBlobUrl || video?.thumbnail?.dataUri;
useEnsureStory(storyData!.userId, storyData!.id, story);
function getTitle() {
if (user?.isSelf) {
return isDeleted
? lang('ExpiredStoryMentioned', getUserFirstOrLastName(targetUser))
: lang('StoryYouMentionedTitle', getUserFirstOrLastName(targetUser));
}
return isDeleted
? lang('ExpiredStoryMention')
: lang('StoryMentionedTitle', getUserFirstOrLastName(user));
}
return (
<div
className={buildClassName('action-message-story-mention', isUnread && 'is-unread', isLoaded && 'with-preview')}
tabIndex={0}
role="button"
onClick={isLoaded ? handleClick : undefined}
>
{isLoaded && (
<span className="story-media-wrapper">
{thumbUrl && (
<img src={thumbUrl} alt="" className="story-media" />
)}
</span>
)}
<span className="story-title">{renderText(getTitle(), ['emoji', 'simple_markdown'])}</span>
</div>
);
}
export default memo(withGlobal<OwnProps>((global, { message }): StateProps => {
const { id, userId } = message.content.storyData!;
const lastReadId = selectUserStories(global, userId)?.lastReadId;
return {
story: selectUserStory(global, userId, id),
user: selectUser(global, userId),
targetUser: selectUser(global, message.chatId),
isUnread: Boolean(lastReadId && lastReadId < id),
};
})(StoryMention));

View File

@ -20,6 +20,11 @@
background: var(--accent-color); background: var(--accent-color);
border-radius: 0.125rem; border-radius: 0.125rem;
} }
&.is-story {
display: flex;
flex-direction: column-reverse;
}
} }
&--quick-button { &--quick-button {

Some files were not shown because too many files have changed in this diff Show More