Introduce Stories (#3154)
This commit is contained in:
parent
e367d82c6c
commit
fc605350ea
@ -5,7 +5,12 @@ import { Api as GramJs } from '../../../lib/gramjs';
|
||||
import type { ApiAppConfig } from '../../types';
|
||||
import type { ApiLimitType } from '../../../global/types';
|
||||
import { buildJson } from './misc';
|
||||
import { DEFAULT_LIMITS } from '../../../config';
|
||||
import {
|
||||
DEFAULT_LIMITS,
|
||||
SERVICE_NOTIFICATIONS_USER_ID,
|
||||
STORY_EXPIRE_PERIOD,
|
||||
STORY_VIEWERS_EXPIRE_PERIOD,
|
||||
} from '../../../config';
|
||||
|
||||
type LimitType = 'default' | 'premium';
|
||||
type Limit = 'upload_max_fileparts' | 'stickers_faved_limit' | 'saved_gifs_limit' | 'dialog_filters_chats_limit' |
|
||||
@ -39,6 +44,11 @@ export interface GramJsAppConfig extends LimitsConfig {
|
||||
autoarchive_setting_available: boolean;
|
||||
// Forums
|
||||
topics_pinned_limit: number;
|
||||
// Stories
|
||||
stories_all_hidden?: boolean;
|
||||
story_expire_period: number;
|
||||
story_viewers_expire_period: number;
|
||||
stories_changelog_user_id?: number;
|
||||
}
|
||||
|
||||
function buildEmojiSounds(appConfig: GramJsAppConfig) {
|
||||
@ -100,5 +110,9 @@ export function buildAppConfig(json: GramJs.TypeJSONValue, hash: number): ApiApp
|
||||
chatlistJoined: getLimit(appConfig, 'chatlist_joined_limit', 'chatlistJoined'),
|
||||
},
|
||||
hash,
|
||||
areStoriesHidden: appConfig.stories_all_hidden,
|
||||
storyExpirePeriod: appConfig.story_expire_period ?? STORY_EXPIRE_PERIOD,
|
||||
storyViewersExpirePeriod: appConfig.story_viewers_expire_period ?? STORY_VIEWERS_EXPIRE_PERIOD,
|
||||
storyChangelogUserId: appConfig.stories_changelog_user_id?.toString() ?? SERVICE_NOTIFICATIONS_USER_ID,
|
||||
};
|
||||
}
|
||||
|
||||
@ -36,10 +36,17 @@ import type {
|
||||
ApiMessageExtendedMediaPreview,
|
||||
ApiReaction,
|
||||
ApiReactionEmoji,
|
||||
ApiTypeReplyTo,
|
||||
ApiStory,
|
||||
ApiStorySkipped,
|
||||
ApiWebPageStoryData,
|
||||
ApiMessageStoryData,
|
||||
ApiTypeStory,
|
||||
} from '../../types';
|
||||
import {
|
||||
ApiMessageEntityTypes,
|
||||
} from '../../types';
|
||||
import type { ApiPrivacySettings, PrivacyVisibility } from '../../../types';
|
||||
|
||||
import {
|
||||
DELETED_COMMENTS_CHANNEL_ID,
|
||||
@ -50,14 +57,19 @@ import {
|
||||
SUPPORTED_VIDEO_CONTENT_TYPES,
|
||||
VIDEO_WEBM_TYPE,
|
||||
} from '../../../config';
|
||||
import { pick } from '../../../util/iteratees';
|
||||
import { buildCollectionByCallback, pick } from '../../../util/iteratees';
|
||||
import { buildStickerFromDocument } from './symbols';
|
||||
import {
|
||||
buildApiPhoto, buildApiPhotoSize, buildApiThumbnailFromPath, buildApiThumbnailFromStripped,
|
||||
} from './common';
|
||||
import { interpolateArray } from '../../../util/waveform';
|
||||
import { buildPeer } from '../gramjsBuilders';
|
||||
import { addPhotoToLocalDb, resolveMessageApiChatId, serializeBytes } from '../helpers';
|
||||
import {
|
||||
addPhotoToLocalDb,
|
||||
addStoryToLocalDb,
|
||||
resolveMessageApiChatId,
|
||||
serializeBytes,
|
||||
} from '../helpers';
|
||||
import { buildApiPeerId, getApiChatIdFromMtpPeer, isPeerUser } from './peers';
|
||||
import { buildApiCallDiscardReason } from './calls';
|
||||
import { getEmojiOnlyCountForMessage } from '../../../global/helpers/getEmojiOnlyCountForMessage';
|
||||
@ -181,9 +193,23 @@ export function buildApiMessageWithChatId(
|
||||
const isInvoiceMedia = mtpMessage.media instanceof GramJs.MessageMediaInvoice
|
||||
&& Boolean(mtpMessage.media.extendedMedia);
|
||||
|
||||
const {
|
||||
replyToMsgId, replyToTopId, forumTopic, replyToPeerId,
|
||||
} = mtpMessage.replyTo || {};
|
||||
let replyToMsgId: number | undefined;
|
||||
let replyToTopId: number | undefined;
|
||||
let replyToStoryUserId: string | undefined;
|
||||
let replyToStoryId: number | undefined;
|
||||
let forumTopic: boolean | undefined;
|
||||
let replyToPeerId: GramJs.TypePeer | undefined;
|
||||
if (mtpMessage.replyTo instanceof GramJs.MessageReplyHeader) {
|
||||
replyToMsgId = mtpMessage.replyTo.replyToMsgId;
|
||||
replyToTopId = mtpMessage.replyTo.replyToTopId;
|
||||
forumTopic = mtpMessage.replyTo.forumTopic;
|
||||
replyToPeerId = mtpMessage.replyTo.replyToPeerId;
|
||||
}
|
||||
if (mtpMessage.replyTo instanceof GramJs.MessageReplyStoryHeader) {
|
||||
replyToStoryUserId = buildApiPeerId(mtpMessage.replyTo.userId, 'user');
|
||||
replyToStoryId = mtpMessage.replyTo.storyId;
|
||||
}
|
||||
|
||||
const isEdited = mtpMessage.editDate && !mtpMessage.editHide;
|
||||
const {
|
||||
inlineButtons, keyboardButtons, keyboardPlaceholder, isKeyboardSingleUse, isKeyboardSelective,
|
||||
@ -219,6 +245,7 @@ export function buildApiMessageWithChatId(
|
||||
...(replyToPeerId && { replyToChatId: getApiChatIdFromMtpPeer(replyToPeerId) }),
|
||||
...(replyToTopId && { replyToTopMessageId: replyToTopId }),
|
||||
...(forwardInfo && { forwardInfo }),
|
||||
...(replyToStoryUserId && { replyToStoryUserId, replyToStoryId }),
|
||||
...(isEdited && { isEdited }),
|
||||
...(mtpMessage.editDate && { editDate: mtpMessage.editDate }),
|
||||
...(isMediaUnread && { isMediaUnread }),
|
||||
@ -399,7 +426,8 @@ export function buildMessageMediaContent(media: GramJs.TypeMessageMedia): ApiMes
|
||||
if (photo) return { photo };
|
||||
|
||||
const video = buildVideo(media);
|
||||
if (video) return { video };
|
||||
const altVideo = buildAltVideo(media);
|
||||
if (video) return { video, altVideo };
|
||||
|
||||
const audio = buildAudio(media);
|
||||
if (audio) return { audio };
|
||||
@ -428,6 +456,9 @@ export function buildMessageMediaContent(media: GramJs.TypeMessageMedia): ApiMes
|
||||
const game = buildGameFromMedia(media);
|
||||
if (game) return { game };
|
||||
|
||||
const storyData = buildMessageStoryData(media);
|
||||
if (storyData) return { storyData };
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@ -499,6 +530,7 @@ export function buildVideoFromDocument(document: GramJs.Document, isSpoiler?: bo
|
||||
h: height,
|
||||
supportsStreaming = false,
|
||||
roundMessage: isRound = false,
|
||||
nosound,
|
||||
} = videoAttr;
|
||||
|
||||
return {
|
||||
@ -514,6 +546,7 @@ export function buildVideoFromDocument(document: GramJs.Document, isSpoiler?: bo
|
||||
thumbnail: buildApiThumbnailFromStripped(thumbs),
|
||||
size: size.toJSNumber(),
|
||||
isSpoiler,
|
||||
...(nosound && { noSound: true }),
|
||||
};
|
||||
}
|
||||
|
||||
@ -529,6 +562,18 @@ function buildVideo(media: GramJs.TypeMessageMedia): ApiVideo | undefined {
|
||||
return buildVideoFromDocument(media.document, media.spoiler);
|
||||
}
|
||||
|
||||
function buildAltVideo(media: GramJs.TypeMessageMedia): ApiVideo | undefined {
|
||||
if (
|
||||
!(media instanceof GramJs.MessageMediaDocument)
|
||||
|| !(media.altDocument instanceof GramJs.Document)
|
||||
|| !media.altDocument.mimeType.startsWith('video')
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return buildVideoFromDocument(media.altDocument, media.spoiler);
|
||||
}
|
||||
|
||||
function buildAudio(media: GramJs.TypeMessageMedia): ApiAudio | undefined {
|
||||
if (
|
||||
!(media instanceof GramJs.MessageMediaDocument)
|
||||
@ -856,12 +901,28 @@ export function buildWebPage(media: GramJs.TypeMessageMedia): ApiWebPage | undef
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { id, photo, document } = media.webpage;
|
||||
const {
|
||||
id, photo, document, attributes,
|
||||
} = media.webpage;
|
||||
|
||||
let video;
|
||||
if (document instanceof GramJs.Document && document.mimeType.startsWith('video/')) {
|
||||
video = buildVideoFromDocument(document);
|
||||
}
|
||||
let story: ApiWebPageStoryData | undefined;
|
||||
const attributeStory = attributes
|
||||
?.find((a: any): a is GramJs.WebPageAttributeStory => a instanceof GramJs.WebPageAttributeStory);
|
||||
if (attributeStory) {
|
||||
const userId = buildApiPeerId(attributeStory.userId, 'user');
|
||||
story = {
|
||||
id: attributeStory.id,
|
||||
userId,
|
||||
};
|
||||
|
||||
if (attributeStory.story instanceof GramJs.StoryItem) {
|
||||
addStoryToLocalDb(attributeStory.story, userId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: Number(id),
|
||||
@ -877,9 +938,20 @@ export function buildWebPage(media: GramJs.TypeMessageMedia): ApiWebPage | undef
|
||||
photo: photo instanceof GramJs.Photo ? buildApiPhoto(photo) : undefined,
|
||||
document: !video && document ? buildApiDocument(document) : undefined,
|
||||
video,
|
||||
story,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMessageStoryData(media: GramJs.TypeMessageMedia): ApiMessageStoryData | undefined {
|
||||
if (!(media instanceof GramJs.MessageMediaStory)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const userId = buildApiPeerId(media.userId, 'user');
|
||||
|
||||
return { id: media.id, userId, ...(media.viaMention && { isMention: true }) };
|
||||
}
|
||||
|
||||
function buildAction(
|
||||
action: GramJs.TypeMessageAction,
|
||||
senderId: string | undefined,
|
||||
@ -1284,8 +1356,7 @@ export function buildLocalMessage(
|
||||
chat: ApiChat,
|
||||
text?: string,
|
||||
entities?: ApiMessageEntity[],
|
||||
replyingTo?: number,
|
||||
replyingToTopId?: number,
|
||||
replyingTo?: ApiTypeReplyTo,
|
||||
attachment?: ApiAttachment,
|
||||
sticker?: ApiSticker,
|
||||
gif?: ApiVideo,
|
||||
@ -1294,12 +1365,27 @@ export function buildLocalMessage(
|
||||
groupedId?: string,
|
||||
scheduledAt?: number,
|
||||
sendAs?: ApiChat | ApiUser,
|
||||
story?: ApiStory | ApiStorySkipped,
|
||||
): ApiMessage {
|
||||
const localId = getNextLocalMessageId(chat.lastMessage?.id);
|
||||
const media = attachment && buildUploadingMedia(attachment);
|
||||
const isChannel = chat.type === 'chatTypeChannel';
|
||||
const isForum = chat.isForum;
|
||||
|
||||
let replyToMessageId: number | undefined;
|
||||
let replyingToTopId: number | undefined;
|
||||
let replyToStoryUserId: string | undefined;
|
||||
let replyToStoryId: number | undefined;
|
||||
if (replyingTo) {
|
||||
if ('replyingTo' in replyingTo) {
|
||||
replyToMessageId = replyingTo.replyingTo;
|
||||
replyingToTopId = replyingTo.replyingToTopId;
|
||||
} else {
|
||||
replyToStoryUserId = replyingTo.userId;
|
||||
replyToStoryId = replyingTo.storyId;
|
||||
}
|
||||
}
|
||||
|
||||
const message = {
|
||||
id: localId,
|
||||
chatId: chat.id,
|
||||
@ -1315,13 +1401,15 @@ export function buildLocalMessage(
|
||||
...(gif && { video: gif }),
|
||||
...(poll && buildNewPoll(poll, localId)),
|
||||
...(contact && { contact }),
|
||||
...(story && { storyData: story }),
|
||||
},
|
||||
date: scheduledAt || Math.round(Date.now() / 1000) + getServerTimeOffset(),
|
||||
isOutgoing: !isChannel,
|
||||
senderId: sendAs?.id || currentUserId,
|
||||
...(replyingTo && { replyToMessageId: replyingTo }),
|
||||
...(replyToMessageId && { replyToMessageId }),
|
||||
...(replyingToTopId && { replyToTopMessageId: replyingToTopId }),
|
||||
...((replyingTo || replyingToTopId) && isForum && { isTopicReply: true }),
|
||||
...((replyToMessageId || replyingToTopId) && isForum && { isTopicReply: true }),
|
||||
...(replyToStoryUserId && { replyToStoryUserId, replyToStoryId }),
|
||||
...(groupedId && {
|
||||
groupedId,
|
||||
...(media && (media.photo || media.video) && { isInAlbum: true }),
|
||||
@ -1627,3 +1715,111 @@ export function buildApiFormattedText(textWithEntities: GramJs.TextWithEntities)
|
||||
entities: entities.map(buildApiMessageEntity),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildApiUsersStories(userStories: GramJs.UserStories) {
|
||||
const userId = buildApiPeerId(userStories.userId, 'user');
|
||||
|
||||
return buildCollectionByCallback(userStories.stories, (story) => [story.id, buildApiStory(userId, story)]);
|
||||
}
|
||||
|
||||
export function buildApiStory(userId: string, story: GramJs.TypeStoryItem): ApiTypeStory {
|
||||
if (story instanceof GramJs.StoryItemDeleted) {
|
||||
return {
|
||||
id: story.id,
|
||||
userId,
|
||||
isDeleted: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (story instanceof GramJs.StoryItemSkipped) {
|
||||
const {
|
||||
id, date, expireDate, closeFriends,
|
||||
} = story;
|
||||
|
||||
return {
|
||||
id,
|
||||
userId,
|
||||
...(closeFriends && { isForCloseFriends: true }),
|
||||
date,
|
||||
expireDate,
|
||||
};
|
||||
}
|
||||
|
||||
const {
|
||||
edited, pinned, expireDate, id, date, caption,
|
||||
entities, media, privacy, views,
|
||||
public: isPublic, noforwards, closeFriends, contacts, selectedContacts,
|
||||
} = story;
|
||||
|
||||
const content: ApiMessage['content'] = {
|
||||
...buildMessageMediaContent(media),
|
||||
};
|
||||
|
||||
if (caption) {
|
||||
content.text = buildMessageTextContent(caption, entities);
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
userId,
|
||||
date,
|
||||
expireDate,
|
||||
content,
|
||||
...(isPublic && { isPublic }),
|
||||
...(edited && { isEdited: true }),
|
||||
...(pinned && { isPinned: true }),
|
||||
...(contacts && { isForContacts: true }),
|
||||
...(selectedContacts && { isForSelectedContacts: true }),
|
||||
...(closeFriends && { isForCloseFriends: true }),
|
||||
...(noforwards && { noForwards: true }),
|
||||
...(views?.viewsCount && { viewsCount: views.viewsCount }),
|
||||
...(views?.recentViewers && {
|
||||
recentViewerIds: views.recentViewers.map((viewerId) => buildApiPeerId(viewerId, 'user')),
|
||||
}),
|
||||
...(privacy && { visibility: buildPrivacyRules(privacy) }),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPrivacyRules(rules: GramJs.TypePrivacyRule[]): ApiPrivacySettings {
|
||||
let visibility: PrivacyVisibility | undefined;
|
||||
let allowUserIds: string[] | undefined;
|
||||
let allowChatIds: string[] | undefined;
|
||||
let blockUserIds: string[] | undefined;
|
||||
let blockChatIds: string[] | undefined;
|
||||
|
||||
rules.forEach((rule) => {
|
||||
if (rule instanceof GramJs.PrivacyValueAllowAll) {
|
||||
visibility ||= 'everybody';
|
||||
} else if (rule instanceof GramJs.PrivacyValueAllowContacts) {
|
||||
visibility ||= 'contacts';
|
||||
} else if (rule instanceof GramJs.PrivacyValueAllowCloseFriends) {
|
||||
visibility ||= 'closeFriends';
|
||||
} else if (rule instanceof GramJs.PrivacyValueDisallowContacts) {
|
||||
visibility ||= 'nonContacts';
|
||||
} else if (rule instanceof GramJs.PrivacyValueDisallowAll) {
|
||||
visibility ||= 'nobody';
|
||||
} else if (rule instanceof GramJs.PrivacyValueAllowUsers) {
|
||||
visibility ||= 'selectedContacts';
|
||||
allowUserIds = rule.users.map((chatId) => buildApiPeerId(chatId, 'user'));
|
||||
} else if (rule instanceof GramJs.PrivacyValueDisallowUsers) {
|
||||
blockUserIds = rule.users.map((chatId) => buildApiPeerId(chatId, 'user'));
|
||||
} else if (rule instanceof GramJs.PrivacyValueAllowChatParticipants) {
|
||||
allowChatIds = rule.chats.map((chatId) => buildApiPeerId(chatId, 'chat'));
|
||||
} else if (rule instanceof GramJs.PrivacyValueDisallowChatParticipants) {
|
||||
blockChatIds = rule.chats.map((chatId) => buildApiPeerId(chatId, 'chat'));
|
||||
}
|
||||
});
|
||||
|
||||
if (!visibility) {
|
||||
// Disallow by default
|
||||
visibility = 'nobody';
|
||||
}
|
||||
|
||||
return {
|
||||
visibility,
|
||||
allowUserIds: allowUserIds || [],
|
||||
allowChatIds: allowChatIds || [],
|
||||
blockUserIds: blockUserIds || [],
|
||||
blockChatIds: blockChatIds || [],
|
||||
};
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ import { Api as GramJs } from '../../../lib/gramjs';
|
||||
import type {
|
||||
ApiConfig, ApiCountry, ApiSession, ApiUrlAuthResult, ApiWallpaper, ApiWebSession, ApiLangString,
|
||||
} from '../../types';
|
||||
import type { ApiPrivacySettings, ApiPrivacyKey, PrivacyVisibility } from '../../../types';
|
||||
import type { ApiPrivacyKey } from '../../../types';
|
||||
|
||||
import { buildApiDocument, buildApiReaction } from './messages';
|
||||
import { buildApiPeerId, getApiChatIdFromMtpPeer } from './peers';
|
||||
@ -81,47 +81,6 @@ export function buildPrivacyKey(key: GramJs.TypePrivacyKey): ApiPrivacyKey | und
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function buildPrivacyRules(rules: GramJs.TypePrivacyRule[]): ApiPrivacySettings {
|
||||
let visibility: PrivacyVisibility | undefined;
|
||||
let allowUserIds: string[] | undefined;
|
||||
let allowChatIds: string[] | undefined;
|
||||
let blockUserIds: string[] | undefined;
|
||||
let blockChatIds: string[] | undefined;
|
||||
|
||||
rules.forEach((rule) => {
|
||||
if (rule instanceof GramJs.PrivacyValueAllowAll) {
|
||||
visibility = visibility || 'everybody';
|
||||
} else if (rule instanceof GramJs.PrivacyValueAllowContacts) {
|
||||
visibility = visibility || 'contacts';
|
||||
} else if (rule instanceof GramJs.PrivacyValueDisallowContacts) {
|
||||
visibility = visibility || 'nonContacts';
|
||||
} else if (rule instanceof GramJs.PrivacyValueDisallowAll) {
|
||||
visibility = visibility || 'nobody';
|
||||
} else if (rule instanceof GramJs.PrivacyValueAllowUsers) {
|
||||
allowUserIds = rule.users.map((chatId) => buildApiPeerId(chatId, 'user'));
|
||||
} else if (rule instanceof GramJs.PrivacyValueDisallowUsers) {
|
||||
blockUserIds = rule.users.map((chatId) => buildApiPeerId(chatId, 'user'));
|
||||
} else if (rule instanceof GramJs.PrivacyValueAllowChatParticipants) {
|
||||
allowChatIds = rule.chats.map((chatId) => buildApiPeerId(chatId, 'chat'));
|
||||
} else if (rule instanceof GramJs.PrivacyValueDisallowChatParticipants) {
|
||||
blockChatIds = rule.chats.map((chatId) => buildApiPeerId(chatId, 'chat'));
|
||||
}
|
||||
});
|
||||
|
||||
if (!visibility) {
|
||||
// disallow by default.
|
||||
visibility = 'nobody';
|
||||
}
|
||||
|
||||
return {
|
||||
visibility,
|
||||
allowUserIds: allowUserIds || [],
|
||||
allowChatIds: allowChatIds || [],
|
||||
blockUserIds: blockUserIds || [],
|
||||
blockChatIds: blockChatIds || [],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildApiNotifyException(
|
||||
notifySettings: GramJs.TypePeerNotifySettings, peer: GramJs.TypePeer,
|
||||
) {
|
||||
|
||||
@ -16,7 +16,7 @@ export function buildApiUserFullInfo(mtpUserFull: GramJs.users.UserFull): ApiUse
|
||||
fullUser: {
|
||||
about, commonChatsCount, pinnedMsgId, botInfo, blocked,
|
||||
profilePhoto, voiceMessagesForbidden, premiumGifts,
|
||||
fallbackPhoto, personalPhoto, translationsDisabled,
|
||||
fallbackPhoto, personalPhoto, translationsDisabled, storiesPinnedAvailable,
|
||||
},
|
||||
users,
|
||||
} = mtpUserFull;
|
||||
@ -29,6 +29,7 @@ export function buildApiUserFullInfo(mtpUserFull: GramJs.users.UserFull): ApiUse
|
||||
pinnedMessageId: pinnedMsgId,
|
||||
isBlocked: Boolean(blocked),
|
||||
noVoiceMessages: voiceMessagesForbidden,
|
||||
hasPinnedStories: Boolean(storiesPinnedAvailable),
|
||||
isTranslationDisabled: translationsDisabled,
|
||||
...(profilePhoto instanceof GramJs.Photo && { profilePhoto: buildApiPhoto(profilePhoto) }),
|
||||
...(fallbackPhoto instanceof GramJs.Photo && { fallbackPhoto: buildApiPhoto(fallbackPhoto) }),
|
||||
@ -44,7 +45,7 @@ export function buildApiUser(mtpUser: GramJs.TypeUser): ApiUser | undefined {
|
||||
}
|
||||
|
||||
const {
|
||||
id, firstName, lastName, fake, scam,
|
||||
id, firstName, lastName, fake, scam, support, closeFriend, storiesUnavailable, storiesMaxId,
|
||||
} = mtpUser;
|
||||
const hasVideoAvatar = mtpUser.photo instanceof GramJs.UserProfilePhoto
|
||||
? Boolean(mtpUser.photo.hasVideo)
|
||||
@ -63,6 +64,8 @@ export function buildApiUser(mtpUser: GramJs.TypeUser): ApiUser | undefined {
|
||||
...(mtpUser.self && { isSelf: true }),
|
||||
isPremium: Boolean(mtpUser.premium),
|
||||
...(mtpUser.verified && { isVerified: true }),
|
||||
...(closeFriend && { isCloseFriend: true }),
|
||||
...(support && { isSupport: true }),
|
||||
...((mtpUser.contact || mtpUser.mutualContact) && { isContact: true }),
|
||||
type: userType,
|
||||
firstName,
|
||||
@ -75,6 +78,9 @@ export function buildApiUser(mtpUser: GramJs.TypeUser): ApiUser | undefined {
|
||||
...(avatarHash && { avatarHash }),
|
||||
emojiStatus,
|
||||
hasVideoAvatar,
|
||||
areStoriesHidden: Boolean(mtpUser.storiesHidden),
|
||||
maxStoryId: storiesMaxId,
|
||||
hasStories: Boolean(storiesMaxId) && !storiesUnavailable,
|
||||
...(mtpUser.bot && mtpUser.botInlinePlaceholder && { botPlaceholder: mtpUser.botInlinePlaceholder }),
|
||||
...(mtpUser.bot && mtpUser.botAttachMenu && { isAttachBot: mtpUser.botAttachMenu }),
|
||||
};
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import BigInt from 'big-integer';
|
||||
import { Api as GramJs } from '../../../lib/gramjs';
|
||||
|
||||
import type { ApiPrivacyKey } from '../../../types';
|
||||
import type { ApiPrivacyKey, PrivacyVisibility } from '../../../types';
|
||||
|
||||
import { generateRandomBytes, readBigIntFromBuffer } from '../../../lib/gramjs/Helpers';
|
||||
import type {
|
||||
@ -24,6 +24,10 @@ import type {
|
||||
ApiReaction,
|
||||
ApiFormattedText,
|
||||
ApiBotApp,
|
||||
ApiStory,
|
||||
ApiStorySkipped,
|
||||
ApiUser,
|
||||
ApiTypeReplyTo,
|
||||
} from '../../types';
|
||||
import {
|
||||
ApiMessageEntityTypes,
|
||||
@ -282,6 +286,14 @@ export function buildFilterFromApiFolder(folder: ApiChatFolder): GramJs.DialogFi
|
||||
});
|
||||
}
|
||||
|
||||
export function buildInputStory(story: ApiStory | ApiStorySkipped) {
|
||||
const user = localDb.users[story.userId];
|
||||
return new GramJs.InputMediaStory({
|
||||
userId: new GramJs.InputUser({ userId: BigInt(user!.id), accessHash: user!.accessHash! }),
|
||||
id: story.id,
|
||||
});
|
||||
}
|
||||
|
||||
export function generateRandomBigInt() {
|
||||
return readBigIntFromBuffer(generateRandomBytes(8), true, true);
|
||||
}
|
||||
@ -625,3 +637,82 @@ export function buildInputBotApp(app: ApiBotApp) {
|
||||
accessHash: BigInt(app.accessHash),
|
||||
});
|
||||
}
|
||||
|
||||
export function buildInputReplyToMessage(replyToMsgId: number, topMsgId?: number) {
|
||||
return new GramJs.InputReplyToMessage({
|
||||
replyToMsgId,
|
||||
topMsgId,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildInputReplyToStory(userId: string, storyId: number) {
|
||||
return new GramJs.InputReplyToStory({
|
||||
userId: buildInputPeerFromLocalDb(userId)!,
|
||||
storyId,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildInputReplyTo(replyingTo: ApiTypeReplyTo) {
|
||||
return 'replyingTo' in replyingTo
|
||||
? buildInputReplyToMessage(replyingTo.replyingTo, replyingTo.replyingToTopId)
|
||||
: buildInputReplyToStory(replyingTo.userId, replyingTo.storyId);
|
||||
}
|
||||
|
||||
export function buildInputPrivacyRules(
|
||||
visibility: PrivacyVisibility,
|
||||
allowedUserList?: ApiUser[],
|
||||
deniedUserList?: ApiUser[],
|
||||
) {
|
||||
const rules: GramJs.TypeInputPrivacyRule[] = [];
|
||||
|
||||
switch (visibility) {
|
||||
case 'everybody':
|
||||
rules.push(new GramJs.InputPrivacyValueAllowAll());
|
||||
break;
|
||||
|
||||
case 'contacts': {
|
||||
rules.push(new GramJs.InputPrivacyValueAllowContacts());
|
||||
|
||||
const users = deniedUserList?.reduce<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;
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Api as GramJs } from '../../lib/gramjs';
|
||||
import localDb from './localDb';
|
||||
import type { StoryRepairInfo } from './localDb';
|
||||
import { buildApiPeerId, getApiChatIdFromMtpPeer } from './apiBuilders/peers';
|
||||
|
||||
const LOG_BACKGROUND = '#111111DD';
|
||||
@ -78,6 +79,36 @@ export function addMessageToLocalDb(message: GramJs.Message | GramJs.MessageServ
|
||||
}
|
||||
}
|
||||
|
||||
export function addStoryToLocalDb(story: GramJs.TypeStoryItem, userId: string) {
|
||||
if (!(story instanceof GramJs.StoryItem)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storyData = {
|
||||
id: story.id,
|
||||
userId,
|
||||
};
|
||||
|
||||
if (story.media instanceof GramJs.MessageMediaPhoto) {
|
||||
const photo = story.media.photo as GramJs.Photo & StoryRepairInfo;
|
||||
photo.storyData = storyData;
|
||||
addPhotoToLocalDb(photo);
|
||||
}
|
||||
if (story.media instanceof GramJs.MessageMediaDocument) {
|
||||
if (story.media.document instanceof GramJs.Document) {
|
||||
const doc = story.media.document as GramJs.Document & StoryRepairInfo;
|
||||
doc.storyData = storyData;
|
||||
localDb.documents[String(story.media.document.id)] = doc;
|
||||
}
|
||||
|
||||
if (story.media.altDocument instanceof GramJs.Document) {
|
||||
const doc = story.media.altDocument as GramJs.Document & StoryRepairInfo;
|
||||
doc.storyData = storyData;
|
||||
localDb.documents[String(story.media.altDocument.id)] = doc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function addPhotoToLocalDb(photo: GramJs.TypePhoto) {
|
||||
if (photo instanceof GramJs.Photo) {
|
||||
localDb.photos[String(photo.id)] = photo;
|
||||
|
||||
@ -8,16 +8,22 @@ import { throttle } from '../../util/schedulers';
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
const IS_MULTITAB_SUPPORTED = 'BroadcastChannel' in self;
|
||||
|
||||
export type StoryRepairInfo = {
|
||||
storyData?: {
|
||||
userId: string;
|
||||
id: number;
|
||||
};
|
||||
};
|
||||
|
||||
export interface LocalDb {
|
||||
// Used for loading avatars and media through in-memory Gram JS instances.
|
||||
chats: Record<string, GramJs.Chat | GramJs.Channel>;
|
||||
users: Record<string, GramJs.User>;
|
||||
messages: Record<string, GramJs.Message | GramJs.MessageService>;
|
||||
documents: Record<string, GramJs.Document>;
|
||||
documents: Record<string, GramJs.Document & StoryRepairInfo>;
|
||||
stickerSets: Record<string, GramJs.StickerSet>;
|
||||
photos: Record<string, GramJs.Photo>;
|
||||
photos: Record<string, GramJs.Photo & StoryRepairInfo>;
|
||||
webDocuments: Record<string, GramJs.TypeWebDocument>;
|
||||
|
||||
commonBoxState: Record<string, number>;
|
||||
channelPtsById: Record<string, number>;
|
||||
}
|
||||
@ -79,7 +85,7 @@ function convertToVirtualClass(value: any): any {
|
||||
|
||||
function createLocalDbInitial(initial?: LocalDb): LocalDb {
|
||||
return [
|
||||
'localMessages', 'chats', 'users', 'messages', 'documents', 'stickerSets', 'photos', 'webDocuments',
|
||||
'localMessages', 'chats', 'users', 'messages', 'documents', 'stickerSets', 'photos', 'webDocuments', 'stories',
|
||||
'commonBoxState', 'channelPtsById',
|
||||
]
|
||||
.reduce((acc: Record<string, any>, key) => {
|
||||
|
||||
@ -10,7 +10,12 @@ import localDb from '../localDb';
|
||||
import { WEB_APP_PLATFORM } from '../../../config';
|
||||
import { invokeRequest } from './client';
|
||||
import {
|
||||
buildInputBotApp, buildInputEntity, buildInputPeer, buildInputThemeParams, generateRandomBigInt,
|
||||
buildInputBotApp,
|
||||
buildInputEntity,
|
||||
buildInputPeer,
|
||||
buildInputReplyToMessage,
|
||||
buildInputThemeParams,
|
||||
generateRandomBigInt,
|
||||
} from '../gramjsBuilders';
|
||||
import { buildApiUser } from '../apiBuilders/users';
|
||||
import {
|
||||
@ -189,13 +194,12 @@ export async function requestWebView({
|
||||
silent: isSilent || undefined,
|
||||
peer: buildInputPeer(peer.id, peer.accessHash),
|
||||
bot: buildInputPeer(bot.id, bot.accessHash),
|
||||
replyToMsgId: replyToMessageId,
|
||||
url,
|
||||
startParam,
|
||||
themeParams: theme ? buildInputThemeParams(theme) : undefined,
|
||||
fromBotMenu: isFromBotMenu || undefined,
|
||||
platform: WEB_APP_PLATFORM,
|
||||
...(threadId && { topMsgId: threadId }),
|
||||
...(replyToMessageId && { replyTo: buildInputReplyToMessage(replyToMessageId, threadId) }),
|
||||
...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }),
|
||||
}));
|
||||
|
||||
@ -294,8 +298,7 @@ export function prolongWebView({
|
||||
peer: buildInputPeer(peer.id, peer.accessHash),
|
||||
bot: buildInputPeer(bot.id, bot.accessHash),
|
||||
queryId: BigInt(queryId),
|
||||
replyToMsgId: replyToMessageId,
|
||||
...(threadId && { topMsgId: threadId }),
|
||||
...(replyToMessageId && { replyTo: buildInputReplyToMessage(replyToMessageId, threadId) }),
|
||||
...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }),
|
||||
}));
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import {
|
||||
sessions, Api as GramJs, connection,
|
||||
sessions, Api as GramJs,
|
||||
} from '../../../lib/gramjs';
|
||||
import TelegramClient from '../../../lib/gramjs/client/TelegramClient';
|
||||
|
||||
@ -26,7 +26,8 @@ import { buildApiUser, buildApiUserFullInfo } from '../apiBuilders/users';
|
||||
import localDb, { clearLocalDb } from '../localDb';
|
||||
import { buildApiPeerId } from '../apiBuilders/peers';
|
||||
import {
|
||||
addMessageToLocalDb, addUserToLocalDb, isResponseUpdate, log,
|
||||
addEntitiesToLocalDb,
|
||||
addMessageToLocalDb, addStoryToLocalDb, addUserToLocalDb, isResponseUpdate, log,
|
||||
} from '../helpers';
|
||||
import { ChatAbortController } from '../ChatAbortController';
|
||||
import {
|
||||
@ -52,7 +53,6 @@ const ABORT_CONTROLLERS = new Map<string, AbortController>();
|
||||
|
||||
let onUpdate: OnApiUpdate;
|
||||
let client: TelegramClient;
|
||||
let isConnected = false;
|
||||
let currentUserId: string | undefined;
|
||||
|
||||
export async function init(_onUpdate: OnApiUpdate, initialArgs: ApiInitialArgs) {
|
||||
@ -197,9 +197,7 @@ type UpdateConfig = GramJs.UpdateConfig & { _entities?: (GramJs.TypeUser | GramJ
|
||||
export function handleGramJsUpdate(update: any) {
|
||||
processUpdate(update);
|
||||
|
||||
if (update instanceof connection.UpdateConnectionState) {
|
||||
isConnected = update.state === connection.UpdateConnectionState.connected;
|
||||
} else if (update instanceof GramJs.UpdatesTooLong) {
|
||||
if (update instanceof GramJs.UpdatesTooLong) {
|
||||
void handleTerminatedSession();
|
||||
} else {
|
||||
const updates = 'updates' in update ? update.updates : [update];
|
||||
@ -314,16 +312,18 @@ export function invokeRequestBeacon<T extends GramJs.AnyRequest>(
|
||||
}
|
||||
|
||||
export async function downloadMedia(
|
||||
args: { url: string; mediaFormat: ApiMediaFormat; start?: number; end?: number; isHtmlAllowed?: boolean },
|
||||
args: {
|
||||
url: string; mediaFormat: ApiMediaFormat; start?: number; end?: number; isHtmlAllowed?: boolean;
|
||||
},
|
||||
onProgress?: ApiOnProgress,
|
||||
) {
|
||||
try {
|
||||
return (await downloadMediaWithClient(args, client, isConnected, onProgress));
|
||||
return (await downloadMediaWithClient(args, client, onProgress));
|
||||
} catch (err: any) {
|
||||
if (err.message.startsWith('FILE_REFERENCE')) {
|
||||
const isFileReferenceRepaired = await repairFileReference({ url: args.url });
|
||||
if (isFileReferenceRepaired) {
|
||||
return downloadMediaWithClient(args, client, isConnected, onProgress);
|
||||
return downloadMediaWithClient(args, client, onProgress);
|
||||
}
|
||||
|
||||
if (DEBUG) {
|
||||
@ -439,8 +439,21 @@ export async function repairFileReference({
|
||||
entityType, entityId, mediaMatchType,
|
||||
} = parsed;
|
||||
|
||||
if (mediaMatchType === 'file') {
|
||||
return false;
|
||||
if (mediaMatchType === 'document' || mediaMatchType === 'photo') {
|
||||
const entity = mediaMatchType === 'document' ? localDb.documents[entityId] : localDb.photos[entityId];
|
||||
if (!entity.storyData) return false;
|
||||
const user = localDb.users[entity.storyData.userId];
|
||||
if (!user?.accessHash) return false;
|
||||
|
||||
const result = await invokeRequest(new GramJs.stories.GetStoriesByID({
|
||||
userId: new GramJs.InputUser({ userId: user.id, accessHash: user.accessHash }),
|
||||
id: [entity.storyData.id],
|
||||
}));
|
||||
if (!result) return false;
|
||||
|
||||
addEntitiesToLocalDb(result.users);
|
||||
result.stories.forEach((story) => addStoryToLocalDb(story, entity.storyData!.userId));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (entityType === 'msg') {
|
||||
@ -470,6 +483,8 @@ export async function repairFileReference({
|
||||
|
||||
const message = result.messages[0];
|
||||
if (message instanceof GramJs.MessageEmpty) return false;
|
||||
addEntitiesToLocalDb(result.users);
|
||||
addEntitiesToLocalDb(result.chats);
|
||||
addMessageToLocalDb(message);
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -39,6 +39,7 @@ export {
|
||||
export {
|
||||
fetchFullUser, fetchNearestCountry, fetchTopUsers, fetchContactList, fetchUsers,
|
||||
updateContact, importContact, deleteContact, fetchProfilePhotos, fetchCommonChats, reportSpam, updateEmojiStatus,
|
||||
saveCloseFriends,
|
||||
} from './users';
|
||||
|
||||
export {
|
||||
@ -107,3 +108,9 @@ export {
|
||||
export {
|
||||
broadcastLocalDbUpdateFull,
|
||||
} from '../localDb';
|
||||
|
||||
export {
|
||||
fetchAllStories, fetchUserStories, fetchUserPinnedStories, fetchUserStoriesByIds, viewStory, markStoryRead,
|
||||
deleteStory, toggleStoryPinned, fetchStorySeenBy, fetchStoryLink, fetchStoriesArchive, reportStory, editStoryPrivacy,
|
||||
toggleStoriesHidden, fetchStoriesMaxIds,
|
||||
} from './stories';
|
||||
|
||||
@ -27,12 +27,11 @@ export default async function downloadMedia(
|
||||
url: string; mediaFormat: ApiMediaFormat; start?: number; end?: number; isHtmlAllowed?: boolean;
|
||||
},
|
||||
client: TelegramClient,
|
||||
isConnected: boolean,
|
||||
onProgress?: ApiOnProgress,
|
||||
) {
|
||||
const {
|
||||
data, mimeType, fullSize,
|
||||
} = await download(url, client, isConnected, onProgress, start, end, mediaFormat, isHtmlAllowed) || {};
|
||||
} = await download(url, client, onProgress, start, end, mediaFormat, isHtmlAllowed) || {};
|
||||
|
||||
if (!data) {
|
||||
return undefined;
|
||||
@ -71,7 +70,6 @@ export type EntityType = (
|
||||
async function download(
|
||||
url: string,
|
||||
client: TelegramClient,
|
||||
isConnected: boolean,
|
||||
onProgress?: ApiOnProgress,
|
||||
start?: number,
|
||||
end?: number,
|
||||
@ -218,14 +216,17 @@ function getMessageMediaMimeType(message: GramJs.Message, sizeType?: string) {
|
||||
return 'image/png';
|
||||
}
|
||||
|
||||
if (message.media instanceof GramJs.MessageMediaDocument && message.media.document instanceof GramJs.Document) {
|
||||
if (sizeType) {
|
||||
return message.media.document!.attributes.some((a) => a instanceof GramJs.DocumentAttributeSticker)
|
||||
? 'image/webp'
|
||||
: 'image/jpeg';
|
||||
}
|
||||
if (message.media instanceof GramJs.MessageMediaDocument) {
|
||||
const document = message.media.document;
|
||||
if (document instanceof GramJs.Document) {
|
||||
if (sizeType) {
|
||||
return document.attributes.some((a) => a instanceof GramJs.DocumentAttributeSticker)
|
||||
? 'image/webp'
|
||||
: 'image/jpeg';
|
||||
}
|
||||
|
||||
return message.media.document!.mimeType;
|
||||
return document.mimeType;
|
||||
}
|
||||
}
|
||||
|
||||
if (message.media instanceof GramJs.MessageMediaWebPage
|
||||
|
||||
@ -17,6 +17,9 @@ import type {
|
||||
ApiContact,
|
||||
ApiPoll,
|
||||
ApiFormattedText,
|
||||
ApiTypeReplyTo,
|
||||
ApiStory,
|
||||
ApiStorySkipped,
|
||||
} from '../../types';
|
||||
import {
|
||||
MAIN_THREAD_ID,
|
||||
@ -57,6 +60,8 @@ import {
|
||||
buildInputPollFromExisting,
|
||||
buildInputTextWithEntities,
|
||||
buildMessageFromUpdate,
|
||||
buildInputStory,
|
||||
buildInputReplyTo,
|
||||
} from '../gramjsBuilders';
|
||||
import { buildApiChatFromPreview, buildApiSendAsPeerId } from '../apiBuilders/chats';
|
||||
import { fetchFile } from '../../../util/files';
|
||||
@ -233,9 +238,9 @@ export function sendMessage(
|
||||
text,
|
||||
entities,
|
||||
replyingTo,
|
||||
replyingToTopId,
|
||||
attachment,
|
||||
sticker,
|
||||
story,
|
||||
gif,
|
||||
poll,
|
||||
contact,
|
||||
@ -250,10 +255,10 @@ export function sendMessage(
|
||||
lastMessageId?: number;
|
||||
text?: string;
|
||||
entities?: ApiMessageEntity[];
|
||||
replyingTo?: number;
|
||||
replyingToTopId?: number;
|
||||
replyingTo?: ApiTypeReplyTo;
|
||||
attachment?: ApiAttachment;
|
||||
sticker?: ApiSticker;
|
||||
story?: ApiStory | ApiStorySkipped;
|
||||
gif?: ApiVideo;
|
||||
poll?: ApiNewPoll;
|
||||
contact?: ApiContact;
|
||||
@ -271,7 +276,6 @@ export function sendMessage(
|
||||
text,
|
||||
entities,
|
||||
replyingTo,
|
||||
replyingToTopId,
|
||||
attachment,
|
||||
sticker,
|
||||
gif,
|
||||
@ -280,6 +284,7 @@ export function sendMessage(
|
||||
groupedId,
|
||||
scheduledAt,
|
||||
sendAs,
|
||||
story,
|
||||
);
|
||||
|
||||
onUpdate({
|
||||
@ -310,7 +315,6 @@ export function sendMessage(
|
||||
text,
|
||||
entities,
|
||||
replyingTo,
|
||||
replyingToTopId,
|
||||
attachment: attachment!,
|
||||
groupedId,
|
||||
isSilent,
|
||||
@ -339,6 +343,8 @@ export function sendMessage(
|
||||
media = buildInputMediaDocument(gif);
|
||||
} else if (poll) {
|
||||
media = buildInputPoll(poll, randomId);
|
||||
} else if (story) {
|
||||
media = buildInputStory(story);
|
||||
} else if (contact) {
|
||||
media = new GramJs.InputMediaContact({
|
||||
phoneNumber: contact.phoneNumber,
|
||||
@ -349,6 +355,7 @@ export function sendMessage(
|
||||
}
|
||||
|
||||
const RequestClass = media ? GramJs.messages.SendMedia : GramJs.messages.SendMessage;
|
||||
const replyTo = replyingTo ? buildInputReplyTo(replyingTo) : undefined;
|
||||
|
||||
try {
|
||||
const update = await invokeRequest(new RequestClass({
|
||||
@ -359,8 +366,7 @@ export function sendMessage(
|
||||
randomId,
|
||||
...(isSilent && { silent: isSilent }),
|
||||
...(scheduledAt && { scheduleDate: scheduledAt }),
|
||||
...(replyingTo && { replyToMsgId: replyingTo }),
|
||||
...(replyingToTopId && { topMsgId: replyingToTopId }),
|
||||
...(replyTo && { replyTo }),
|
||||
...(media && { media }),
|
||||
...(noWebPage && { noWebpage: noWebPage }),
|
||||
...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }),
|
||||
@ -396,7 +402,6 @@ function sendGroupedMedia(
|
||||
text,
|
||||
entities,
|
||||
replyingTo,
|
||||
replyingToTopId,
|
||||
attachment,
|
||||
groupedId,
|
||||
isSilent,
|
||||
@ -406,8 +411,7 @@ function sendGroupedMedia(
|
||||
chat: ApiChat;
|
||||
text?: string;
|
||||
entities?: ApiMessageEntity[];
|
||||
replyingTo?: number;
|
||||
replyingToTopId?: number;
|
||||
replyingTo?: ApiTypeReplyTo;
|
||||
attachment: ApiAttachment;
|
||||
groupedId: string;
|
||||
isSilent?: boolean;
|
||||
@ -479,13 +483,13 @@ function sendGroupedMedia(
|
||||
|
||||
const { singleMediaByIndex, localMessages } = groupedUploads[groupedId];
|
||||
delete groupedUploads[groupedId];
|
||||
const replyTo = replyingTo ? buildInputReplyTo(replyingTo) : undefined;
|
||||
|
||||
const update = await invokeRequest(new GramJs.messages.SendMultiMedia({
|
||||
clearDraft: true,
|
||||
peer: buildInputPeer(chat.id, chat.accessHash),
|
||||
multiMedia: Object.values(singleMediaByIndex), // Object keys are usually ordered
|
||||
replyToMsgId: replyingTo,
|
||||
...(replyingToTopId && { topMsgId: replyingToTopId }),
|
||||
...(replyingTo && { replyTo }),
|
||||
...(isSilent && { silent: isSilent }),
|
||||
...(scheduledAt && { scheduleDate: scheduledAt }),
|
||||
...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }),
|
||||
|
||||
@ -22,9 +22,8 @@ import {
|
||||
buildApiSession,
|
||||
buildApiWallpaper,
|
||||
buildApiWebSession, buildLangPack, buildLangPackString,
|
||||
buildPrivacyRules,
|
||||
} from '../apiBuilders/misc';
|
||||
|
||||
import { buildPrivacyRules } from '../apiBuilders/messages';
|
||||
import { buildApiPhoto } from '../apiBuilders/common';
|
||||
import { buildApiUser } from '../apiBuilders/users';
|
||||
import { buildApiChatFromPreview } from '../apiBuilders/chats';
|
||||
@ -620,7 +619,7 @@ export async function updateGlobalPrivacySettings({ shouldArchiveAndMuteNewNonCo
|
||||
}) {
|
||||
const result = await invokeRequest(new GramJs.account.SetGlobalPrivacySettings({
|
||||
settings: new GramJs.GlobalPrivacySettings({
|
||||
archiveAndMuteNewNoncontactPeers: shouldArchiveAndMuteNewNonContact,
|
||||
...(shouldArchiveAndMuteNewNonContact && { archiveAndMuteNewNoncontactPeers: true }),
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
343
src/api/gramjs/methods/stories.ts
Normal file
343
src/api/gramjs/methods/stories.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -301,6 +301,14 @@ export function updateEmojiStatus(emojiStatus: ApiSticker, expires?: number) {
|
||||
});
|
||||
}
|
||||
|
||||
export function saveCloseFriends(userIds: string[]) {
|
||||
const id = userIds.map((userId) => buildMtpPeerId(userId, 'user'));
|
||||
|
||||
return invokeRequest(new GramJs.contacts.EditCloseFriends({ id }), {
|
||||
shouldReturnTrue: true,
|
||||
});
|
||||
}
|
||||
|
||||
function updateLocalDb(result: (GramJs.photos.Photos | GramJs.photos.PhotosSlice | GramJs.messages.Chats)) {
|
||||
if ('chats' in result) {
|
||||
addEntitiesToLocalDb(result.chats);
|
||||
|
||||
@ -2,6 +2,7 @@ import type { GroupCallConnectionData } from '../../lib/secret-sauce';
|
||||
import { Api as GramJs, connection } from '../../lib/gramjs';
|
||||
import type {
|
||||
ApiMessage, ApiMessageExtendedMediaPreview, ApiUpdate, ApiUpdateConnectionStateType, OnApiUpdate,
|
||||
ApiStory, ApiStorySkipped,
|
||||
} from '../types';
|
||||
|
||||
import { DEBUG, GENERAL_TOPIC_ID } from '../../config';
|
||||
@ -18,6 +19,8 @@ import {
|
||||
buildMessageDraft,
|
||||
buildMessageReactions,
|
||||
buildApiMessageExtendedMediaPreview,
|
||||
buildApiStory,
|
||||
buildPrivacyRules,
|
||||
} from './apiBuilders/messages';
|
||||
import {
|
||||
buildChatMember,
|
||||
@ -49,12 +52,12 @@ import {
|
||||
log,
|
||||
swapLocalInvoiceMedia,
|
||||
isChatFolder,
|
||||
addStoryToLocalDb,
|
||||
} from './helpers';
|
||||
import {
|
||||
buildApiNotifyException,
|
||||
buildApiNotifyExceptionTopic,
|
||||
buildPrivacyKey,
|
||||
buildPrivacyRules,
|
||||
} from './apiBuilders/misc';
|
||||
import { buildApiPhoto, buildApiUsernames } from './apiBuilders/common';
|
||||
import {
|
||||
@ -300,7 +303,9 @@ export function updater(update: Update) {
|
||||
});
|
||||
}
|
||||
} else if (action instanceof GramJs.MessageActionTopicEdit) {
|
||||
const { replyTo } = update.message;
|
||||
const replyTo = update.message.replyTo instanceof GramJs.MessageReplyHeader
|
||||
? update.message.replyTo
|
||||
: undefined;
|
||||
const {
|
||||
replyToMsgId, replyToTopId, forumTopic: isTopicReply,
|
||||
} = replyTo || {};
|
||||
@ -1050,6 +1055,37 @@ export function updater(update: Update) {
|
||||
});
|
||||
} else if (update instanceof GramJs.UpdateRecentEmojiStatuses) {
|
||||
onUpdate({ '@type': 'updateRecentEmojiStatuses' });
|
||||
} else if (update instanceof GramJs.UpdateStory) {
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
const entities = update._entities;
|
||||
if (entities) {
|
||||
addEntitiesToLocalDb(entities);
|
||||
dispatchUserAndChatUpdates(entities);
|
||||
}
|
||||
|
||||
const { story } = update;
|
||||
const userId = buildApiPeerId(update.userId, 'user');
|
||||
addStoryToLocalDb(story, userId);
|
||||
|
||||
if (story instanceof GramJs.StoryItemDeleted) {
|
||||
onUpdate({
|
||||
'@type': 'deleteStory',
|
||||
userId,
|
||||
storyId: story.id,
|
||||
});
|
||||
} else {
|
||||
onUpdate({
|
||||
'@type': 'updateStory',
|
||||
userId,
|
||||
story: buildApiStory(userId, story) as ApiStory | ApiStorySkipped,
|
||||
});
|
||||
}
|
||||
} else if (update instanceof GramJs.UpdateReadStories) {
|
||||
onUpdate({
|
||||
'@type': 'updateReadStories',
|
||||
userId: buildApiPeerId(update.userId, 'user'),
|
||||
lastReadId: update.maxId,
|
||||
});
|
||||
} else if (DEBUG) {
|
||||
const params = typeof update === 'object' && 'className' in update ? update.className : update;
|
||||
log('UNEXPECTED UPDATE', params);
|
||||
|
||||
@ -35,7 +35,6 @@ const savedLocalDb: LocalDb = {
|
||||
stickerSets: {},
|
||||
photos: {},
|
||||
webDocuments: {},
|
||||
|
||||
commonBoxState: {},
|
||||
channelPtsById: {},
|
||||
};
|
||||
|
||||
@ -9,3 +9,4 @@ export * from './bots';
|
||||
export * from './misc';
|
||||
export * from './calls';
|
||||
export * from './statistics';
|
||||
export * from './stories';
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { ApiWebDocument } from './bots';
|
||||
import type { ApiGroupCall, PhoneCallAction } from './calls';
|
||||
import type { ApiChat } from './chats';
|
||||
import type { ApiMessageStoryData, ApiWebPageStoryData } from './stories';
|
||||
|
||||
export interface ApiDimensions {
|
||||
width: number;
|
||||
@ -95,6 +96,7 @@ export interface ApiVideo {
|
||||
blobUrl?: string;
|
||||
previewBlobUrl?: string;
|
||||
size: number;
|
||||
noSound?: boolean;
|
||||
}
|
||||
|
||||
export interface ApiAudio {
|
||||
@ -293,6 +295,19 @@ export interface ApiWebPage {
|
||||
duration?: number;
|
||||
document?: ApiDocument;
|
||||
video?: ApiVideo;
|
||||
story?: ApiWebPageStoryData;
|
||||
}
|
||||
|
||||
export type ApiTypeReplyTo = ApiMessageReplyTo | ApiStoryReplyTo;
|
||||
|
||||
export interface ApiMessageReplyTo {
|
||||
replyingTo: number;
|
||||
replyingToTopId?: number;
|
||||
}
|
||||
|
||||
export interface ApiStoryReplyTo {
|
||||
userId: string;
|
||||
storyId: number;
|
||||
}
|
||||
|
||||
export interface ApiMessageForwardInfo {
|
||||
@ -383,6 +398,7 @@ export interface ApiMessage {
|
||||
text?: ApiFormattedText;
|
||||
photo?: ApiPhoto;
|
||||
video?: ApiVideo;
|
||||
altVideo?: ApiVideo;
|
||||
document?: ApiDocument;
|
||||
sticker?: ApiSticker;
|
||||
contact?: ApiContact;
|
||||
@ -394,6 +410,7 @@ export interface ApiMessage {
|
||||
invoice?: ApiInvoice;
|
||||
location?: ApiLocation;
|
||||
game?: ApiGame;
|
||||
storyData?: ApiMessageStoryData;
|
||||
};
|
||||
date: number;
|
||||
isOutgoing: boolean;
|
||||
@ -402,6 +419,8 @@ export interface ApiMessage {
|
||||
replyToMessageId?: number;
|
||||
replyToTopMessageId?: number;
|
||||
isTopicReply?: true;
|
||||
replyToStoryUserId?: string;
|
||||
replyToStoryId?: number;
|
||||
sendingState?: 'messageSendingStatePending' | 'messageSendingStateFailed';
|
||||
forwardInfo?: ApiMessageForwardInfo;
|
||||
isDeleting?: boolean;
|
||||
|
||||
@ -109,7 +109,7 @@ export type ApiNotification = {
|
||||
title?: string;
|
||||
message: string;
|
||||
actionText?: string;
|
||||
action?: CallbackAction;
|
||||
action?: CallbackAction | CallbackAction[];
|
||||
className?: string;
|
||||
duration?: number;
|
||||
};
|
||||
@ -191,6 +191,10 @@ export interface ApiAppConfig {
|
||||
hiddenMembersMinCount: number;
|
||||
limits: Record<ApiLimitType, readonly [number, number]>;
|
||||
canDisplayAutoarchiveSetting: boolean;
|
||||
areStoriesHidden?: boolean;
|
||||
storyExpirePeriod: number;
|
||||
storyViewersExpirePeriod: number;
|
||||
storyChangelogUserId: string;
|
||||
}
|
||||
|
||||
export interface ApiConfig {
|
||||
|
||||
59
src/api/types/stories.ts
Normal file
59
src/api/types/stories.ts
Normal 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;
|
||||
};
|
||||
@ -33,6 +33,7 @@ import type {
|
||||
} from './calls';
|
||||
import type { ApiBotMenuButton } from './bots';
|
||||
import type { ApiPrivacyKey, PrivacyVisibility } from '../../types';
|
||||
import type { ApiStory, ApiStorySkipped } from './stories';
|
||||
|
||||
export type ApiUpdateReady = {
|
||||
'@type': 'updateApiReady';
|
||||
@ -624,6 +625,24 @@ export type ApiRequestReconnectApi = {
|
||||
'@type': 'requestReconnectApi';
|
||||
};
|
||||
|
||||
export type ApiUpdateStory = {
|
||||
'@type': 'updateStory';
|
||||
userId: string;
|
||||
story: ApiStory | ApiStorySkipped;
|
||||
};
|
||||
|
||||
export type ApiUpdateDeleteStory = {
|
||||
'@type': 'deleteStory';
|
||||
userId: string;
|
||||
storyId: number;
|
||||
};
|
||||
|
||||
export type ApiUpdateReadStories = {
|
||||
'@type': 'updateReadStories';
|
||||
userId: string;
|
||||
lastReadId: number;
|
||||
};
|
||||
|
||||
export type ApiRequestSync = {
|
||||
'@type': 'requestSync';
|
||||
};
|
||||
@ -654,7 +673,8 @@ export type ApiUpdate = (
|
||||
ApiUpdatePhoneCallConnectionState | ApiUpdateBotMenuButton | ApiUpdateTranscribedAudio | ApiUpdateUserEmojiStatus |
|
||||
ApiUpdateMessageExtendedMedia | ApiUpdateConfig | ApiUpdateTopicNotifyExceptions | ApiUpdatePinnedTopic |
|
||||
ApiUpdatePinnedTopicsOrder | ApiUpdateTopic | ApiUpdateTopics | ApiUpdateRecentEmojiStatuses |
|
||||
ApiUpdateRecentReactions | ApiRequestReconnectApi | ApiRequestSync | ApiUpdateFetchingDifference
|
||||
ApiUpdateRecentReactions | ApiUpdateStory | ApiUpdateReadStories | ApiUpdateDeleteStory |
|
||||
ApiRequestReconnectApi | ApiRequestSync | ApiUpdateFetchingDifference | ApiUpdateChannelMessages
|
||||
);
|
||||
|
||||
export type OnApiUpdate = (update: ApiUpdate) => void;
|
||||
|
||||
@ -8,7 +8,9 @@ export interface ApiUser {
|
||||
isSelf?: true;
|
||||
isVerified?: true;
|
||||
isPremium?: boolean;
|
||||
isCloseFriend?: boolean;
|
||||
isContact?: true;
|
||||
isSupport?: true;
|
||||
type: ApiUserType;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
@ -29,6 +31,10 @@ export interface ApiUser {
|
||||
fakeType?: ApiFakeType;
|
||||
isAttachBot?: boolean;
|
||||
emojiStatus?: ApiEmojiStatus;
|
||||
areStoriesHidden?: boolean;
|
||||
hasStories?: boolean;
|
||||
hasUnreadStories?: boolean;
|
||||
maxStoryId?: number;
|
||||
}
|
||||
|
||||
export interface ApiUserFullInfo {
|
||||
@ -43,6 +49,7 @@ export interface ApiUserFullInfo {
|
||||
noVoiceMessages?: boolean;
|
||||
premiumGifts?: ApiPremiumGiftOption[];
|
||||
isTranslationDisabled?: true;
|
||||
hasPinnedStories?: boolean;
|
||||
}
|
||||
|
||||
export type ApiFakeType = 'fake' | 'scam';
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -1,4 +1,5 @@
|
||||
export { default as MediaViewer } from '../components/mediaViewer/MediaViewer';
|
||||
export { default as StoryViewer } from '../components/story/StoryViewer';
|
||||
|
||||
export { default as ForwardRecipientPicker } from '../components/main/ForwardRecipientPicker';
|
||||
export { default as DraftRecipientPicker } from '../components/main/DraftRecipientPicker';
|
||||
|
||||
@ -11,6 +11,8 @@
|
||||
}
|
||||
|
||||
:global(.Avatar) {
|
||||
--radius: 0;
|
||||
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
@ -1,20 +1,33 @@
|
||||
.Avatar {
|
||||
--color-user: var(--color-primary);
|
||||
--radius: 50%;
|
||||
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3.375rem;
|
||||
height: 3.375rem;
|
||||
border-radius: var(--radius);
|
||||
background-image: linear-gradient(var(--color-white) -125%, var(--color-user));
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
> .inner {
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-image: linear-gradient(var(--color-white) -125%, var(--color-user));
|
||||
}
|
||||
|
||||
&__media {
|
||||
width: 100%;
|
||||
@ -120,6 +133,68 @@
|
||||
cursor: var(--custom-cursor, pointer);
|
||||
}
|
||||
|
||||
&.with-story-circle {
|
||||
z-index: 1;
|
||||
|
||||
> .inner {
|
||||
width: calc(100% - 0.375rem);
|
||||
height: calc(100% - 0.375rem);
|
||||
left: 0.1875rem;
|
||||
top: 0.1875rem;
|
||||
}
|
||||
}
|
||||
|
||||
&.with-story-solid {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
margin: 0.1875rem;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
left: -0.25rem;
|
||||
top: -0.25rem;
|
||||
border-radius: 50%;
|
||||
padding: 0.125rem;
|
||||
|
||||
background: var(--color-borders-read-story);
|
||||
mask: linear-gradient(to bottom, #fff 0%, #fff 100%) content-box, linear-gradient(to bottom, #fff 0%, #fff 100%);
|
||||
mask-composite: exclude;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&.size-tiny {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
|
||||
&::before {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
&.size-medium {
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
|
||||
&::before {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
&.online::after {
|
||||
bottom: -0.125rem;
|
||||
right: -0.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
&.has-unread-story::before {
|
||||
background-image: linear-gradient(215.87deg, var(--color-avatar-story-unread-from) -1.61%, var(--color-avatar-story-unread-to) 97.44%);
|
||||
}
|
||||
|
||||
.poster {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
|
||||
@ -1,12 +1,9 @@
|
||||
import type { MouseEvent as ReactMouseEvent } from 'react';
|
||||
import React, {
|
||||
memo, useRef,
|
||||
} from '../../lib/teact/teact';
|
||||
import React, { memo, useRef } from '../../lib/teact/teact';
|
||||
import { getActions } from '../../global';
|
||||
|
||||
import type { FC, TeactNode } from '../../lib/teact/teact';
|
||||
import type {
|
||||
ApiChat, ApiPhoto, ApiUser,
|
||||
} from '../../api/types';
|
||||
import type { ApiChat, ApiPhoto, ApiUser } from '../../api/types';
|
||||
import type { ObserveFn } from '../../hooks/useIntersectionObserver';
|
||||
import { ApiMediaFormat } from '../../api/types';
|
||||
|
||||
@ -27,10 +24,11 @@ import renderText from './helpers/renderText';
|
||||
import useMedia from '../../hooks/useMedia';
|
||||
import useMediaTransition from '../../hooks/useMediaTransition';
|
||||
import useLang from '../../hooks/useLang';
|
||||
import { useFastClick } from '../../hooks/useFastClick';
|
||||
import useLastCallback from '../../hooks/useLastCallback';
|
||||
import { useFastClick } from '../../hooks/useFastClick';
|
||||
|
||||
import OptimizedVideo from '../ui/OptimizedVideo';
|
||||
import AvatarStoryCircle from './AvatarStoryCircle';
|
||||
|
||||
import './Avatar.scss';
|
||||
|
||||
@ -50,6 +48,10 @@ type OwnProps = {
|
||||
text?: string;
|
||||
isSavedMessages?: boolean;
|
||||
withVideo?: boolean;
|
||||
withStory?: boolean;
|
||||
withStoryGap?: boolean;
|
||||
withStorySolid?: boolean;
|
||||
storyViewerMode?: 'full' | 'single-user' | 'disabled';
|
||||
loopIndefinitely?: boolean;
|
||||
noPersonalPhoto?: boolean;
|
||||
observeIntersection?: ObserveFn;
|
||||
@ -64,10 +66,16 @@ const Avatar: FC<OwnProps> = ({
|
||||
text,
|
||||
isSavedMessages,
|
||||
withVideo,
|
||||
withStory,
|
||||
withStoryGap,
|
||||
withStorySolid,
|
||||
storyViewerMode = 'single-user',
|
||||
loopIndefinitely,
|
||||
noPersonalPhoto,
|
||||
onClick,
|
||||
}) => {
|
||||
const { openStoryViewer } = getActions();
|
||||
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const videoLoopCountRef = useRef(0);
|
||||
@ -163,6 +171,7 @@ const Avatar: FC<OwnProps> = ({
|
||||
className={buildClassName(cn.media, 'avatar-media', transitionClassNames, videoBlobUrl && 'poster')}
|
||||
alt={author}
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
/>
|
||||
{shouldPlayVideo && (
|
||||
<OptimizedVideo
|
||||
@ -174,6 +183,7 @@ const Avatar: FC<OwnProps> = ({
|
||||
autoPlay
|
||||
disablePictureInPicture
|
||||
playsInline
|
||||
draggable={false}
|
||||
onEnded={handleVideoEnded}
|
||||
/>
|
||||
)}
|
||||
@ -197,6 +207,9 @@ const Avatar: FC<OwnProps> = ({
|
||||
isDeleted && 'deleted-account',
|
||||
isReplies && 'replies-bot-account',
|
||||
isForum && 'forum',
|
||||
withStory && user?.hasStories && 'with-story-circle',
|
||||
withStorySolid && user?.hasStories && 'with-story-solid',
|
||||
withStorySolid && user?.hasUnreadStories && 'has-unread-story',
|
||||
onClick && 'interactive',
|
||||
(!isSavedMessages && !imgBlobUrl) && 'no-photo',
|
||||
);
|
||||
@ -204,6 +217,13 @@ const Avatar: FC<OwnProps> = ({
|
||||
const hasMedia = Boolean(isSavedMessages || imgBlobUrl);
|
||||
|
||||
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) {
|
||||
onClick(e, hasMedia);
|
||||
}
|
||||
@ -218,7 +238,12 @@ const Avatar: FC<OwnProps> = ({
|
||||
onClick={handleClick}
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
199
src/components/common/AvatarStoryCircle.tsx
Normal file
199
src/components/common/AvatarStoryCircle.tsx
Normal 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();
|
||||
});
|
||||
}
|
||||
@ -75,6 +75,7 @@ const ChatExtra: FC<OwnProps & StateProps> = ({
|
||||
showNotification,
|
||||
updateChatMutedState,
|
||||
updateTopicMutedState,
|
||||
loadUserStories,
|
||||
} = getActions();
|
||||
|
||||
const {
|
||||
@ -95,6 +96,7 @@ const ChatExtra: FC<OwnProps & StateProps> = ({
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
loadFullUser({ userId });
|
||||
loadUserStories({ userId });
|
||||
}, [userId]);
|
||||
|
||||
const isTopicInfo = Boolean(topicId && topicId !== MAIN_THREAD_ID);
|
||||
|
||||
@ -10,6 +10,7 @@ import { REM } from './helpers/mediaDimensions';
|
||||
import { CHAT_HEIGHT_PX } from '../../config';
|
||||
import renderText from './helpers/renderText';
|
||||
import { getCanPostInChat, isUserId } from '../../global/helpers';
|
||||
import buildClassName from '../../util/buildClassName';
|
||||
|
||||
import useLastCallback from '../../hooks/useLastCallback';
|
||||
import useInfiniteScroll from '../../hooks/useInfiniteScroll';
|
||||
@ -37,6 +38,7 @@ export type OwnProps = {
|
||||
isOpen: boolean;
|
||||
searchPlaceholder: string;
|
||||
search: string;
|
||||
className?: string;
|
||||
loadMore?: NoneToVoidFunction;
|
||||
onSearchChange: (search: string) => void;
|
||||
onSelectChatOrUser: (chatOrUserId: string, threadId?: number) => void;
|
||||
@ -55,6 +57,7 @@ const ChatOrUserPicker: FC<OwnProps> = ({
|
||||
chatsById,
|
||||
search,
|
||||
searchPlaceholder,
|
||||
className,
|
||||
loadMore,
|
||||
onSearchChange,
|
||||
onSelectChatOrUser,
|
||||
@ -264,7 +267,7 @@ const ChatOrUserPicker: FC<OwnProps> = ({
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
className="ChatOrUserPicker"
|
||||
className={buildClassName('ChatOrUserPicker', className)}
|
||||
onClose={onClose}
|
||||
onCloseAnimationEnd={onCloseAnimationEnd}
|
||||
>
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
.Composer {
|
||||
--base-height: 3.5rem;
|
||||
|
||||
align-items: flex-end;
|
||||
|
||||
.select-mode-active + .middle-column-footer & {
|
||||
@ -40,7 +42,7 @@
|
||||
|
||||
to {
|
||||
/* stylelint-disable-next-line plugin/no-low-performance-animation-properties */
|
||||
width: 3.5rem;
|
||||
width: var(--base-height);
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@ -48,6 +50,8 @@
|
||||
> .Button {
|
||||
flex-shrink: 0;
|
||||
margin-left: 0.5rem;
|
||||
width: var(--base-height);
|
||||
height: var(--base-height);
|
||||
|
||||
&:not(.danger) {
|
||||
color: var(--color-composer-button);
|
||||
@ -60,6 +64,7 @@
|
||||
|
||||
.icon-send,
|
||||
.icon-schedule,
|
||||
.icon-forward,
|
||||
.icon-microphone-alt,
|
||||
.icon-check {
|
||||
position: absolute;
|
||||
@ -69,6 +74,7 @@
|
||||
&:not(:active):not(:focus):not(:hover) {
|
||||
.icon-send,
|
||||
.icon-schedule,
|
||||
.icon-forward,
|
||||
.icon-check {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
@ -79,6 +85,7 @@
|
||||
&:not(:active):not(:focus) {
|
||||
.icon-send,
|
||||
.icon-schedule,
|
||||
.icon-forward,
|
||||
.icon-check {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
@ -107,6 +114,7 @@
|
||||
}
|
||||
|
||||
.icon-microphone-alt,
|
||||
.icon-forward,
|
||||
.icon-check,
|
||||
.icon-schedule {
|
||||
animation: hide-icon 0.4s forwards ease-out;
|
||||
@ -120,6 +128,7 @@
|
||||
|
||||
.icon-microphone-alt,
|
||||
.icon-check,
|
||||
.icon-forward,
|
||||
.icon-send {
|
||||
animation: hide-icon 0.4s forwards ease-out;
|
||||
}
|
||||
@ -132,6 +141,7 @@
|
||||
|
||||
.icon-send,
|
||||
.icon-check,
|
||||
.icon-forward,
|
||||
.icon-schedule {
|
||||
animation: hide-icon 0.4s forwards ease-out;
|
||||
}
|
||||
@ -143,6 +153,24 @@
|
||||
}
|
||||
|
||||
.icon-send,
|
||||
.icon-forward,
|
||||
.icon-microphone-alt,
|
||||
.icon-schedule {
|
||||
animation: hide-icon 0.4s forwards ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
&.forward {
|
||||
--color-primary: #212121;
|
||||
|
||||
.icon-forward {
|
||||
--color-primary: #707478;
|
||||
|
||||
animation: grow-icon 0.4s ease-out;
|
||||
}
|
||||
|
||||
.icon-send,
|
||||
.icon-check,
|
||||
.icon-microphone-alt,
|
||||
.icon-schedule {
|
||||
animation: hide-icon 0.4s forwards ease-out;
|
||||
@ -172,6 +200,32 @@
|
||||
animation: 0.25s ease-in-out forwards show-send-as-button;
|
||||
transform-origin: right;
|
||||
}
|
||||
|
||||
> .ReactionSelector {
|
||||
--color-background-compact-menu: rgba(0, 0, 0, 0.3);
|
||||
--color-interactive-element-hover: rgba(255, 255, 255, 0.1);
|
||||
--color-text: #fff;
|
||||
|
||||
left: 50%;
|
||||
right: auto;
|
||||
top: -3.875rem;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1;
|
||||
|
||||
@media (max-width: 600px) {
|
||||
top: -4.25rem;
|
||||
}
|
||||
|
||||
.ReactionSelector__bubble-small,
|
||||
.ReactionSelector__bubble-big {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ReactionSelector__show-more {
|
||||
transform: scaleY(-1);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-symbol-menu-button {
|
||||
@ -225,17 +279,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#message-compose {
|
||||
.composer-wrapper {
|
||||
flex-grow: 1;
|
||||
max-width: calc(100% - 4rem);
|
||||
background: var(--color-background);
|
||||
border-radius: var(--border-radius-messages);
|
||||
border-bottom-right-radius: 0;
|
||||
box-shadow: 0 1px 2px var(--color-default-shadow);
|
||||
border-radius: var(--border-radius-default-small);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
&.full-featured {
|
||||
box-shadow: 0 1px 2px var(--color-default-shadow);
|
||||
border-radius: var(--border-radius-messages);
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.svg-appendix {
|
||||
position: absolute;
|
||||
bottom: -0.1875rem;
|
||||
@ -274,8 +331,8 @@
|
||||
> .Button {
|
||||
flex-shrink: 0;
|
||||
background: none !important;
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
width: var(--base-height, 3.5rem);
|
||||
height: var(--base-height, 3.5rem);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
align-self: flex-end;
|
||||
@ -380,8 +437,8 @@
|
||||
.recording-state {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
line-height: 3.5rem;
|
||||
height: 3.5rem;
|
||||
line-height: var(--base-height);
|
||||
height: var(--base-height);
|
||||
padding: 0 3.125rem 0 1rem;
|
||||
font-family: var(--font-family);
|
||||
font-variant-numeric: tabular-nums;
|
||||
@ -424,7 +481,7 @@
|
||||
}
|
||||
|
||||
.input-scroller {
|
||||
min-height: 3.5rem;
|
||||
min-height: var(--base-height, 3.5rem);
|
||||
max-height: 26rem;
|
||||
overflow: hidden;
|
||||
|
||||
@ -474,12 +531,13 @@
|
||||
}
|
||||
|
||||
#message-input-text,
|
||||
#story-input-text,
|
||||
#caption-input-text {
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
|
||||
.form-control {
|
||||
padding: calc((3.5rem - var(--composer-text-size, 1rem) * 1.375) / 2) 0.875rem;
|
||||
padding: calc((var(--base-height, 3.5rem) - var(--composer-text-size, 1rem) * 1.375) / 2) 0.875rem;
|
||||
overflow: hidden;
|
||||
height: auto;
|
||||
line-height: 1.375;
|
||||
@ -495,8 +553,9 @@
|
||||
caret-color: var(--color-text);
|
||||
|
||||
&.touched {
|
||||
& + .placeholder-text {
|
||||
display: none;
|
||||
& ~ .placeholder-text {
|
||||
opacity: 0;
|
||||
transform: translateX(1rem);
|
||||
}
|
||||
}
|
||||
|
||||
@ -520,6 +579,7 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
transition: opacity 200ms ease-out, transform 200ms ease-out;
|
||||
|
||||
&.with-icon {
|
||||
display: inline-flex;
|
||||
@ -544,8 +604,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
&[dir="rtl"] .placeholder-text {
|
||||
right: 0;
|
||||
&[dir="rtl"] {
|
||||
.placeholder-text {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.touched ~ .placeholder-text {
|
||||
transform: translateX(-1rem);
|
||||
}
|
||||
}
|
||||
|
||||
.text-entity-link {
|
||||
@ -561,7 +627,7 @@
|
||||
}
|
||||
|
||||
.spoiler {
|
||||
background-image: url("../../../assets/spoiler-dots-black.png");
|
||||
background-image: url("../../assets/spoiler-dots-black.png");
|
||||
background-size: auto min(100%, 1.125rem);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0 0.3125rem 0.125rem 0.3125rem;
|
||||
@ -570,7 +636,7 @@
|
||||
}
|
||||
|
||||
html.theme-dark & .spoiler {
|
||||
background-image: url("../../../assets/spoiler-dots-white.png");
|
||||
background-image: url("../../assets/spoiler-dots-white.png");
|
||||
}
|
||||
|
||||
.clone {
|
||||
@ -590,7 +656,7 @@
|
||||
.form-control {
|
||||
margin-bottom: 0;
|
||||
line-height: 1.3125;
|
||||
padding: calc((3.5rem - var(--composer-text-size, 1rem) * 1.3125) / 2) 0;
|
||||
padding: calc((var(--base-height, 3.5rem) - var(--composer-text-size, 1rem) * 1.3125) / 2) 0;
|
||||
white-space: pre-wrap;
|
||||
height: auto;
|
||||
|
||||
@ -601,7 +667,7 @@
|
||||
|
||||
.forced-placeholder,
|
||||
.placeholder-text {
|
||||
top: calc((3.5rem - var(--composer-text-size, 1rem) * 1.3125) / 2);
|
||||
top: calc((var(--base-height, 3.5rem) - var(--composer-text-size, 1rem) * 1.3125) / 2);
|
||||
|
||||
@media (max-width: 600px) {
|
||||
top: calc((2.875rem - var(--composer-text-size, 1rem) * 1.3125) / 2);
|
||||
@ -627,10 +693,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
#story-input-text,
|
||||
#caption-input-text {
|
||||
--margin-for-scrollbar: 5rem;
|
||||
--margin-for-scrollbar: 2rem;
|
||||
.input-scroller {
|
||||
min-height: 3.5rem;
|
||||
min-height: var(--base-height, 3.5rem);
|
||||
max-height: 10rem;
|
||||
|
||||
margin-right: calc((var(--margin-for-scrollbar) + 1rem) * -1);
|
||||
@ -646,8 +713,8 @@
|
||||
|
||||
.placeholder-text {
|
||||
top: auto;
|
||||
bottom: 1.125rem;
|
||||
left: 0.9375rem;
|
||||
bottom: 0.875rem;
|
||||
left: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -153,6 +153,7 @@ const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
|
||||
: Object.values(pickTruthy(customEmojisById!, recentCustomEmojiIds!));
|
||||
}, [customEmojisById, isStatusPicker, recentCustomEmojiIds, recentStatusEmojis]);
|
||||
|
||||
const prefix = `${idPrefix}-custom-emoji`;
|
||||
const {
|
||||
activeSetIndex,
|
||||
observeIntersectionForSet,
|
||||
@ -160,7 +161,7 @@ const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
|
||||
observeIntersectionForShowingItems,
|
||||
observeIntersectionForCovers,
|
||||
selectStickerSet,
|
||||
} = useStickerPickerObservers(containerRef, headerRef, idPrefix, isHidden);
|
||||
} = useStickerPickerObservers(containerRef, headerRef, prefix, isHidden);
|
||||
|
||||
const lang = useLang();
|
||||
|
||||
@ -401,7 +402,7 @@ const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
|
||||
stickerSet={stickerSet}
|
||||
loadAndPlay={Boolean(canAnimate && loadAndPlay)}
|
||||
index={i}
|
||||
idPrefix={idPrefix}
|
||||
idPrefix={prefix}
|
||||
observeIntersection={observeIntersectionForSet}
|
||||
observeIntersectionForPlayingItems={observeIntersectionForPlayingItems}
|
||||
observeIntersectionForShowingItems={observeIntersectionForShowingItems}
|
||||
|
||||
@ -69,6 +69,12 @@
|
||||
font-size: calc(var(--message-text-size, 1rem) - 0.125rem);
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 1.25rem;
|
||||
line-height: 0.9375rem;
|
||||
vertical-align: -0.1875rem;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
overflow: hidden;
|
||||
margin-inline-start: 0.5rem;
|
||||
@ -113,6 +119,10 @@
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.with-message-color {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
}
|
||||
|
||||
.embedded-action-message {
|
||||
|
||||
118
src/components/common/EmbeddedStory.tsx
Normal file
118
src/components/common/EmbeddedStory.tsx
Normal 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;
|
||||
@ -58,12 +58,12 @@ const InviteLink: FC<OwnProps> = ({
|
||||
color="translucent"
|
||||
className={isOpen ? 'active' : ''}
|
||||
onClick={onTrigger}
|
||||
ariaLabel="Actions"
|
||||
ariaLabel={lang('AccDescrOpenMenu2')}
|
||||
>
|
||||
<i className="icon icon-more" />
|
||||
</Button>
|
||||
);
|
||||
}, [isMobile]);
|
||||
}, [isMobile, lang]);
|
||||
|
||||
return (
|
||||
<div className="settings-item">
|
||||
|
||||
@ -66,7 +66,7 @@ function MessageSummary({
|
||||
function renderMessageText() {
|
||||
return (
|
||||
<MessageText
|
||||
message={message}
|
||||
messageOrStory={message}
|
||||
translatedText={translatedText}
|
||||
highlight={highlight}
|
||||
isSimple
|
||||
|
||||
@ -2,7 +2,7 @@ import React, {
|
||||
memo, useMemo, useRef,
|
||||
} 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 { ApiMessageEntityTypes } from '../../api/types';
|
||||
@ -12,7 +12,7 @@ import { renderTextWithEntities } from './helpers/renderTextWithEntities';
|
||||
import useSyncEffect from '../../hooks/useSyncEffect';
|
||||
|
||||
interface OwnProps {
|
||||
message: ApiMessage;
|
||||
messageOrStory: ApiMessage | ApiStory;
|
||||
translatedText?: ApiFormattedText;
|
||||
isForAnimation?: boolean;
|
||||
emojiSize?: number;
|
||||
@ -30,7 +30,7 @@ interface OwnProps {
|
||||
const MIN_CUSTOM_EMOJIS_FOR_SHARED_CANVAS = 3;
|
||||
|
||||
function MessageText({
|
||||
message,
|
||||
messageOrStory,
|
||||
translatedText,
|
||||
isForAnimation,
|
||||
emojiSize,
|
||||
@ -51,7 +51,7 @@ function MessageText({
|
||||
|
||||
const textCacheBusterRef = useRef(0);
|
||||
|
||||
const formattedText = translatedText || extractMessageText(message, inChatList);
|
||||
const formattedText = translatedText || extractMessageText(messageOrStory, inChatList);
|
||||
const adaptedFormattedText = isForAnimation && formattedText ? stripCustomEmoji(formattedText) : formattedText;
|
||||
const { text, entities } = adaptedFormattedText || {};
|
||||
|
||||
@ -70,7 +70,7 @@ function MessageText({
|
||||
}, [entities]) || 0;
|
||||
|
||||
if (!text) {
|
||||
const contentNotSupportedText = getMessageText(message);
|
||||
const contentNotSupportedText = getMessageText(messageOrStory);
|
||||
return contentNotSupportedText ? [trimText(contentNotSupportedText, truncateLength)] : undefined as any;
|
||||
}
|
||||
|
||||
@ -85,7 +85,7 @@ function MessageText({
|
||||
highlight,
|
||||
emojiSize,
|
||||
shouldRenderAsHtml,
|
||||
messageId: message.id,
|
||||
messageId: messageOrStory.id,
|
||||
isSimple,
|
||||
isProtected,
|
||||
observeIntersectionForLoading,
|
||||
|
||||
@ -37,6 +37,7 @@ type OwnProps = {
|
||||
isSearchable?: boolean;
|
||||
isRoundCheckbox?: boolean;
|
||||
lockedIds?: string[];
|
||||
forceShowSelf?: boolean;
|
||||
onSelectedIdsChange?: (ids: string[]) => void;
|
||||
onFilterChange?: (value: string) => void;
|
||||
onDisabledClick?: (id: string) => void;
|
||||
@ -61,6 +62,7 @@ const Picker: FC<OwnProps> = ({
|
||||
isSearchable,
|
||||
isRoundCheckbox,
|
||||
lockedIds,
|
||||
forceShowSelf,
|
||||
onSelectedIdsChange,
|
||||
onFilterChange,
|
||||
onDisabledClick,
|
||||
@ -134,6 +136,7 @@ const Picker: FC<OwnProps> = ({
|
||||
<PickerSelectedItem
|
||||
chatOrUserId={id}
|
||||
isMinimized={shouldMinimize && i < selectedIds.length - ALWAYS_FULL_ITEMS_COUNT}
|
||||
forceShowSelf={forceShowSelf}
|
||||
onClick={handleItemClick}
|
||||
clickArg={id}
|
||||
/>
|
||||
@ -189,7 +192,7 @@ const Picker: FC<OwnProps> = ({
|
||||
>
|
||||
{!isRoundCheckbox ? renderCheckbox() : undefined}
|
||||
{isUserId(id) ? (
|
||||
<PrivateChatInfo userId={id} />
|
||||
<PrivateChatInfo forceShowSelf={forceShowSelf} userId={id} />
|
||||
) : (
|
||||
<GroupChatInfo chatId={id} />
|
||||
)}
|
||||
|
||||
@ -20,15 +20,16 @@ type OwnProps = {
|
||||
title?: string;
|
||||
isMinimized?: boolean;
|
||||
canClose?: boolean;
|
||||
onClick: (arg: any) => void;
|
||||
forceShowSelf?: boolean;
|
||||
clickArg: any;
|
||||
className?: string;
|
||||
onClick: (arg: any) => void;
|
||||
};
|
||||
|
||||
type StateProps = {
|
||||
chat?: ApiChat;
|
||||
user?: ApiUser;
|
||||
currentUserId?: string;
|
||||
isSavedMessages?: boolean;
|
||||
};
|
||||
|
||||
const PickerSelectedItem: FC<OwnProps & StateProps> = ({
|
||||
@ -40,7 +41,7 @@ const PickerSelectedItem: FC<OwnProps & StateProps> = ({
|
||||
chat,
|
||||
user,
|
||||
className,
|
||||
currentUserId,
|
||||
isSavedMessages,
|
||||
onClick,
|
||||
}) => {
|
||||
const lang = useLang();
|
||||
@ -61,13 +62,13 @@ const PickerSelectedItem: FC<OwnProps & StateProps> = ({
|
||||
<Avatar
|
||||
peer={user || chat}
|
||||
size="small"
|
||||
isSavedMessages={user?.isSelf}
|
||||
isSavedMessages={isSavedMessages}
|
||||
/>
|
||||
);
|
||||
|
||||
const name = !chat || (user && !user.isSelf)
|
||||
const name = !chat || (user && !isSavedMessages)
|
||||
? getUserFirstOrLastName(user)
|
||||
: getChatTitle(lang, chat, chat.id === currentUserId);
|
||||
: getChatTitle(lang, chat, isSavedMessages);
|
||||
|
||||
titleText = name ? renderText(name) : undefined;
|
||||
}
|
||||
@ -103,18 +104,19 @@ const PickerSelectedItem: FC<OwnProps & StateProps> = ({
|
||||
};
|
||||
|
||||
export default memo(withGlobal<OwnProps>(
|
||||
(global, { chatOrUserId }): StateProps => {
|
||||
(global, { chatOrUserId, forceShowSelf }): StateProps => {
|
||||
if (!chatOrUserId) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const chat = chatOrUserId ? selectChat(global, chatOrUserId) : undefined;
|
||||
const user = isUserId(chatOrUserId) ? selectUser(global, chatOrUserId) : undefined;
|
||||
const isSavedMessages = !forceShowSelf && user && user.isSelf;
|
||||
|
||||
return {
|
||||
chat,
|
||||
user,
|
||||
currentUserId: global.currentUserId,
|
||||
isSavedMessages,
|
||||
};
|
||||
},
|
||||
)(PickerSelectedItem));
|
||||
|
||||
@ -23,6 +23,7 @@ import Avatar from './Avatar';
|
||||
import TypingStatus from './TypingStatus';
|
||||
import DotAnimation from './DotAnimation';
|
||||
import FullNameTitle from './FullNameTitle';
|
||||
import RippleEffect from '../ui/RippleEffect';
|
||||
|
||||
type OwnProps = {
|
||||
userId: string;
|
||||
@ -31,9 +32,11 @@ type OwnProps = {
|
||||
forceShowSelf?: boolean;
|
||||
status?: string;
|
||||
statusIcon?: string;
|
||||
ripple?: boolean;
|
||||
withDots?: boolean;
|
||||
withMediaViewer?: boolean;
|
||||
withUsername?: boolean;
|
||||
withStory?: boolean;
|
||||
withFullInfo?: boolean;
|
||||
withUpdatingStatus?: boolean;
|
||||
noEmojiStatus?: boolean;
|
||||
@ -59,6 +62,7 @@ const PrivateChatInfo: FC<OwnProps & StateProps> = ({
|
||||
withDots,
|
||||
withMediaViewer,
|
||||
withUsername,
|
||||
withStory,
|
||||
withFullInfo,
|
||||
withUpdatingStatus,
|
||||
emojiStatusSize,
|
||||
@ -70,6 +74,7 @@ const PrivateChatInfo: FC<OwnProps & StateProps> = ({
|
||||
isSavedMessages,
|
||||
areMessagesLoaded,
|
||||
adminMember,
|
||||
ripple,
|
||||
}) => {
|
||||
const {
|
||||
loadFullUser,
|
||||
@ -177,12 +182,15 @@ const PrivateChatInfo: FC<OwnProps & StateProps> = ({
|
||||
size={avatarSize}
|
||||
peer={user}
|
||||
isSavedMessages={isSavedMessages}
|
||||
withStory={withStory}
|
||||
storyViewerMode="single-user"
|
||||
onClick={withMediaViewer ? handleAvatarViewerOpen : undefined}
|
||||
/>
|
||||
<div className="info">
|
||||
{renderNameTitle()}
|
||||
{(status || (!isSavedMessages && !noStatusOrTyping)) && renderStatusOrTyping()}
|
||||
</div>
|
||||
{ripple && <RippleEffect />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -24,6 +24,7 @@ import useCurrentOrPrev from '../../hooks/useCurrentOrPrev';
|
||||
export type OwnProps = {
|
||||
isOpen: boolean;
|
||||
searchPlaceholder: string;
|
||||
className?: string;
|
||||
filter?: ApiChatType[];
|
||||
loadMore?: NoneToVoidFunction;
|
||||
onSelectRecipient: (peerId: string, threadId?: number) => void;
|
||||
@ -49,6 +50,7 @@ const RecipientPicker: FC<OwnProps & StateProps> = ({
|
||||
pinnedIds,
|
||||
contactIds,
|
||||
filter = API_CHAT_TYPES,
|
||||
className,
|
||||
searchPlaceholder,
|
||||
loadMore,
|
||||
onSelectRecipient,
|
||||
@ -93,6 +95,7 @@ const RecipientPicker: FC<OwnProps & StateProps> = ({
|
||||
return (
|
||||
<ChatOrUserPicker
|
||||
isOpen={isOpen}
|
||||
className={className}
|
||||
chatOrUserIds={renderingIds}
|
||||
chatsById={chatsById}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
|
||||
@ -6,6 +6,7 @@ import { getActions } from '../../global';
|
||||
|
||||
import type { ApiPhoto, ApiReportReason } from '../../api/types';
|
||||
|
||||
import buildClassName from '../../util/buildClassName';
|
||||
import useLastCallback from '../../hooks/useLastCallback';
|
||||
import useLang from '../../hooks/useLang';
|
||||
|
||||
@ -16,10 +17,12 @@ import InputText from '../ui/InputText';
|
||||
|
||||
export type OwnProps = {
|
||||
isOpen: boolean;
|
||||
subject?: 'peer' | 'messages' | 'media';
|
||||
subject?: 'peer' | 'messages' | 'media' | 'story';
|
||||
chatId?: string;
|
||||
userId?: string;
|
||||
photo?: ApiPhoto;
|
||||
messageIds?: number[];
|
||||
storyId?: number;
|
||||
onClose: () => void;
|
||||
onCloseAnimationEnd?: () => void;
|
||||
};
|
||||
@ -28,8 +31,10 @@ const ReportModal: FC<OwnProps> = ({
|
||||
isOpen,
|
||||
subject = 'messages',
|
||||
chatId,
|
||||
userId,
|
||||
photo,
|
||||
messageIds,
|
||||
storyId,
|
||||
onClose,
|
||||
onCloseAnimationEnd,
|
||||
}) => {
|
||||
@ -37,6 +42,7 @@ const ReportModal: FC<OwnProps> = ({
|
||||
reportMessages,
|
||||
reportPeer,
|
||||
reportProfilePhoto,
|
||||
reportStory,
|
||||
exitMessageSelectMode,
|
||||
} = getActions();
|
||||
|
||||
@ -57,6 +63,10 @@ const ReportModal: FC<OwnProps> = ({
|
||||
chatId, photo, reason: selectedReason, description,
|
||||
});
|
||||
break;
|
||||
case 'story':
|
||||
reportStory({
|
||||
userId: userId!, storyId: storyId!, reason: selectedReason, description,
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
});
|
||||
@ -86,6 +96,7 @@ const ReportModal: FC<OwnProps> = ({
|
||||
(subject === 'messages' && !messageIds)
|
||||
|| (subject === 'peer' && !chatId)
|
||||
|| (subject === 'media' && (!chatId || !photo))
|
||||
|| (subject === 'story' && (!storyId || !userId))
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
@ -100,7 +111,7 @@ const ReportModal: FC<OwnProps> = ({
|
||||
onClose={onClose}
|
||||
onEnter={isOpen ? handleReport : undefined}
|
||||
onCloseAnimationEnd={onCloseAnimationEnd}
|
||||
className="narrow"
|
||||
className={buildClassName('narrow', subject === 'story' && 'component-theme-dark')}
|
||||
title={title}
|
||||
>
|
||||
<RadioGroup
|
||||
|
||||
@ -41,7 +41,7 @@ type OwnProps = {
|
||||
stickerSet: StickerSetOrReactionsSetOrRecent;
|
||||
loadAndPlay: boolean;
|
||||
index: number;
|
||||
idPrefix?: string;
|
||||
idPrefix: string;
|
||||
isNearActive: boolean;
|
||||
favoriteStickers?: ApiSticker[];
|
||||
isSavedMessages?: boolean;
|
||||
@ -53,6 +53,7 @@ type OwnProps = {
|
||||
withDefaultTopicIcon?: boolean;
|
||||
withDefaultStatusIcon?: boolean;
|
||||
isTranslucent?: boolean;
|
||||
noContextMenus?: boolean;
|
||||
observeIntersection?: ObserveFn;
|
||||
observeIntersectionForPlayingItems: ObserveFn;
|
||||
observeIntersectionForShowingItems: ObserveFn;
|
||||
@ -90,6 +91,7 @@ const StickerSet: FC<OwnProps> = ({
|
||||
selectedReactionIds,
|
||||
withDefaultStatusIcon,
|
||||
isTranslucent,
|
||||
noContextMenus,
|
||||
observeIntersection,
|
||||
observeIntersectionForPlayingItems,
|
||||
observeIntersectionForShowingItems,
|
||||
@ -243,7 +245,7 @@ const StickerSet: FC<OwnProps> = ({
|
||||
<div
|
||||
ref={ref}
|
||||
key={stickerSet.id}
|
||||
id={`${idPrefix || 'sticker-set'}-${index}`}
|
||||
id={`${idPrefix}-${index}`}
|
||||
className={
|
||||
buildClassName('symbol-set', isLocked && 'symbol-set-locked')
|
||||
}
|
||||
@ -343,6 +345,7 @@ const StickerSet: FC<OwnProps> = ({
|
||||
isSavedMessages={isSavedMessages}
|
||||
isStatusPicker={isStatusPicker}
|
||||
canViewSet
|
||||
noContextMenu={noContextMenus}
|
||||
isCurrentUserPremium={isCurrentUserPremium}
|
||||
sharedCanvasRef={canvasRef}
|
||||
withTranslucentThumb={isTranslucent}
|
||||
|
||||
@ -8,7 +8,8 @@
|
||||
html.theme-dark &,
|
||||
html.theme-light .ListItem.selected &,
|
||||
.ActionMessage &,
|
||||
.MediaViewerFooter & {
|
||||
.MediaViewerFooter &,
|
||||
#StoryViewer & {
|
||||
background-image: url('../../../assets/spoiler-dots-white.png');
|
||||
}
|
||||
|
||||
|
||||
@ -2,8 +2,13 @@
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
.chat-list {
|
||||
height: calc(100% - var(--header-height));
|
||||
.left-header {
|
||||
position: relative;
|
||||
z-index: var(--z-left-header);
|
||||
}
|
||||
|
||||
.left-header-shadow {
|
||||
box-shadow: 0 2px 2px var(--color-light-shadow);
|
||||
}
|
||||
|
||||
.DropdownMenuFiller {
|
||||
@ -32,4 +37,29 @@
|
||||
.archived-chats-more-menu {
|
||||
margin-left: auto !important;
|
||||
}
|
||||
|
||||
.story-toggler-wrapper {
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-list-wrapper {
|
||||
--story-ribbon-height: 5.5rem;
|
||||
height: calc(100% - var(--header-height));
|
||||
position: relative;
|
||||
|
||||
&.shown {
|
||||
transform: translateY(calc(var(--story-ribbon-height) * -1));
|
||||
height: calc(100% - var(--header-height) + var(--story-ribbon-height));
|
||||
transition: none;
|
||||
}
|
||||
|
||||
&.open, &.closing {
|
||||
transition: transform 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
&.open {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,6 +20,8 @@ import ChatList from './main/ChatList';
|
||||
import ForumPanel from './main/ForumPanel';
|
||||
import DropdownMenu from '../ui/DropdownMenu';
|
||||
import MenuItem from '../ui/MenuItem';
|
||||
import StoryRibbon from '../story/StoryRibbon';
|
||||
import StoryToggler from '../story/StoryToggler';
|
||||
|
||||
import './ArchivedChats.scss';
|
||||
|
||||
@ -27,6 +29,7 @@ export type OwnProps = {
|
||||
isActive: boolean;
|
||||
isForumPanelOpen?: boolean;
|
||||
archiveSettings: GlobalState['archiveSettings'];
|
||||
isStoryRibbonShown?: boolean;
|
||||
onReset: () => void;
|
||||
onTopicSearch: NoneToVoidFunction;
|
||||
onSettingsScreenSelect: (screen: SettingsScreens) => void;
|
||||
@ -38,6 +41,7 @@ const ArchivedChats: FC<OwnProps> = ({
|
||||
isActive,
|
||||
isForumPanelOpen,
|
||||
archiveSettings,
|
||||
isStoryRibbonShown,
|
||||
onReset,
|
||||
onTopicSearch,
|
||||
onSettingsScreenSelect,
|
||||
@ -72,9 +76,15 @@ const ArchivedChats: FC<OwnProps> = ({
|
||||
} = useForumPanelRender(isForumPanelOpen);
|
||||
const isForumPanelVisible = isForumPanelOpen && isAnimationStarted;
|
||||
|
||||
const {
|
||||
shouldRender: shouldRenderStoryRibbon,
|
||||
transitionClassNames: storyRibbonClassNames,
|
||||
isClosing: isStoryRibbonClosing,
|
||||
} = useShowTransition(isStoryRibbonShown, undefined, undefined, '');
|
||||
|
||||
return (
|
||||
<div className="ArchivedChats">
|
||||
<div className="left-header">
|
||||
<div className={buildClassName('left-header', !shouldRenderStoryRibbon && 'left-header-shadow')}>
|
||||
{lang.isRtl && <div className="DropdownMenuFiller" />}
|
||||
<Button
|
||||
round
|
||||
@ -92,6 +102,9 @@ const ArchivedChats: FC<OwnProps> = ({
|
||||
<i className="icon icon-arrow-left" />
|
||||
</Button>
|
||||
{shouldRenderTitle && <h3 className={titleClassNames}>{lang('ArchivedChats')}</h3>}
|
||||
<div className="story-toggler-wrapper">
|
||||
<StoryToggler canShow isArchived />
|
||||
</div>
|
||||
{archiveSettings.isHidden && (
|
||||
<DropdownMenu
|
||||
className="archived-chats-more-menu"
|
||||
@ -104,15 +117,20 @@ const ArchivedChats: FC<OwnProps> = ({
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
<ChatList
|
||||
folderType="archived"
|
||||
isActive={isActive}
|
||||
isForumPanelOpen={isForumPanelVisible}
|
||||
onSettingsScreenSelect={onSettingsScreenSelect}
|
||||
onLeftColumnContentChange={onLeftColumnContentChange}
|
||||
foldersDispatch={foldersDispatch}
|
||||
archiveSettings={archiveSettings}
|
||||
/>
|
||||
<div className={buildClassName('chat-list-wrapper', storyRibbonClassNames)}>
|
||||
{shouldRenderStoryRibbon && (
|
||||
<StoryRibbon isArchived className="left-header-shadow" isClosing={isStoryRibbonClosing} />
|
||||
)}
|
||||
<ChatList
|
||||
folderType="archived"
|
||||
isActive={isActive}
|
||||
isForumPanelOpen={isForumPanelVisible}
|
||||
onSettingsScreenSelect={onSettingsScreenSelect}
|
||||
onLeftColumnContentChange={onLeftColumnContentChange}
|
||||
foldersDispatch={foldersDispatch}
|
||||
archiveSettings={archiveSettings}
|
||||
/>
|
||||
</div>
|
||||
{shouldRenderForumPanel && (
|
||||
<ForumPanel
|
||||
isOpen={isForumPanelOpen}
|
||||
|
||||
@ -45,6 +45,7 @@ type StateProps = {
|
||||
forumPanelChatId?: string;
|
||||
isClosingSearch?: boolean;
|
||||
archiveSettings: GlobalState['archiveSettings'];
|
||||
isArchivedStoryRibbonShown?: boolean;
|
||||
};
|
||||
|
||||
enum ContentType {
|
||||
@ -77,6 +78,7 @@ function LeftColumn({
|
||||
forumPanelChatId,
|
||||
isClosingSearch,
|
||||
archiveSettings,
|
||||
isArchivedStoryRibbonShown,
|
||||
}: OwnProps & StateProps) {
|
||||
const {
|
||||
setGlobalSearchQuery,
|
||||
@ -439,6 +441,7 @@ function LeftColumn({
|
||||
onLeftColumnContentChange={setContent}
|
||||
isForumPanelOpen={isForumPanelOpen}
|
||||
archiveSettings={archiveSettings}
|
||||
isStoryRibbonShown={isArchivedStoryRibbonShown}
|
||||
/>
|
||||
);
|
||||
case ContentType.Settings:
|
||||
@ -525,6 +528,9 @@ export default memo(withGlobal<OwnProps>(
|
||||
activeChatFolder,
|
||||
nextSettingsScreen,
|
||||
nextFoldersAction,
|
||||
storyViewer: {
|
||||
isArchivedRibbonShown,
|
||||
},
|
||||
} = tabState;
|
||||
const {
|
||||
currentUserId,
|
||||
@ -555,6 +561,7 @@ export default memo(withGlobal<OwnProps>(
|
||||
forumPanelChatId,
|
||||
isClosingSearch: tabState.globalSearch.isClosing,
|
||||
archiveSettings,
|
||||
isArchivedStoryRibbonShown: isArchivedRibbonShown,
|
||||
};
|
||||
},
|
||||
)(LeftColumn));
|
||||
|
||||
@ -2,6 +2,11 @@
|
||||
--background-color: var(--color-background);
|
||||
--thumbs-background: var(--background-color);
|
||||
|
||||
--z-forum-indicator: 2;
|
||||
--z-badge: 4;
|
||||
--z-ripple: 6;
|
||||
--z-status: 8; // Avatar stories require a higher z-index than the ripple to work
|
||||
|
||||
body.is-ios &,
|
||||
body.is-macos & {
|
||||
--color-text-meta: var(--color-text-meta-apple);
|
||||
@ -168,7 +173,7 @@
|
||||
}
|
||||
|
||||
background: var(--color-primary);
|
||||
z-index: 1;
|
||||
z-index: var(--z-forum-indicator);
|
||||
|
||||
border-start-end-radius: var(--border-radius-default);
|
||||
border-end-end-radius: var(--border-radius-default);
|
||||
@ -191,7 +196,7 @@
|
||||
}
|
||||
|
||||
.ripple-container {
|
||||
z-index: 2;
|
||||
z-index: var(--z-ripple);
|
||||
}
|
||||
|
||||
.status {
|
||||
@ -199,15 +204,15 @@
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
z-index: 1;
|
||||
background: var(--background-color);
|
||||
z-index: var(--z-status);
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
.avatar-badge-wrapper {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0.5rem;
|
||||
z-index: 2;
|
||||
z-index: var(--z-badge);
|
||||
|
||||
--outline-color: var(--color-background);
|
||||
|
||||
@ -312,8 +317,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
.colon {
|
||||
margin-inline-end: 0.25rem;
|
||||
.colon, .forward {
|
||||
margin-inline-end: 0.1875rem;
|
||||
}
|
||||
|
||||
.forward {
|
||||
font-size: 0.875rem;
|
||||
display: inline-block;
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.media-preview-spoiler {
|
||||
|
||||
@ -259,10 +259,13 @@ const Chat: FC<OwnProps & StateProps> = ({
|
||||
onDragEnter={handleDragEnter}
|
||||
withPortalForMenu
|
||||
>
|
||||
<div className="status">
|
||||
<div className={buildClassName('status', 'status-clickable')}>
|
||||
<Avatar
|
||||
peer={peer}
|
||||
isSavedMessages={user?.isSelf}
|
||||
withStory={user && !user?.isSelf}
|
||||
withStoryGap={isAvatarOnlineShown}
|
||||
storyViewerMode="single-user"
|
||||
/>
|
||||
<div className="avatar-badge-wrapper">
|
||||
<div className={buildClassName('avatar-online', isAvatarOnlineShown && 'avatar-online-shown')} />
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { FC } from '../../../lib/teact/teact';
|
||||
import React, {
|
||||
memo, useEffect, useMemo, useRef,
|
||||
memo, useEffect, useLayoutEffect, useMemo, useRef, useState,
|
||||
} from '../../../lib/teact/teact';
|
||||
import { getActions, getGlobal, withGlobal } from '../../../global';
|
||||
|
||||
@ -10,7 +10,7 @@ import type { FolderEditDispatch } from '../../../hooks/reducers/useFoldersReduc
|
||||
import type { GlobalState } from '../../../global/types';
|
||||
import type { TabWithProperties } from '../../ui/TabList';
|
||||
|
||||
import { ALL_FOLDER_ID } from '../../../config';
|
||||
import { ALL_FOLDER_ID, ANIMATION_END_DELAY } from '../../../config';
|
||||
import { IS_TOUCH_ENV } from '../../../util/windowEnvironment';
|
||||
import { MEMO_EMPTY_ARRAY } from '../../../util/memo';
|
||||
import { captureEvents, SwipeDirection } from '../../../util/captureEvents';
|
||||
@ -28,6 +28,7 @@ import { useFolderManagerForUnreadCounters } from '../../../hooks/useFolderManag
|
||||
import Transition from '../../ui/Transition';
|
||||
import TabList from '../../ui/TabList';
|
||||
import ChatList from './ChatList';
|
||||
import StoryRibbon from '../../story/StoryRibbon';
|
||||
|
||||
type OwnProps = {
|
||||
onSettingsScreenSelect: (screen: SettingsScreens) => void;
|
||||
@ -48,11 +49,14 @@ type StateProps = {
|
||||
maxChatLists: number;
|
||||
maxFolderInvites: number;
|
||||
hasArchivedChats?: boolean;
|
||||
hasArchivedStories?: boolean;
|
||||
archiveSettings: GlobalState['archiveSettings'];
|
||||
isStoryRibbonShown?: boolean;
|
||||
};
|
||||
|
||||
const SAVED_MESSAGES_HOTKEY = '0';
|
||||
const FIRST_FOLDER_INDEX = 0;
|
||||
const STORY_RIBBON_APPEARANCE_DURATION_MS = 200 + ANIMATION_END_DELAY;
|
||||
|
||||
const ChatFolders: FC<OwnProps & StateProps> = ({
|
||||
foldersDispatch,
|
||||
@ -70,7 +74,9 @@ const ChatFolders: FC<OwnProps & StateProps> = ({
|
||||
folderInvitesById,
|
||||
maxFolderInvites,
|
||||
hasArchivedChats,
|
||||
hasArchivedStories,
|
||||
archiveSettings,
|
||||
isStoryRibbonShown,
|
||||
}) => {
|
||||
const {
|
||||
loadChatFolders,
|
||||
@ -86,11 +92,34 @@ const ChatFolders: FC<OwnProps & StateProps> = ({
|
||||
const transitionRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const lang = useLang();
|
||||
const [isStoryRibbonAnimated, setIsStoryRibbonAnimated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadChatFolders();
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
let timeoutId: number;
|
||||
|
||||
if (isStoryRibbonShown) {
|
||||
timeoutId = window.setTimeout(() => {
|
||||
setIsStoryRibbonAnimated(true);
|
||||
}, STORY_RIBBON_APPEARANCE_DURATION_MS);
|
||||
} else {
|
||||
setIsStoryRibbonAnimated(false);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [isStoryRibbonShown]);
|
||||
|
||||
const {
|
||||
shouldRender: shouldRenderStoryRibbon,
|
||||
transitionClassNames: storyRibbonClassNames,
|
||||
isClosing: isStoryRibbonClosing,
|
||||
} = useShowTransition(isStoryRibbonShown, undefined, undefined, '');
|
||||
|
||||
const allChatsFolder: ApiChatFolder = useMemo(() => {
|
||||
return {
|
||||
id: ALL_FOLDER_ID,
|
||||
@ -285,7 +314,7 @@ const ChatFolders: FC<OwnProps & StateProps> = ({
|
||||
foldersDispatch={foldersDispatch}
|
||||
onSettingsScreenSelect={onSettingsScreenSelect}
|
||||
onLeftColumnContentChange={onLeftColumnContentChange}
|
||||
canDisplayArchive={hasArchivedChats && !archiveSettings.isHidden}
|
||||
canDisplayArchive={(hasArchivedChats || hasArchivedStories) && !archiveSettings.isHidden}
|
||||
archiveSettings={archiveSettings}
|
||||
/>
|
||||
);
|
||||
@ -298,8 +327,11 @@ const ChatFolders: FC<OwnProps & StateProps> = ({
|
||||
className={buildClassName(
|
||||
'ChatFolders',
|
||||
shouldRenderFolders && shouldHideFolderTabs && 'ChatFolders--tabs-hidden',
|
||||
shouldRenderStoryRibbon && !isStoryRibbonAnimated && 'withStoryRibbon',
|
||||
storyRibbonClassNames,
|
||||
)}
|
||||
>
|
||||
{shouldRenderStoryRibbon && <StoryRibbon isClosing={isStoryRibbonClosing} />}
|
||||
{shouldRenderFolders ? (
|
||||
<TabList
|
||||
contextRootElementSelector="#LeftColumn"
|
||||
@ -336,10 +368,16 @@ export default memo(withGlobal<OwnProps>(
|
||||
archived,
|
||||
},
|
||||
},
|
||||
stories: {
|
||||
orderedUserIds: {
|
||||
archived: archivedStories,
|
||||
},
|
||||
},
|
||||
currentUserId,
|
||||
archiveSettings,
|
||||
} = global;
|
||||
const { shouldSkipHistoryAnimations, activeChatFolder } = selectTabState(global);
|
||||
const { storyViewer: { isRibbonShown: isStoryRibbonShown } } = selectTabState(global);
|
||||
|
||||
return {
|
||||
chatFoldersById,
|
||||
@ -349,10 +387,12 @@ export default memo(withGlobal<OwnProps>(
|
||||
currentUserId,
|
||||
shouldSkipHistoryAnimations,
|
||||
hasArchivedChats: Boolean(archived?.length),
|
||||
hasArchivedStories: Boolean(archivedStories?.length),
|
||||
maxFolders: selectCurrentLimit(global, 'dialogFilters'),
|
||||
maxFolderInvites: selectCurrentLimit(global, 'chatlistInvites'),
|
||||
maxChatLists: selectCurrentLimit(global, 'chatlistJoined'),
|
||||
archiveSettings,
|
||||
isStoryRibbonShown,
|
||||
};
|
||||
},
|
||||
)(ChatFolders));
|
||||
|
||||
@ -27,12 +27,14 @@ import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver'
|
||||
import { useHotkeys } from '../../../hooks/useHotkeys';
|
||||
import useDebouncedCallback from '../../../hooks/useDebouncedCallback';
|
||||
import useOrderDiff from './hooks/useOrderDiff';
|
||||
import useUserStoriesPolling from '../../../hooks/polling/useUserStoriesPolling';
|
||||
|
||||
import InfiniteScroll from '../../ui/InfiniteScroll';
|
||||
import Loading from '../../ui/Loading';
|
||||
import Chat from './Chat';
|
||||
import EmptyFolder from './EmptyFolder';
|
||||
import Archive from './Archive';
|
||||
import useTopOverscroll from '../../../hooks/scroll/useTopOverscroll';
|
||||
|
||||
type OwnProps = {
|
||||
folderType: 'all' | 'archived' | 'folder';
|
||||
@ -41,6 +43,8 @@ type OwnProps = {
|
||||
canDisplayArchive?: boolean;
|
||||
archiveSettings: GlobalState['archiveSettings'];
|
||||
isForumPanelOpen?: boolean;
|
||||
isStoryRibbonShown?: boolean;
|
||||
className?: string;
|
||||
foldersDispatch: FolderEditDispatch;
|
||||
onSettingsScreenSelect: (screen: SettingsScreens) => void;
|
||||
onLeftColumnContentChange: (content: LeftColumnContent) => void;
|
||||
@ -61,18 +65,25 @@ const ChatList: FC<OwnProps> = ({
|
||||
onSettingsScreenSelect,
|
||||
onLeftColumnContentChange,
|
||||
}) => {
|
||||
const { openChat, openNextChat, closeForumPanel } = getActions();
|
||||
const {
|
||||
openChat,
|
||||
openNextChat,
|
||||
closeForumPanel,
|
||||
toggleStoryRibbon,
|
||||
} = getActions();
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const shouldIgnoreDragRef = useRef(false);
|
||||
|
||||
const isArchived = folderType === 'archived';
|
||||
const resolvedFolderId = (
|
||||
folderType === 'all' ? ALL_FOLDER_ID : folderType === 'archived' ? ARCHIVED_FOLDER_ID : folderId!
|
||||
folderType === 'all' ? ALL_FOLDER_ID : isArchived ? ARCHIVED_FOLDER_ID : folderId!
|
||||
);
|
||||
|
||||
const shouldDisplayArchive = folderType === 'all' && canDisplayArchive;
|
||||
|
||||
const orderedIds = useFolderManagerForOrderedIds(resolvedFolderId);
|
||||
useUserStoriesPolling(orderedIds);
|
||||
|
||||
const chatsHeight = (orderedIds?.length || 0) * CHAT_HEIGHT_PX;
|
||||
const archiveHeight = shouldDisplayArchive
|
||||
@ -162,6 +173,16 @@ const ChatList: FC<OwnProps> = ({
|
||||
shouldIgnoreDragRef.current = true;
|
||||
});
|
||||
|
||||
const handleShowStoryRibbon = useLastCallback(() => {
|
||||
toggleStoryRibbon({ isShown: true, isArchived });
|
||||
});
|
||||
|
||||
const handleHideStoryRibbon = useLastCallback(() => {
|
||||
toggleStoryRibbon({ isShown: false, isArchived });
|
||||
});
|
||||
|
||||
const renderedOverflowTrigger = useTopOverscroll(containerRef, handleShowStoryRibbon, handleHideStoryRibbon);
|
||||
|
||||
function renderChats() {
|
||||
const viewportOffset = orderedIds!.indexOf(viewportIds![0]);
|
||||
|
||||
@ -196,6 +217,7 @@ const ChatList: FC<OwnProps> = ({
|
||||
itemSelector=".ListItem:not(.chat-item-archive)"
|
||||
preloadBackwards={CHAT_LIST_SLICE}
|
||||
withAbsolutePositioning
|
||||
beforeChildren={renderedOverflowTrigger}
|
||||
maxHeight={chatsHeight + archiveHeight}
|
||||
onLoadMore={getMore}
|
||||
onDragLeave={handleDragLeave}
|
||||
|
||||
@ -71,12 +71,11 @@ const ContactList: FC<OwnProps & StateProps> = ({
|
||||
viewportIds.map((id) => (
|
||||
<ListItem
|
||||
key={id}
|
||||
className="chat-item-clickable"
|
||||
className="chat-item-clickable contact-list-item"
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => handleClick(id)}
|
||||
ripple={!isMobile}
|
||||
>
|
||||
<PrivateChatInfo userId={id} forceShowSelf avatarSize="large" />
|
||||
<PrivateChatInfo userId={id} forceShowSelf avatarSize="large" withStory ripple={!isMobile} />
|
||||
</ListItem>
|
||||
))
|
||||
) : viewportIds && !viewportIds.length ? (
|
||||
|
||||
@ -16,6 +16,23 @@
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
&.withStoryRibbon {
|
||||
--story-ribbon-height: 5.5rem;
|
||||
&.shown {
|
||||
transform: translateY(calc(var(--story-ribbon-height) * -1));
|
||||
height: calc(100% + var(--story-ribbon-height));
|
||||
transition: none;
|
||||
}
|
||||
|
||||
&.open, &.closing {
|
||||
transition: transform 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
&.open {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.tabs-placeholder {
|
||||
height: 2.625rem;
|
||||
/* stylelint-disable-next-line plugin/no-low-performance-animation-properties */
|
||||
|
||||
@ -61,6 +61,7 @@ import ShowTransition from '../../ui/ShowTransition';
|
||||
import ConnectionStatusOverlay from '../ConnectionStatusOverlay';
|
||||
import StatusButton from './StatusButton';
|
||||
import Toggle from '../../ui/Toggle';
|
||||
import StoryToggler from '../../story/StoryToggler';
|
||||
|
||||
import './LeftMainHeader.scss';
|
||||
|
||||
@ -132,6 +133,7 @@ const LeftMainHeader: FC<OwnProps & StateProps> = ({
|
||||
}) => {
|
||||
const {
|
||||
openChat,
|
||||
openChatWithInfo,
|
||||
setGlobalSearchDate,
|
||||
setSettingOption,
|
||||
setGlobalSearchChatId,
|
||||
@ -173,6 +175,10 @@ const LeftMainHeader: FC<OwnProps & StateProps> = ({
|
||||
}
|
||||
});
|
||||
|
||||
const handleOpenMyStories = useLastCallback(() => {
|
||||
openChatWithInfo({ id: currentUserId, shouldReplaceHistory: true, profileTab: 'stories' });
|
||||
});
|
||||
|
||||
useHotkeys(canSetPasscode ? {
|
||||
'Ctrl+Shift+L': handleLockScreenHotkey,
|
||||
'Alt+Shift+L': handleLockScreenHotkey,
|
||||
@ -317,6 +323,12 @@ const LeftMainHeader: FC<OwnProps & StateProps> = ({
|
||||
>
|
||||
{lang('Contacts')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon="play-story"
|
||||
onClick={handleOpenMyStories}
|
||||
>
|
||||
{lang('Settings.MyStories')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon="settings"
|
||||
onClick={onSelectSettings}
|
||||
@ -453,6 +465,7 @@ const LeftMainHeader: FC<OwnProps & StateProps> = ({
|
||||
onSpinnerClick={connectionStatusPosition === 'minimized' ? toggleConnectionStatus : undefined}
|
||||
>
|
||||
{searchContent}
|
||||
<StoryToggler canShow={!isSearchFocused && !selectedSearchDate && !globalSearchChatId} />
|
||||
</SearchInput>
|
||||
{isCurrentUserPremium && <StatusButton />}
|
||||
{hasPasscode && (
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
:global(body:not(.no-menu-blur)) & {
|
||||
--color-background: var(--color-background-compact-menu);
|
||||
|
||||
backdrop-filter: blur(10px);
|
||||
backdrop-filter: blur(25px);
|
||||
}
|
||||
|
||||
@media (max-width: 26rem) {
|
||||
|
||||
@ -151,6 +151,7 @@ export default function useChatListEntry({
|
||||
<span className="colon">:</span>
|
||||
</>
|
||||
)}
|
||||
{lastMessage.forwardInfo && (<i className="icon icon-share-filled forward" />)}
|
||||
{renderSummary(lang, lastMessage, observeIntersection, mediaBlobUrl || mediaThumbnail, isRoundVideo)}
|
||||
</p>
|
||||
);
|
||||
|
||||
@ -117,6 +117,10 @@
|
||||
|
||||
.ListItem.search-result {
|
||||
.ChatInfo {
|
||||
// Fix for overflow hidden and stories indicator
|
||||
padding: 0.0625rem;
|
||||
margin: -0.0625rem;
|
||||
|
||||
.handle {
|
||||
unicode-bidi: plaintext;
|
||||
color: var(--color-primary);
|
||||
|
||||
@ -85,7 +85,7 @@ const LeftSearchResultChat: FC<OwnProps & StateProps> = ({
|
||||
buttonRef={buttonRef}
|
||||
>
|
||||
{isUserId(chatId) ? (
|
||||
<PrivateChatInfo userId={chatId} withUsername={withUsername} avatarSize="large" />
|
||||
<PrivateChatInfo userId={chatId} withUsername={withUsername} withStory avatarSize="large" />
|
||||
) : (
|
||||
<GroupChatInfo chatId={chatId} withUsername={withUsername} avatarSize="large" />
|
||||
)}
|
||||
|
||||
@ -2,13 +2,15 @@ import type { FC } from '../../lib/teact/teact';
|
||||
import React, {
|
||||
memo, useCallback, useEffect,
|
||||
} from '../../lib/teact/teact';
|
||||
import { getActions, withGlobal } from '../../global';
|
||||
import { getActions, getGlobal, withGlobal } from '../../global';
|
||||
|
||||
import { selectTabState } from '../../global/selectors';
|
||||
import { selectChat, selectTabState, selectUser } from '../../global/selectors';
|
||||
import { getChatTitle, getUserFirstOrLastName, isUserId } from '../../global/helpers';
|
||||
import useLang from '../../hooks/useLang';
|
||||
import useFlag from '../../hooks/useFlag';
|
||||
|
||||
import RecipientPicker from '../common/RecipientPicker';
|
||||
import usePrevious from '../../hooks/usePrevious';
|
||||
|
||||
export type OwnProps = {
|
||||
isOpen: boolean;
|
||||
@ -17,22 +19,26 @@ export type OwnProps = {
|
||||
interface StateProps {
|
||||
currentUserId?: string;
|
||||
isManyMessages?: boolean;
|
||||
isStory?: boolean;
|
||||
}
|
||||
|
||||
const ForwardRecipientPicker: FC<OwnProps & StateProps> = ({
|
||||
isOpen,
|
||||
currentUserId,
|
||||
isManyMessages,
|
||||
isStory,
|
||||
}) => {
|
||||
const {
|
||||
setForwardChatOrTopic,
|
||||
exitForwardMode,
|
||||
forwardToSavedMessages,
|
||||
forwardStory,
|
||||
showNotification,
|
||||
} = getActions();
|
||||
|
||||
const lang = useLang();
|
||||
|
||||
const renderingIsStory = usePrevious(isStory, true);
|
||||
const [isShown, markIsShown, unmarkIsShown] = useFlag();
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
@ -41,17 +47,43 @@ const ForwardRecipientPicker: FC<OwnProps & StateProps> = ({
|
||||
}, [isOpen, markIsShown]);
|
||||
|
||||
const handleSelectRecipient = useCallback((recipientId: string, threadId?: number) => {
|
||||
if (recipientId === currentUserId) {
|
||||
forwardToSavedMessages();
|
||||
showNotification({
|
||||
message: lang(isManyMessages
|
||||
const isSelf = recipientId === currentUserId;
|
||||
if (isStory) {
|
||||
forwardStory({ toChatId: recipientId });
|
||||
const global = getGlobal();
|
||||
if (isUserId(recipientId)) {
|
||||
showNotification({
|
||||
message: isSelf
|
||||
? lang('Conversation.StoryForwardTooltip.SavedMessages.One')
|
||||
: lang(
|
||||
'StorySharedTo',
|
||||
getUserFirstOrLastName(selectUser(global, recipientId)),
|
||||
),
|
||||
});
|
||||
} else {
|
||||
const chat = selectChat(global, recipientId);
|
||||
if (!chat) return;
|
||||
|
||||
showNotification({
|
||||
message: lang('StorySharedTo', getChatTitle(lang, chat)),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSelf) {
|
||||
const message = lang(
|
||||
isManyMessages
|
||||
? 'Conversation.ForwardTooltip.SavedMessages.Many'
|
||||
: 'Conversation.ForwardTooltip.SavedMessages.One'),
|
||||
});
|
||||
: 'Conversation.ForwardTooltip.SavedMessages.One',
|
||||
);
|
||||
|
||||
forwardToSavedMessages();
|
||||
showNotification({ message });
|
||||
} else {
|
||||
setForwardChatOrTopic({ chatId: recipientId, topicId: threadId });
|
||||
}
|
||||
}, [currentUserId, forwardToSavedMessages, isManyMessages, lang, setForwardChatOrTopic, showNotification]);
|
||||
}, [currentUserId, isManyMessages, isStory, lang]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
exitForwardMode();
|
||||
@ -64,6 +96,7 @@ const ForwardRecipientPicker: FC<OwnProps & StateProps> = ({
|
||||
return (
|
||||
<RecipientPicker
|
||||
isOpen={isOpen}
|
||||
className={renderingIsStory ? 'component-theme-dark' : undefined}
|
||||
searchPlaceholder={lang('ForwardTo')}
|
||||
onSelectRecipient={handleSelectRecipient}
|
||||
onClose={handleClose}
|
||||
@ -73,8 +106,10 @@ const ForwardRecipientPicker: FC<OwnProps & StateProps> = ({
|
||||
};
|
||||
|
||||
export default memo(withGlobal<OwnProps>((global): StateProps => {
|
||||
const { messageIds, storyId } = selectTabState(global).forwardMessages;
|
||||
return {
|
||||
currentUserId: global.currentUserId,
|
||||
isManyMessages: (selectTabState(global).forwardMessages.messageIds?.length || 0) > 1,
|
||||
isManyMessages: (messageIds?.length || 0) > 1,
|
||||
isStory: Boolean(storyId),
|
||||
};
|
||||
})(ForwardRecipientPicker));
|
||||
|
||||
@ -40,10 +40,6 @@
|
||||
|
||||
background-color: var(--color-background);
|
||||
|
||||
@media (max-width: 600px) {
|
||||
height: calc(var(--vh, 1vh) * 100);
|
||||
}
|
||||
|
||||
@media (min-width: 926px) {
|
||||
--left-column-max-width: 40vw;
|
||||
}
|
||||
@ -115,6 +111,7 @@
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
height: calc(var(--vh, 1vh) * 100);
|
||||
max-width: none;
|
||||
--left-column-max-width: calc(100vw - env(safe-area-inset-left));
|
||||
transform: translate3d(-20vw, 0, 0);
|
||||
@ -126,6 +123,18 @@
|
||||
left: 0 !important;
|
||||
width: 100vw !important;
|
||||
}
|
||||
|
||||
// Fix: when opening the SymbolMenu, the chat list flashes in the background
|
||||
body.is-symbol-menu-open &::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: var(--color-background);
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -36,6 +36,7 @@ import {
|
||||
selectPerformanceSettingsValue,
|
||||
selectCanAnimateInterface,
|
||||
selectChatFolder,
|
||||
selectIsStoryViewerOpen,
|
||||
} from '../../global/selectors';
|
||||
import { getUserFullName } from '../../global/helpers';
|
||||
import buildClassName from '../../util/buildClassName';
|
||||
@ -57,7 +58,6 @@ import useInterval from '../../hooks/useInterval';
|
||||
import { useFullscreenStatus } from '../../hooks/useFullscreen';
|
||||
import useAppLayout from '../../hooks/useAppLayout';
|
||||
import useTimeout from '../../hooks/useTimeout';
|
||||
import useFlag from '../../hooks/useFlag';
|
||||
|
||||
import StickerSetModal from '../common/StickerSetModal.async';
|
||||
import UnreadCount from '../common/UnreadCounter';
|
||||
@ -94,6 +94,7 @@ import DraftRecipientPicker from './DraftRecipientPicker.async';
|
||||
import AttachBotRecipientPicker from './AttachBotRecipientPicker.async';
|
||||
import ReactionPicker from '../middle/message/ReactionPicker.async';
|
||||
import ChatlistModal from '../modals/chatlist/ChatlistModal.async';
|
||||
import StoryViewer from '../story/StoryViewer.async';
|
||||
|
||||
import './Main.scss';
|
||||
|
||||
@ -108,6 +109,7 @@ type StateProps = {
|
||||
isMiddleColumnOpen: boolean;
|
||||
isRightColumnOpen: boolean;
|
||||
isMediaViewerOpen: boolean;
|
||||
isStoryViewerOpen: boolean;
|
||||
isForwardModalOpen: boolean;
|
||||
hasNotifications: boolean;
|
||||
hasDialogs: boolean;
|
||||
@ -152,7 +154,6 @@ type StateProps = {
|
||||
|
||||
const APP_OUTDATED_TIMEOUT_MS = 5 * 60 * 1000; // 5 min
|
||||
const CALL_BUNDLE_LOADING_DELAY_MS = 5000; // 5 sec
|
||||
const REACTION_PICKER_LOADING_DELAY_MS = 7000; // 7 sec
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
let DEBUG_isLogged = false;
|
||||
@ -163,6 +164,7 @@ const Main: FC<OwnProps & StateProps> = ({
|
||||
isMiddleColumnOpen,
|
||||
isRightColumnOpen,
|
||||
isMediaViewerOpen,
|
||||
isStoryViewerOpen,
|
||||
isForwardModalOpen,
|
||||
hasNotifications,
|
||||
hasDialogs,
|
||||
@ -256,9 +258,6 @@ const Main: FC<OwnProps & StateProps> = ({
|
||||
void loadBundle(Bundles.Calls);
|
||||
}, CALL_BUNDLE_LOADING_DELAY_MS);
|
||||
|
||||
const [shouldLoadReactionPicker, markShouldLoadReactionPicker] = useFlag(false);
|
||||
useTimeout(markShouldLoadReactionPicker, REACTION_PICKER_LOADING_DELAY_MS);
|
||||
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
@ -503,7 +502,7 @@ const Main: FC<OwnProps & StateProps> = ({
|
||||
// Online status and browser tab indicators
|
||||
useBackgroundMode(handleBlur, handleFocus, !!IS_ELECTRON);
|
||||
useBeforeUnload(handleBlur);
|
||||
usePreventPinchZoomGesture(isMediaViewerOpen);
|
||||
usePreventPinchZoomGesture(isMediaViewerOpen || isStoryViewerOpen);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} id="Main" className={className}>
|
||||
@ -511,6 +510,7 @@ const Main: FC<OwnProps & StateProps> = ({
|
||||
<MiddleColumn leftColumnRef={leftColumnRef} isMobile={isMobile} />
|
||||
<RightColumn isMobile={isMobile} />
|
||||
<MediaViewer isOpen={isMediaViewerOpen} />
|
||||
<StoryViewer isOpen={isStoryViewerOpen} />
|
||||
<ForwardRecipientPicker isOpen={isForwardModalOpen} />
|
||||
<DraftRecipientPicker requestedDraft={requestedDraft} />
|
||||
<Notifications isOpen={hasNotifications} />
|
||||
@ -556,7 +556,7 @@ const Main: FC<OwnProps & StateProps> = ({
|
||||
<PaymentModal isOpen={isPaymentModalOpen} onClose={closePaymentModal} />
|
||||
<ReceiptModal isOpen={isReceiptModalOpen} onClose={clearReceipt} />
|
||||
<DeleteFolderDialog folder={deleteFolderDialog} />
|
||||
<ReactionPicker isOpen={isReactionPickerOpen} shouldLoad={shouldLoadReactionPicker} />
|
||||
<ReactionPicker isOpen={isReactionPickerOpen} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -616,6 +616,7 @@ export default memo(withGlobal<OwnProps>(
|
||||
isMiddleColumnOpen: Boolean(chatId),
|
||||
isRightColumnOpen: selectIsRightColumnShown(global, isMobile),
|
||||
isMediaViewerOpen: selectIsMediaViewerOpen(global),
|
||||
isStoryViewerOpen: selectIsStoryViewerOpen(global),
|
||||
isForwardModalOpen: selectIsForwardModalOpen(global),
|
||||
isReactionPickerOpen: selectIsReactionPickerOpen(global),
|
||||
hasNotifications: Boolean(notifications.length),
|
||||
|
||||
@ -8,8 +8,8 @@ import type { ApiDimensions } from '../../api/types';
|
||||
|
||||
import useLastCallback from '../../hooks/useLastCallback';
|
||||
import useSignal from '../../hooks/useSignal';
|
||||
import useCurrentTimeSignal from '../../hooks/useCurrentTimeSignal';
|
||||
import { useThrottledSignal } from '../../hooks/useAsyncResolvers';
|
||||
import useCurrentTimeSignal from './hooks/useCurrentTimeSignal';
|
||||
import useVideoWaitingSignal from './hooks/useVideoWaitingSignal';
|
||||
|
||||
import { captureEvents } from '../../util/captureEvents';
|
||||
|
||||
@ -18,7 +18,7 @@ import usePictureInPicture from '../../hooks/usePictureInPicture';
|
||||
import useShowTransition from '../../hooks/useShowTransition';
|
||||
import useVideoCleanup from '../../hooks/useVideoCleanup';
|
||||
import useAppLayout from '../../hooks/useAppLayout';
|
||||
import useCurrentTimeSignal from './hooks/useCurrentTimeSignal';
|
||||
import useCurrentTimeSignal from '../../hooks/useCurrentTimeSignal';
|
||||
import useControlsSignal from './hooks/useControlsSignal';
|
||||
import useVideoWaitingSignal from './hooks/useVideoWaitingSignal';
|
||||
import useUnsupportedMedia from '../../hooks/media/useUnsupportedMedia';
|
||||
|
||||
@ -12,7 +12,7 @@ import useFlag from '../../hooks/useFlag';
|
||||
import useAppLayout from '../../hooks/useAppLayout';
|
||||
import useDerivedState from '../../hooks/useDerivedState';
|
||||
import useSignal from '../../hooks/useSignal';
|
||||
import useCurrentTimeSignal from './hooks/useCurrentTimeSignal';
|
||||
import useCurrentTimeSignal from '../../hooks/useCurrentTimeSignal';
|
||||
import useControlsSignal from './hooks/useControlsSignal';
|
||||
|
||||
import buildClassName from '../../util/buildClassName';
|
||||
|
||||
@ -123,6 +123,7 @@ type StateProps = {
|
||||
|
||||
const MESSAGE_REACTIONS_POLLING_INTERVAL = 15 * 1000;
|
||||
const MESSAGE_COMMENTS_POLLING_INTERVAL = 15 * 1000;
|
||||
const MESSAGE_STORY_POLLING_INTERVAL = 5 * 60 * 1000;
|
||||
const BOTTOM_THRESHOLD = 50;
|
||||
const UNREAD_DIVIDER_TOP = 10;
|
||||
const UNREAD_DIVIDER_TOP_WITH_TOOLS = 60;
|
||||
@ -173,7 +174,7 @@ const MessageList: FC<OwnProps & StateProps> = ({
|
||||
}) => {
|
||||
const {
|
||||
loadViewportMessages, setScrollOffset, loadSponsoredMessages, loadMessageReactions, copyMessagesByIds,
|
||||
loadMessageViews,
|
||||
loadMessageViews, loadUserStoriesByIds,
|
||||
} = getActions();
|
||||
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
@ -259,6 +260,28 @@ const MessageList: FC<OwnProps & StateProps> = ({
|
||||
loadMessageReactions({ chatId, ids });
|
||||
}, MESSAGE_REACTIONS_POLLING_INTERVAL);
|
||||
|
||||
useInterval(() => {
|
||||
if (!messageIds || !messagesById || type === 'scheduled') {
|
||||
return;
|
||||
}
|
||||
const storyDataList = messageIds.map((id) => messagesById[id]?.content.storyData).filter(Boolean);
|
||||
|
||||
if (!storyDataList.length) return;
|
||||
|
||||
const storiesByUserIds = storyDataList.reduce((acc, storyData) => {
|
||||
const { userId, id } = storyData!;
|
||||
if (!acc[userId]) {
|
||||
acc[userId] = [];
|
||||
}
|
||||
acc[userId].push(id);
|
||||
return acc;
|
||||
}, {} as Record<string, number[]>);
|
||||
|
||||
Object.entries(storiesByUserIds).forEach(([userId, storyIds]) => {
|
||||
loadUserStoriesByIds({ userId, storyIds });
|
||||
});
|
||||
}, MESSAGE_STORY_POLLING_INTERVAL);
|
||||
|
||||
useInterval(() => {
|
||||
if (!messageIds || !messagesById || threadId !== MAIN_THREAD_ID || type === 'scheduled') {
|
||||
return;
|
||||
|
||||
@ -35,7 +35,7 @@
|
||||
}
|
||||
|
||||
.Composer {
|
||||
#message-compose {
|
||||
.composer-wrapper {
|
||||
transform: scaleX(1) translateX(0);
|
||||
transition: transform var(--select-transition), border-bottom-right-radius var(--select-transition);
|
||||
|
||||
@ -83,7 +83,7 @@
|
||||
height: 0;
|
||||
}
|
||||
|
||||
#message-compose {
|
||||
.composer-wrapper {
|
||||
transform: scaleX(var(--composer-hidden-scale, 1)) translateX(var(--composer-translate-x, 0));
|
||||
border-bottom-right-radius: var(--border-radius-messages);
|
||||
|
||||
|
||||
@ -21,6 +21,8 @@ import {
|
||||
GENERAL_TOPIC_ID,
|
||||
TMP_CHAT_ID,
|
||||
MAX_SCREEN_WIDTH_FOR_EXPAND_PINNED_MESSAGES,
|
||||
EDITABLE_INPUT_ID,
|
||||
EDITABLE_INPUT_CSS_SELECTOR,
|
||||
} from '../../config';
|
||||
import {
|
||||
IS_ANDROID, IS_IOS, IS_TRANSLATION_SUPPORTED, MASK_IMAGE_DISABLED,
|
||||
@ -78,7 +80,6 @@ import Transition from '../ui/Transition';
|
||||
import MiddleHeader from './MiddleHeader';
|
||||
import MessageList from './MessageList';
|
||||
import FloatingActionButtons from './FloatingActionButtons';
|
||||
import Composer from './composer/Composer';
|
||||
import Button from '../ui/Button';
|
||||
import MobileSearch from './MobileSearch.async';
|
||||
import MessageSelectToolbar from './MessageSelectToolbar.async';
|
||||
@ -88,6 +89,7 @@ import EmojiInteractionAnimation from './EmojiInteractionAnimation.async';
|
||||
import ReactorListModal from './ReactorListModal.async';
|
||||
import GiftPremiumModal from '../main/premium/GiftPremiumModal.async';
|
||||
import ChatLanguageModal from './ChatLanguageModal.async';
|
||||
import Composer from '../common/Composer';
|
||||
|
||||
import './MiddleColumn.scss';
|
||||
|
||||
@ -529,6 +531,7 @@ function MiddleColumn({
|
||||
<div className={footerClassName}>
|
||||
{renderingCanPost && (
|
||||
<Composer
|
||||
type="messageList"
|
||||
chatId={renderingChatId!}
|
||||
threadId={renderingThreadId!}
|
||||
messageListType={renderingMessageListType!}
|
||||
@ -536,6 +539,9 @@ function MiddleColumn({
|
||||
onDropHide={handleHideDropArea}
|
||||
isReady={isReady}
|
||||
isMobile={isMobile}
|
||||
editableInputId={EDITABLE_INPUT_ID}
|
||||
editableInputCssSelector={EDITABLE_INPUT_CSS_SELECTOR}
|
||||
inputId="message-input-text"
|
||||
/>
|
||||
)}
|
||||
{isPinnedMessageList && canUnpin && (
|
||||
|
||||
@ -31,10 +31,12 @@
|
||||
background: var(--color-background);
|
||||
position: relative;
|
||||
z-index: var(--z-middle-header);
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
padding-left: max(1.5rem, env(safe-area-inset-left));
|
||||
padding-top: 0.25rem;
|
||||
padding-bottom: 0.25rem;
|
||||
padding-left: max(1.4375rem, env(safe-area-inset-left));
|
||||
padding-right: max(0.8125rem, env(safe-area-inset-right));
|
||||
flex-shrink: 0;
|
||||
height: 3.5rem;
|
||||
|
||||
@media (max-width: 600px) {
|
||||
position: relative;
|
||||
@ -214,6 +216,8 @@
|
||||
cursor: var(--custom-cursor, pointer);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
// Space for unread story circle
|
||||
padding: 0.0625rem 0 0.0625rem 0.0625rem;
|
||||
|
||||
@media (max-width: 600px) {
|
||||
user-select: none;
|
||||
@ -266,6 +270,11 @@
|
||||
.custom-emoji {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.story-circle {
|
||||
max-width: 2.625rem !important;
|
||||
max-height: 2.625rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
.Avatar, .topic-header-icon {
|
||||
|
||||
@ -367,6 +367,7 @@ const MiddleHeader: FC<OwnProps & StateProps> = ({
|
||||
withDots={Boolean(connectionStatusText)}
|
||||
withFullInfo
|
||||
withMediaViewer
|
||||
withStory={!isChatWithSelf}
|
||||
withUpdatingStatus
|
||||
emojiStatusSize={EMOJI_STATUS_SIZE}
|
||||
noRtl
|
||||
|
||||
@ -40,11 +40,13 @@ export type OwnProps = {
|
||||
canSendDocuments: boolean;
|
||||
canSendAudios: boolean;
|
||||
isScheduled?: boolean;
|
||||
attachBots: GlobalState['attachMenu']['bots'];
|
||||
attachBots?: GlobalState['attachMenu']['bots'];
|
||||
peerType?: ApiAttachMenuPeerType;
|
||||
shouldCollectDebugLogs?: boolean;
|
||||
onFileSelect: (files: File[], shouldSuggestCompression?: boolean) => void;
|
||||
onPollCreate: () => void;
|
||||
onPollCreate: NoneToVoidFunction;
|
||||
onMenuOpen: NoneToVoidFunction;
|
||||
onMenuClose: NoneToVoidFunction;
|
||||
theme: ISettings['theme'];
|
||||
};
|
||||
|
||||
@ -62,6 +64,8 @@ const AttachMenu: FC<OwnProps> = ({
|
||||
peerType,
|
||||
isScheduled,
|
||||
onFileSelect,
|
||||
onMenuOpen,
|
||||
onMenuClose,
|
||||
onPollCreate,
|
||||
theme,
|
||||
shouldCollectDebugLogs,
|
||||
@ -73,12 +77,22 @@ const AttachMenu: FC<OwnProps> = ({
|
||||
const canSendVideoOrPhoto = canSendPhotos || canSendVideos;
|
||||
|
||||
const [isAttachmentBotMenuOpen, markAttachmentBotMenuOpen, unmarkAttachmentBotMenuOpen] = useFlag();
|
||||
const isMenuOpen = isAttachMenuOpen || isAttachmentBotMenuOpen;
|
||||
|
||||
useEffect(() => {
|
||||
if (isAttachMenuOpen) {
|
||||
markMouseInside();
|
||||
}
|
||||
}, [isAttachMenuOpen, markMouseInside]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMenuOpen) {
|
||||
onMenuOpen();
|
||||
} else {
|
||||
onMenuClose();
|
||||
}
|
||||
}, [isMenuOpen, onMenuClose, onMenuOpen]);
|
||||
|
||||
const handleToggleAttachMenu = useLastCallback(() => {
|
||||
if (isAttachMenuOpen) {
|
||||
closeAttachMenu();
|
||||
@ -118,13 +132,15 @@ const AttachMenu: FC<OwnProps> = ({
|
||||
});
|
||||
|
||||
const bots = useMemo(() => {
|
||||
return Object.values(attachBots).filter((bot) => {
|
||||
if (!peerType) return false;
|
||||
if (peerType === 'bots' && bot.id === chatId && bot.peerTypes.includes('self')) {
|
||||
return true;
|
||||
}
|
||||
return bot.peerTypes.includes(peerType);
|
||||
});
|
||||
return attachBots
|
||||
? Object.values(attachBots).filter((bot) => {
|
||||
if (!peerType) return false;
|
||||
if (peerType === 'bots' && bot.id === chatId && bot.peerTypes.includes('self')) {
|
||||
return true;
|
||||
}
|
||||
return bot.peerTypes.includes(peerType);
|
||||
})
|
||||
: undefined;
|
||||
}, [attachBots, chatId, peerType]);
|
||||
|
||||
const lang = useLang();
|
||||
@ -149,7 +165,7 @@ const AttachMenu: FC<OwnProps> = ({
|
||||
</ResponsiveHoverButton>
|
||||
<Menu
|
||||
id="attach-menu-controls"
|
||||
isOpen={isAttachMenuOpen || isAttachmentBotMenuOpen}
|
||||
isOpen={isMenuOpen}
|
||||
autoClose
|
||||
positionX="right"
|
||||
positionY="bottom"
|
||||
@ -193,7 +209,7 @@ const AttachMenu: FC<OwnProps> = ({
|
||||
<MenuItem icon="poll" onClick={onPollCreate}>{lang('Poll')}</MenuItem>
|
||||
)}
|
||||
|
||||
{canAttachMedia && !isScheduled && bots.map((bot) => (
|
||||
{canAttachMedia && !isScheduled && bots?.map((bot) => (
|
||||
<AttachBotItem
|
||||
bot={bot}
|
||||
chatId={chatId}
|
||||
|
||||
@ -64,11 +64,13 @@ export type OwnProps = {
|
||||
getHtml: Signal<string>;
|
||||
canShowCustomSendMenu?: boolean;
|
||||
isReady: boolean;
|
||||
isForMessage?: boolean;
|
||||
shouldSchedule?: boolean;
|
||||
shouldSuggestCompression?: boolean;
|
||||
shouldForceCompression?: boolean;
|
||||
shouldForceAsFile?: boolean;
|
||||
isForCurrentMessageList?: boolean;
|
||||
forceDarkTheme?: boolean;
|
||||
onCaptionUpdate: (html: string) => void;
|
||||
onSend: (sendCompressed: boolean, sendGrouped: boolean) => void;
|
||||
onFileAppend: (files: File[], isSpoiler?: boolean) => void;
|
||||
@ -112,6 +114,7 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
|
||||
recentEmojis,
|
||||
baseEmojiKeywords,
|
||||
emojiKeywords,
|
||||
isForMessage,
|
||||
shouldSchedule,
|
||||
shouldSuggestCustomEmoji,
|
||||
customEmojiForEmoji,
|
||||
@ -120,6 +123,7 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
|
||||
shouldForceCompression,
|
||||
shouldForceAsFile,
|
||||
isForCurrentMessageList,
|
||||
forceDarkTheme,
|
||||
onAttachmentsUpdate,
|
||||
onCaptionUpdate,
|
||||
onSend,
|
||||
@ -194,7 +198,7 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
|
||||
insertEmoji,
|
||||
closeEmojiTooltip,
|
||||
} = useEmojiTooltip(
|
||||
Boolean(isReady && isForCurrentMessageList && renderingIsOpen),
|
||||
Boolean(isReady && (isForCurrentMessageList || !isForMessage) && renderingIsOpen),
|
||||
getHtml,
|
||||
onCaptionUpdate,
|
||||
EDITABLE_INPUT_MODAL_ID,
|
||||
@ -208,7 +212,7 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
|
||||
insertCustomEmoji,
|
||||
closeCustomEmojiTooltip,
|
||||
} = useCustomEmojiTooltip(
|
||||
Boolean(isReady && isForCurrentMessageList && renderingIsOpen && shouldSuggestCustomEmoji),
|
||||
Boolean(isReady && (isForCurrentMessageList || !isForMessage) && renderingIsOpen && shouldSuggestCustomEmoji),
|
||||
getHtml,
|
||||
onCaptionUpdate,
|
||||
getSelectionRange,
|
||||
@ -256,7 +260,7 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
|
||||
|
||||
const sendAttachments = useLastCallback((isSilent?: boolean, shouldSendScheduled?: boolean) => {
|
||||
if (isOpen) {
|
||||
const send = (shouldSchedule || shouldSendScheduled) ? onSendScheduled
|
||||
const send = ((shouldSchedule || shouldSendScheduled) && isForMessage) ? onSendScheduled
|
||||
: isSilent ? onSendSilent : onSend;
|
||||
send(isSendingCompressed, shouldSendGrouped);
|
||||
updateAttachmentSettings({
|
||||
@ -508,6 +512,7 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
|
||||
!areAttachmentsNotScrolled && styles.headerBorder,
|
||||
isMobile && styles.mobile,
|
||||
isSymbolMenuOpen && styles.symbolMenuOpen,
|
||||
forceDarkTheme && 'component-theme-dark',
|
||||
)}
|
||||
noBackdropClose
|
||||
>
|
||||
@ -586,6 +591,7 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
|
||||
isAttachmentModal
|
||||
canSendPlainText
|
||||
className="attachment-modal-symbol-menu with-menu-transitions"
|
||||
idPrefix="attachment"
|
||||
/>
|
||||
<MessageInput
|
||||
ref={inputRef}
|
||||
@ -593,6 +599,7 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
|
||||
chatId={chatId}
|
||||
threadId={threadId}
|
||||
isAttachmentModalInput
|
||||
customEmojiPrefix="attachment"
|
||||
isReady={isReady}
|
||||
isActive={isOpen}
|
||||
getHtml={getHtml}
|
||||
@ -618,6 +625,7 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
|
||||
{canShowCustomSendMenu && (
|
||||
<CustomSendMenu
|
||||
isOpen={isCustomSendMenuOpen}
|
||||
canSchedule={isForMessage}
|
||||
onSendSilent={!isChatWithSelf ? handleSendSilent : undefined}
|
||||
onSendSchedule={handleScheduleClick}
|
||||
onClose={handleContextMenuClose}
|
||||
|
||||
@ -52,6 +52,7 @@ type StateProps = {
|
||||
noCaptions?: boolean;
|
||||
forwardsHaveCaptions?: boolean;
|
||||
isCurrentUserPremium?: boolean;
|
||||
isContextMenuDisabled?: boolean;
|
||||
};
|
||||
|
||||
type OwnProps = {
|
||||
@ -73,6 +74,7 @@ const ComposerEmbeddedMessage: FC<OwnProps & StateProps> = ({
|
||||
forwardsHaveCaptions,
|
||||
shouldForceShowEditing,
|
||||
isCurrentUserPremium,
|
||||
isContextMenuDisabled,
|
||||
onClear,
|
||||
}) => {
|
||||
const {
|
||||
@ -201,7 +203,7 @@ const ComposerEmbeddedMessage: FC<OwnProps & StateProps> = ({
|
||||
customText={customText}
|
||||
title={editingId ? lang('EditMessage') : noAuthors ? lang('HiddenSendersNameDescription') : undefined}
|
||||
onClick={handleMessageClick}
|
||||
hasContextMenu={isForwarding}
|
||||
hasContextMenu={isForwarding && !isContextMenuDisabled}
|
||||
/>
|
||||
<Button
|
||||
className="embedded-cancel"
|
||||
@ -213,7 +215,7 @@ const ComposerEmbeddedMessage: FC<OwnProps & StateProps> = ({
|
||||
>
|
||||
<i className="icon icon-close" />
|
||||
</Button>
|
||||
{isForwarding && (
|
||||
{isForwarding && !isContextMenuDisabled && (
|
||||
<Menu
|
||||
isOpen={isContextMenuOpen}
|
||||
transformOriginX={transformOriginX}
|
||||
@ -338,6 +340,9 @@ export default memo(withGlobal<OwnProps>(
|
||||
forward?.content.text && Object.keys(forward.content).length > 1
|
||||
));
|
||||
|
||||
const isContextMenuDisabled = isForwarding && forwardMessageIds!.length === 1
|
||||
&& Boolean(message?.content.storyData);
|
||||
|
||||
return {
|
||||
replyingToId,
|
||||
editingId,
|
||||
@ -349,6 +354,7 @@ export default memo(withGlobal<OwnProps>(
|
||||
noCaptions,
|
||||
forwardsHaveCaptions,
|
||||
isCurrentUserPremium: selectIsCurrentUserPremium(global),
|
||||
isContextMenuDisabled,
|
||||
};
|
||||
},
|
||||
)(ComposerEmbeddedMessage));
|
||||
|
||||
@ -17,6 +17,7 @@ export type OwnProps = {
|
||||
isOpen: boolean;
|
||||
isOpenToBottom?: boolean;
|
||||
isSavedMessages?: boolean;
|
||||
canSchedule?: boolean;
|
||||
canScheduleUntilOnline?: boolean;
|
||||
onSendSilent?: NoneToVoidFunction;
|
||||
onSendSchedule?: NoneToVoidFunction;
|
||||
@ -29,6 +30,7 @@ const CustomSendMenu: FC<OwnProps> = ({
|
||||
isOpen,
|
||||
isOpenToBottom = false,
|
||||
isSavedMessages,
|
||||
canSchedule,
|
||||
canScheduleUntilOnline,
|
||||
onSendSilent,
|
||||
onSendSchedule,
|
||||
@ -62,12 +64,12 @@ const CustomSendMenu: FC<OwnProps> = ({
|
||||
noCloseOnBackdrop={!IS_TOUCH_ENV}
|
||||
>
|
||||
{onSendSilent && <MenuItem icon="mute" onClick={onSendSilent}>{lang('SendWithoutSound')}</MenuItem>}
|
||||
{onSendSchedule && (
|
||||
{canSchedule && onSendSchedule && (
|
||||
<MenuItem icon="schedule" onClick={onSendSchedule}>
|
||||
{lang(isSavedMessages ? 'SetReminder' : 'ScheduleMessage')}
|
||||
</MenuItem>
|
||||
)}
|
||||
{onSendSchedule && displayScheduleUntilOnline && (
|
||||
{canSchedule && onSendSchedule && displayScheduleUntilOnline && (
|
||||
<MenuItem icon="user-online" onClick={onSendWhenOnline}>
|
||||
{lang('SendWhenOnline')}
|
||||
</MenuItem>
|
||||
|
||||
@ -47,6 +47,8 @@ type OwnProps = {
|
||||
chatId: string;
|
||||
threadId: number;
|
||||
isAttachmentModalInput?: boolean;
|
||||
isStoryInput?: boolean;
|
||||
customEmojiPrefix: string;
|
||||
editableInputId?: string;
|
||||
isReady: boolean;
|
||||
isActive: boolean;
|
||||
@ -63,6 +65,8 @@ type OwnProps = {
|
||||
onSend: () => void;
|
||||
onScroll?: (event: React.UIEvent<HTMLElement>) => void;
|
||||
captionLimit?: number;
|
||||
onFocus?: NoneToVoidFunction;
|
||||
onBlur?: NoneToVoidFunction;
|
||||
};
|
||||
|
||||
type StateProps = {
|
||||
@ -73,6 +77,7 @@ type StateProps = {
|
||||
};
|
||||
|
||||
const MAX_ATTACHMENT_MODAL_INPUT_HEIGHT = 160;
|
||||
const MAX_STORY_MODAL_INPUT_HEIGHT = 128;
|
||||
const TAB_INDEX_PRIORITY_TIMEOUT = 2000;
|
||||
// Heuristics allowing the user to make a triple click
|
||||
const SELECTION_RECALCULATE_DELAY_MS = 260;
|
||||
@ -102,6 +107,8 @@ const MessageInput: FC<OwnProps & StateProps> = ({
|
||||
chatId,
|
||||
captionLimit,
|
||||
isAttachmentModalInput,
|
||||
isStoryInput,
|
||||
customEmojiPrefix,
|
||||
editableInputId,
|
||||
isReady,
|
||||
isActive,
|
||||
@ -121,6 +128,8 @@ const MessageInput: FC<OwnProps & StateProps> = ({
|
||||
onSuppressedFocus,
|
||||
onSend,
|
||||
onScroll,
|
||||
onFocus,
|
||||
onBlur,
|
||||
}) => {
|
||||
const {
|
||||
editLastMessage,
|
||||
@ -162,13 +171,15 @@ const MessageInput: FC<OwnProps & StateProps> = ({
|
||||
sharedCanvasRef,
|
||||
sharedCanvasHqRef,
|
||||
absoluteContainerRef,
|
||||
isAttachmentModalInput ? 'attachment' : 'composer',
|
||||
customEmojiPrefix,
|
||||
canPlayAnimatedEmojis,
|
||||
isReady,
|
||||
isActive,
|
||||
);
|
||||
|
||||
const maxInputHeight = isAttachmentModalInput ? MAX_ATTACHMENT_MODAL_INPUT_HEIGHT : (isMobile ? 256 : 416);
|
||||
const maxInputHeight = isAttachmentModalInput
|
||||
? MAX_ATTACHMENT_MODAL_INPUT_HEIGHT
|
||||
: isStoryInput ? MAX_STORY_MODAL_INPUT_HEIGHT : (isMobile ? 256 : 416);
|
||||
const updateInputHeight = useLastCallback((willSend = false) => {
|
||||
requestForcedReflow(() => {
|
||||
const scroller = inputRef.current!.closest<HTMLDivElement>(`.${SCROLLER_CLASS}`)!;
|
||||
@ -351,7 +362,6 @@ const MessageInput: FC<OwnProps & StateProps> = ({
|
||||
const { isComposing } = e;
|
||||
|
||||
const html = getHtml();
|
||||
|
||||
if (!isComposing && !html && (e.metaKey || e.ctrlKey)) {
|
||||
const targetIndexDelta = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : undefined;
|
||||
if (targetIndexDelta) {
|
||||
@ -549,6 +559,8 @@ const MessageInput: FC<OwnProps & StateProps> = ({
|
||||
onContextMenu={IS_ANDROID ? handleAndroidContextMenu : undefined}
|
||||
onTouchCancel={IS_ANDROID ? processSelectionWithTimeout : undefined}
|
||||
aria-label={placeholder}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
{!forcedPlaceholder && (
|
||||
<span
|
||||
|
||||
@ -53,6 +53,8 @@ type OwnProps = {
|
||||
isTranslucent?: boolean;
|
||||
loadAndPlay: boolean;
|
||||
canSendStickers?: boolean;
|
||||
noContextMenus?: boolean;
|
||||
idPrefix: string;
|
||||
onStickerSelect: (
|
||||
sticker: ApiSticker, isSilent?: boolean, shouldSchedule?: boolean, canUpdateStickerSetsOrder?: boolean,
|
||||
) => void;
|
||||
@ -90,6 +92,8 @@ const StickerPicker: FC<OwnProps & StateProps> = ({
|
||||
canAnimate,
|
||||
isSavedMessages,
|
||||
isCurrentUserPremium,
|
||||
noContextMenus,
|
||||
idPrefix,
|
||||
onStickerSelect,
|
||||
}) => {
|
||||
const {
|
||||
@ -114,6 +118,7 @@ const StickerPicker: FC<OwnProps & StateProps> = ({
|
||||
|
||||
const sendMessageAction = useSendMessageAction(chat!.id, threadId);
|
||||
|
||||
const prefix = `${idPrefix}-sticker-set`;
|
||||
const {
|
||||
activeSetIndex,
|
||||
observeIntersectionForSet,
|
||||
@ -121,7 +126,7 @@ const StickerPicker: FC<OwnProps & StateProps> = ({
|
||||
observeIntersectionForShowingItems,
|
||||
observeIntersectionForCovers,
|
||||
selectStickerSet,
|
||||
} = useStickerPickerObservers(containerRef, headerRef, 'sticker-set', isHidden);
|
||||
} = useStickerPickerObservers(containerRef, headerRef, prefix, isHidden);
|
||||
|
||||
const lang = useLang();
|
||||
|
||||
@ -355,7 +360,9 @@ const StickerPicker: FC<OwnProps & StateProps> = ({
|
||||
key={stickerSet.id}
|
||||
stickerSet={stickerSet}
|
||||
loadAndPlay={Boolean(canAnimate && loadAndPlay)}
|
||||
noContextMenus={noContextMenus}
|
||||
index={i}
|
||||
idPrefix={prefix}
|
||||
observeIntersection={observeIntersectionForSet}
|
||||
observeIntersectionForPlayingItems={observeIntersectionForPlayingItems}
|
||||
observeIntersectionForShowingItems={observeIntersectionForShowingItems}
|
||||
|
||||
@ -51,7 +51,7 @@
|
||||
transition: none;
|
||||
}
|
||||
|
||||
&.left-column-open {
|
||||
&.left-column-open.in-middle-column {
|
||||
transform: translate3d(100vw, 0, 0) !important;
|
||||
}
|
||||
}
|
||||
@ -154,7 +154,7 @@
|
||||
|
||||
body:not(.no-menu-blur) & {
|
||||
background: var(--color-background-compact-menu);
|
||||
backdrop-filter: blur(10px);
|
||||
backdrop-filter: blur(25px);
|
||||
}
|
||||
|
||||
&:not(.open) {
|
||||
@ -224,6 +224,7 @@
|
||||
|
||||
&-external {
|
||||
color: var(--color-text);
|
||||
text-align: start;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -39,6 +39,8 @@ export type OwnProps = {
|
||||
isOpen: boolean;
|
||||
canSendStickers?: boolean;
|
||||
canSendGifs?: boolean;
|
||||
isMessageComposer?: boolean;
|
||||
idPrefix: string;
|
||||
onLoad: () => void;
|
||||
onClose: () => void;
|
||||
onEmojiSelect: (emoji: string) => void;
|
||||
@ -79,27 +81,29 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
|
||||
isOpen,
|
||||
canSendStickers,
|
||||
canSendGifs,
|
||||
isMessageComposer,
|
||||
isLeftColumnShown,
|
||||
isCurrentUserPremium,
|
||||
onLoad,
|
||||
onClose,
|
||||
onEmojiSelect,
|
||||
idPrefix,
|
||||
isAttachmentModal,
|
||||
canSendPlainText,
|
||||
onCustomEmojiSelect,
|
||||
onStickerSelect,
|
||||
className,
|
||||
onGifSelect,
|
||||
onRemoveSymbol,
|
||||
onSearchOpen,
|
||||
addRecentEmoji,
|
||||
addRecentCustomEmoji,
|
||||
positionX,
|
||||
positionY,
|
||||
transformOriginX,
|
||||
transformOriginY,
|
||||
style,
|
||||
isBackgroundTranslucent,
|
||||
onLoad,
|
||||
onClose,
|
||||
onEmojiSelect,
|
||||
onCustomEmojiSelect,
|
||||
onStickerSelect,
|
||||
onGifSelect,
|
||||
onRemoveSymbol,
|
||||
onSearchOpen,
|
||||
addRecentEmoji,
|
||||
addRecentCustomEmoji,
|
||||
}) => {
|
||||
const { loadPremiumSetStickers } = getActions();
|
||||
const [activeTab, setActiveTab] = useState<number>(0);
|
||||
@ -218,6 +222,7 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
|
||||
<CustomEmojiPicker
|
||||
className="picker-tab"
|
||||
isHidden={!isOpen || !isActive}
|
||||
idPrefix={idPrefix}
|
||||
loadAndPlay={isOpen && (isActive || isFrom)}
|
||||
chatId={chatId}
|
||||
isTranslucent={!isMobile && isBackgroundTranslucent}
|
||||
@ -230,7 +235,9 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
|
||||
className="picker-tab"
|
||||
isHidden={!isOpen || !isActive}
|
||||
loadAndPlay={canSendStickers ? isOpen && (isActive || isFrom) : false}
|
||||
idPrefix={idPrefix}
|
||||
canSendStickers={canSendStickers}
|
||||
noContextMenus={!isMessageComposer}
|
||||
chatId={chatId}
|
||||
threadId={threadId}
|
||||
isTranslucent={!isMobile && isBackgroundTranslucent}
|
||||
@ -285,6 +292,7 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
|
||||
activeTab={activeTab}
|
||||
onSwitchTab={setActiveTab}
|
||||
onRemoveSymbol={onRemoveSymbol}
|
||||
canSearch={isMessageComposer}
|
||||
onSearchOpen={handleSearch}
|
||||
isAttachmentModal={isAttachmentModal}
|
||||
canSendPlainText={canSendPlainText}
|
||||
@ -302,6 +310,7 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
|
||||
transitionClassNames,
|
||||
isLeftColumnShown && 'left-column-open',
|
||||
isAttachmentModal && 'in-attachment-modal',
|
||||
isMessageComposer && 'in-middle-column',
|
||||
);
|
||||
|
||||
if (isAttachmentModal) {
|
||||
|
||||
@ -27,6 +27,8 @@ type OwnProps = {
|
||||
isSymbolMenuOpen?: boolean;
|
||||
canSendGifs?: boolean;
|
||||
canSendStickers?: boolean;
|
||||
isMessageComposer?: boolean;
|
||||
idPrefix: string;
|
||||
openSymbolMenu: VoidFunction;
|
||||
closeSymbolMenu: VoidFunction;
|
||||
onCustomEmojiSelect: (emoji: ApiSticker) => void;
|
||||
@ -46,6 +48,7 @@ type OwnProps = {
|
||||
isAttachmentModal?: boolean;
|
||||
canSendPlainText?: boolean;
|
||||
className?: string;
|
||||
inputCssSelector?: string;
|
||||
};
|
||||
|
||||
const SymbolMenuButton: FC<OwnProps> = ({
|
||||
@ -54,21 +57,24 @@ const SymbolMenuButton: FC<OwnProps> = ({
|
||||
isMobile,
|
||||
canSendGifs,
|
||||
canSendStickers,
|
||||
isMessageComposer,
|
||||
isReady,
|
||||
isSymbolMenuOpen,
|
||||
idPrefix,
|
||||
isAttachmentModal,
|
||||
canSendPlainText,
|
||||
isSymbolMenuForced,
|
||||
className,
|
||||
inputCssSelector = EDITABLE_INPUT_CSS_SELECTOR,
|
||||
openSymbolMenu,
|
||||
closeSymbolMenu,
|
||||
onCustomEmojiSelect,
|
||||
onStickerSelect,
|
||||
onGifSelect,
|
||||
isAttachmentModal,
|
||||
canSendPlainText,
|
||||
onRemoveSymbol,
|
||||
onEmojiSelect,
|
||||
closeBotCommandMenu,
|
||||
closeSendAsMenu,
|
||||
isSymbolMenuForced,
|
||||
className,
|
||||
}) => {
|
||||
const {
|
||||
setStickerSearchQuery,
|
||||
@ -113,7 +119,7 @@ const SymbolMenuButton: FC<OwnProps> = ({
|
||||
|
||||
const handleSymbolMenuOpen = useLastCallback(() => {
|
||||
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) {
|
||||
@ -176,6 +182,8 @@ const SymbolMenuButton: FC<OwnProps> = ({
|
||||
isOpen={isSymbolMenuOpen || Boolean(isSymbolMenuForced)}
|
||||
canSendGifs={canSendGifs}
|
||||
canSendStickers={canSendStickers}
|
||||
isMessageComposer={isMessageComposer}
|
||||
idPrefix={idPrefix}
|
||||
onLoad={onSymbolMenuLoadingComplete}
|
||||
onClose={closeSymbolMenu}
|
||||
onEmojiSelect={onEmojiSelect}
|
||||
|
||||
@ -15,6 +15,7 @@ type OwnProps = {
|
||||
onSearchOpen: (type: 'stickers' | 'gifs') => void;
|
||||
isAttachmentModal?: boolean;
|
||||
canSendPlainText?: boolean;
|
||||
canSearch?: boolean;
|
||||
};
|
||||
|
||||
export enum SymbolMenuTabs {
|
||||
@ -40,7 +41,7 @@ const SYMBOL_MENU_TAB_ICONS = {
|
||||
|
||||
const SymbolMenuFooter: FC<OwnProps> = ({
|
||||
activeTab, onSwitchTab, onRemoveSymbol, onSearchOpen, isAttachmentModal,
|
||||
canSendPlainText,
|
||||
canSendPlainText, canSearch,
|
||||
}) => {
|
||||
const lang = useLang();
|
||||
|
||||
@ -70,7 +71,7 @@ const SymbolMenuFooter: FC<OwnProps> = ({
|
||||
|
||||
return (
|
||||
<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
|
||||
className="symbol-search-button"
|
||||
ariaLabel={activeTab === SymbolMenuTabs.Stickers ? 'Search Stickers' : 'Search GIFs'}
|
||||
|
||||
@ -55,7 +55,7 @@ export default function useCustomEmojiTooltip(
|
||||
const hasCustomEmojis = Boolean(customEmojis?.length);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEnabled) return;
|
||||
if (!isEnabled || !isActive) return;
|
||||
|
||||
const lastEmoji = getLastEmoji();
|
||||
if (lastEmoji) {
|
||||
@ -67,7 +67,7 @@ export default function useCustomEmojiTooltip(
|
||||
} else {
|
||||
clearCustomEmojiForEmoji();
|
||||
}
|
||||
}, [isEnabled, getLastEmoji, hasCustomEmojis, clearCustomEmojiForEmoji, loadCustomEmojiForEmoji]);
|
||||
}, [isEnabled, isActive, getLastEmoji, hasCustomEmojis, clearCustomEmojiForEmoji, loadCustomEmojiForEmoji]);
|
||||
|
||||
const insertCustomEmoji = useLastCallback((emoji: ApiSticker) => {
|
||||
const lastEmoji = getLastEmoji();
|
||||
|
||||
@ -37,13 +37,14 @@ const useDraft = (
|
||||
getHtml: Signal<string>,
|
||||
setHtml: (html: string) => void,
|
||||
editedMessage: ApiMessage | undefined,
|
||||
isDisabled: boolean | undefined,
|
||||
) => {
|
||||
const { saveDraft, clearDraft, loadCustomEmojis } = getActions();
|
||||
|
||||
const isEditing = Boolean(editedMessage);
|
||||
|
||||
const updateDraft = useLastCallback((prevState: { chatId?: string; threadId?: number } = {}, shouldForce = false) => {
|
||||
if (isEditing) return;
|
||||
if (isDisabled || isEditing) return;
|
||||
|
||||
const html = getHtml();
|
||||
|
||||
@ -68,6 +69,10 @@ const useDraft = (
|
||||
|
||||
// Restore draft on chat change
|
||||
useEffectWithPrevDeps(([prevChatId, prevThreadId, prevDraft]) => {
|
||||
if (isDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (chatId === prevChatId && threadId === prevThreadId) {
|
||||
if (!draft && prevDraft) {
|
||||
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
|
||||
useEffect(() => {
|
||||
if (isDisabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return () => {
|
||||
// eslint-disable-next-line react-hooks-static-deps/exhaustive-deps
|
||||
if (!isEditing) {
|
||||
@ -110,12 +119,12 @@ const useDraft = (
|
||||
|
||||
freeze();
|
||||
};
|
||||
}, [chatId, threadId, isEditing, updateDraftRef]);
|
||||
}, [chatId, threadId, isEditing, updateDraftRef, isDisabled]);
|
||||
|
||||
const chatIdRef = useStateRef(chatId);
|
||||
const threadIdRef = useStateRef(threadId);
|
||||
useEffect(() => {
|
||||
if (isFrozen) {
|
||||
if (isDisabled || isFrozen) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -133,7 +142,7 @@ const useDraft = (
|
||||
updateDraftRef.current();
|
||||
}
|
||||
});
|
||||
}, [chatIdRef, getHtml, runDebouncedForSaveDraft, threadIdRef, updateDraftRef]);
|
||||
}, [chatIdRef, getHtml, isDisabled, runDebouncedForSaveDraft, threadIdRef, updateDraftRef]);
|
||||
|
||||
function forceUpdateDraft() {
|
||||
updateDraft(undefined, true);
|
||||
|
||||
@ -48,7 +48,7 @@ export default function useStickerTooltip(
|
||||
const hasStickers = Boolean(stickers?.length);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEnabled) return;
|
||||
if (!isEnabled || !isActive) return;
|
||||
|
||||
const singleEmoji = getSingleEmoji();
|
||||
if (singleEmoji) {
|
||||
@ -58,7 +58,7 @@ export default function useStickerTooltip(
|
||||
} else {
|
||||
clearStickersForEmoji();
|
||||
}
|
||||
}, [isEnabled, getSingleEmoji, hasStickers, loadStickersForEmoji, clearStickersForEmoji]);
|
||||
}, [isEnabled, isActive, getSingleEmoji, hasStickers, loadStickersForEmoji, clearStickersForEmoji]);
|
||||
|
||||
useEffect(unmarkManuallyClosed, [unmarkManuallyClosed, getHtml]);
|
||||
|
||||
|
||||
@ -31,7 +31,8 @@ const useVoiceRecording = () => {
|
||||
if (recordButtonRef.current) {
|
||||
if (startRecordTimeRef.current && Date.now() % 4 === 0) {
|
||||
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());
|
||||
@ -78,7 +79,7 @@ const useVoiceRecording = () => {
|
||||
|
||||
requestMutation(() => {
|
||||
if (recordButtonRef.current) {
|
||||
recordButtonRef.current!.style.boxShadow = 'none';
|
||||
recordButtonRef.current.style.boxShadow = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
53
src/components/middle/message/BaseStory.module.scss
Normal file
53
src/components/middle/message/BaseStory.module.scss
Normal 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;
|
||||
}
|
||||
108
src/components/middle/message/BaseStory.tsx
Normal file
108
src/components/middle/message/BaseStory.tsx
Normal 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);
|
||||
@ -40,7 +40,7 @@ import {
|
||||
getMessageVideo,
|
||||
getChatMessageLink,
|
||||
} 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 { copyTextToClipboard } from '../../../util/clipboard';
|
||||
|
||||
@ -194,7 +194,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
|
||||
requestMessageTranslation,
|
||||
showOriginalMessage,
|
||||
openChatLanguageModal,
|
||||
openReactionPicker,
|
||||
openMessageReactionPicker,
|
||||
} = getActions();
|
||||
|
||||
const lang = useLang();
|
||||
@ -245,14 +245,16 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
|
||||
({ 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) {
|
||||
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]);
|
||||
|
||||
const isDownloading = useMemo(() => {
|
||||
@ -435,7 +437,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
|
||||
});
|
||||
|
||||
const handleReactionPickerOpen = useLastCallback((position: IAnchorPosition) => {
|
||||
openReactionPicker({ chatId: message.chatId, messageId: message.id, position });
|
||||
openMessageReactionPicker({ chatId: message.chatId, messageId: message.id, position });
|
||||
});
|
||||
|
||||
const handleTranslate = useLastCallback(() => {
|
||||
|
||||
@ -26,12 +26,14 @@ const MentionLink: FC<OwnProps & StateProps> = ({
|
||||
const {
|
||||
openChat,
|
||||
openChatByUsername,
|
||||
closeStoryViewer,
|
||||
} = getActions();
|
||||
|
||||
const handleClick = () => {
|
||||
if (userOrChat) {
|
||||
openChat({ id: userOrChat.id });
|
||||
} else if (username) {
|
||||
closeStoryViewer();
|
||||
openChatByUsername({ username: username.substring(1) });
|
||||
}
|
||||
};
|
||||
|
||||
@ -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 {
|
||||
--meta-safe-area-extra-width: 4rem;
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@ import type {
|
||||
ApiMessageOutgoingStatus,
|
||||
ApiReaction,
|
||||
ApiStickerSet,
|
||||
ApiTypeStory,
|
||||
ApiThreadInfo,
|
||||
ApiTopic,
|
||||
ApiUser,
|
||||
@ -56,6 +57,7 @@ import {
|
||||
selectMessageIdsByGroupId,
|
||||
selectOutgoingStatus,
|
||||
selectPerformanceSettingsValue,
|
||||
selectUserStory,
|
||||
selectReplySender,
|
||||
selectRequestedChatTranslationLanguage,
|
||||
selectRequestedMessageTranslationLanguage,
|
||||
@ -123,6 +125,7 @@ import useMessageTranslation from './hooks/useMessageTranslation';
|
||||
import usePrevious from '../../../hooks/usePrevious';
|
||||
import useTextLanguage from '../../../hooks/useTextLanguage';
|
||||
import useAuthorWidth from '../hooks/useAuthorWidth';
|
||||
import useEnsureStory from '../../../hooks/useEnsureStory';
|
||||
import { dispatchHeavyAnimationEvent } from '../../../hooks/useHeavyAnimationCheck';
|
||||
import useDetectChatLanguage from './hooks/useDetectChatLanguage';
|
||||
|
||||
@ -158,6 +161,9 @@ import PremiumIcon from '../../common/PremiumIcon';
|
||||
import FakeIcon from '../../common/FakeIcon';
|
||||
import MessageText from '../../common/MessageText';
|
||||
import TopicChip from '../../common/TopicChip';
|
||||
import EmbeddedStory from '../../common/EmbeddedStory';
|
||||
import Story from './Story';
|
||||
import StoryMention from './StoryMention';
|
||||
|
||||
import './Message.scss';
|
||||
|
||||
@ -203,6 +209,8 @@ type StateProps = {
|
||||
shouldHideReply?: boolean;
|
||||
replyMessage?: ApiMessage;
|
||||
replyMessageSender?: ApiUser | ApiChat;
|
||||
replyStory?: ApiTypeStory;
|
||||
storySender?: ApiUser;
|
||||
outgoingStatus?: ApiMessageOutgoingStatus;
|
||||
uploadProgress?: number;
|
||||
isInDocumentGroup: boolean;
|
||||
@ -255,6 +263,7 @@ type StateProps = {
|
||||
requestedChatTranslationLanguage?: string;
|
||||
withReactionEffects?: boolean;
|
||||
withStickerEffects?: boolean;
|
||||
webPageStory?: ApiTypeStory;
|
||||
isConnected: boolean;
|
||||
};
|
||||
|
||||
@ -311,6 +320,8 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
shouldHideReply,
|
||||
replyMessage,
|
||||
replyMessageSender,
|
||||
replyStory,
|
||||
storySender,
|
||||
outgoingStatus,
|
||||
uploadProgress,
|
||||
isInDocumentGroup,
|
||||
@ -361,6 +372,7 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
requestedChatTranslationLanguage,
|
||||
withReactionEffects,
|
||||
withStickerEffects,
|
||||
webPageStory,
|
||||
isConnected,
|
||||
onPinnedIntersectionChange,
|
||||
getIsMessageListReady,
|
||||
@ -446,6 +458,7 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
const isOwn = isOwnMessage(message);
|
||||
const isScheduled = messageListType === 'scheduled' || message.isScheduled;
|
||||
const hasReply = isReplyMessage(message) && !shouldHideReply;
|
||||
const hasStoryReply = Boolean(message.replyToStoryId);
|
||||
const hasThread = Boolean(repliesThreadInfo) && messageListType === 'thread';
|
||||
const isCustomShape = getMessageCustomShape(message);
|
||||
const hasAnimatedEmoji = isCustomShape && (animatedEmoji || animatedCustomEmoji);
|
||||
@ -456,7 +469,8 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
&& !isRepliesChat
|
||||
&& !forwardInfo.isLinkedChannelPost
|
||||
&& !isCustomShape
|
||||
);
|
||||
) || Boolean(message.content.storyData && !message.content.storyData.isMention);
|
||||
const isStoryMention = message.content.storyData?.isMention;
|
||||
const isAlbum = Boolean(album) && album!.messages.length > 1
|
||||
&& !album?.messages.some((msg) => Object.keys(msg.content).length === 0);
|
||||
const isInDocumentGroupNotFirst = isInDocumentGroup && !isFirstInDocumentGroup;
|
||||
@ -465,6 +479,7 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
const canShowActionButton = (
|
||||
!(isContextMenuShown || isInSelectMode || isForwarding)
|
||||
&& !isInDocumentGroupNotLast
|
||||
&& !isStoryMention
|
||||
);
|
||||
const canForward = isChannel && !isScheduled && message.isForwardingAllowed && !isChatProtected;
|
||||
const canFocus = Boolean(isPinnedList
|
||||
@ -473,7 +488,7 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
&& forwardInfo.fromMessageId
|
||||
));
|
||||
|
||||
const hasSubheader = hasTopicChip || hasReply;
|
||||
const hasSubheader = hasTopicChip || hasReply || hasStoryReply;
|
||||
|
||||
const selectMessage = useLastCallback((e?: React.MouseEvent<HTMLDivElement, MouseEvent>, groupedId?: string) => {
|
||||
toggleMessageSelection({
|
||||
@ -539,6 +554,7 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
handleFocusForwarded,
|
||||
handleDocumentGroupSelectAll,
|
||||
handleTopicChipClick,
|
||||
handleStoryClick,
|
||||
} = useInnerHandlers(
|
||||
lang,
|
||||
selectMessage,
|
||||
@ -555,6 +571,7 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
botSender,
|
||||
messageTopic,
|
||||
Boolean(requestedChatTranslationLanguage),
|
||||
replyStory && 'content' in replyStory ? replyStory : undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@ -594,10 +611,14 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
transitionClassNames,
|
||||
isJustAdded && 'is-just-added',
|
||||
(Boolean(activeReactions) || hasActiveStickerEffect) && 'has-active-reaction',
|
||||
isStoryMention && 'is-story-mention',
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
const detectedLanguage = useTextLanguage(
|
||||
@ -625,7 +646,7 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
const withCommentButton = repliesThreadInfo && !isInDocumentGroupNotLast && messageListType === 'thread'
|
||||
&& !noComments;
|
||||
const withQuickReactionButton = !isTouchScreen && !phoneCall && !isInSelectMode && defaultReaction
|
||||
&& !isInDocumentGroupNotLast;
|
||||
&& !isInDocumentGroupNotLast && !isStoryMention;
|
||||
|
||||
const contentClassName = buildContentClassName(message, {
|
||||
hasSubheader,
|
||||
@ -658,7 +679,7 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
|
||||
let reactionsPosition!: ReactionsPosition;
|
||||
if (hasReactions) {
|
||||
if (isCustomShape || ((photo || video || (location && location.type === 'geo')) && !hasText)) {
|
||||
if (isCustomShape || ((photo || video || storyData || (location && location.type === 'geo')) && !hasText)) {
|
||||
reactionsPosition = 'outside';
|
||||
} else if (asForwarded) {
|
||||
metaPosition = 'standalone';
|
||||
@ -679,6 +700,12 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
message.id,
|
||||
);
|
||||
|
||||
useEnsureStory(
|
||||
message.replyToStoryUserId ? message.replyToStoryUserId : chatId,
|
||||
message.replyToStoryId,
|
||||
replyStory,
|
||||
);
|
||||
|
||||
useFocusMessage(
|
||||
ref, chatId, isFocused, focusDirection, noFocusHighlight, isResizingContainer, isJustAdded,
|
||||
);
|
||||
@ -818,7 +845,7 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
function renderMessageText(isForAnimation?: boolean) {
|
||||
return (
|
||||
<MessageText
|
||||
message={message}
|
||||
messageOrStory={message}
|
||||
translatedText={requestedTranslationLanguage ? currentTranslatedText : undefined}
|
||||
isForAnimation={isForAnimation}
|
||||
emojiSize={emojiSize}
|
||||
@ -932,6 +959,16 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
onClick={handleReplyClick}
|
||||
/>
|
||||
)}
|
||||
{hasStoryReply && (
|
||||
<EmbeddedStory
|
||||
story={replyStory}
|
||||
sender={storySender}
|
||||
noUserColors={isOwn || isChannel}
|
||||
isProtected={isProtected}
|
||||
observeIntersectionForLoading={observeIntersectionForLoading}
|
||||
onClick={handleStoryClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{sticker && (
|
||||
@ -1070,6 +1107,13 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
isDownloading={isDownloading}
|
||||
/>
|
||||
)}
|
||||
{storyData && !isStoryMention && (
|
||||
<Story
|
||||
message={message}
|
||||
isProtected={isProtected}
|
||||
/>
|
||||
)}
|
||||
{isStoryMention && <StoryMention message={message} />}
|
||||
{contact && (
|
||||
<Contact contact={contact} />
|
||||
)}
|
||||
@ -1128,6 +1172,8 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
isDownloading={isDownloading}
|
||||
isProtected={isProtected}
|
||||
theme={theme}
|
||||
story={webPageStory}
|
||||
isConnected={isConnected}
|
||||
onMediaClick={handleMediaClick}
|
||||
onCancelMediaTransfer={handleCancelUpload}
|
||||
/>
|
||||
@ -1175,6 +1221,8 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
}
|
||||
} else if (forwardInfo?.hiddenUserName) {
|
||||
senderTitle = forwardInfo.hiddenUserName;
|
||||
} else if (storyData && originSender) {
|
||||
senderTitle = getSenderTitle(lang, originSender!);
|
||||
}
|
||||
const senderEmojiStatus = senderPeer && 'emojiStatus' in senderPeer && senderPeer.emojiStatus;
|
||||
const senderIsPremium = senderPeer && 'isPremium' in senderPeer && senderPeer.isPremium;
|
||||
@ -1284,12 +1332,12 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
>
|
||||
{asForwarded && !isInDocumentGroupNotFirst && (
|
||||
<div className="message-title">
|
||||
{lang('ForwardedMessage')}
|
||||
{lang(storyData ? 'ForwardedStory' : 'ForwardedMessage')}
|
||||
{forwardAuthor && <span className="admin-title" dir="auto">{forwardAuthor}</span>}
|
||||
</div>
|
||||
)}
|
||||
{renderContent()}
|
||||
{!isInDocumentGroupNotLast && metaPosition === 'standalone' && renderReactionsAndMeta()}
|
||||
{!isInDocumentGroupNotLast && metaPosition === 'standalone' && !isStoryMention && renderReactionsAndMeta()}
|
||||
{canShowActionButton && canForward ? (
|
||||
<Button
|
||||
className="message-action-button"
|
||||
@ -1320,7 +1368,7 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
{message.inlineButtons && (
|
||||
<InlineButtons message={message} onClick={clickBotInlineButton} />
|
||||
)}
|
||||
{reactionsPosition === 'outside' && (
|
||||
{reactionsPosition === 'outside' && !isStoryMention && (
|
||||
<Reactions
|
||||
message={reactionMessage!}
|
||||
isOutside
|
||||
@ -1387,8 +1435,8 @@ export default memo(withGlobal<OwnProps>(
|
||||
message, album, withSenderName, withAvatar, threadId, messageListType, isLastInDocumentGroup, isFirstInGroup,
|
||||
} = ownProps;
|
||||
const {
|
||||
id, chatId, viaBotId, replyToChatId, replyToMessageId, isOutgoing, repliesThreadInfo, forwardInfo,
|
||||
transcriptionId, isPinned,
|
||||
id, chatId, viaBotId, replyToChatId, replyToMessageId, isOutgoing, forwardInfo,
|
||||
transcriptionId, isPinned, replyToStoryUserId, replyToStoryId, repliesThreadInfo,
|
||||
} = message;
|
||||
|
||||
const chat = selectChat(global, chatId);
|
||||
@ -1398,6 +1446,10 @@ export default memo(withGlobal<OwnProps>(
|
||||
const isGroup = chat && isChatGroup(chat);
|
||||
const chatUsernames = chat?.usernames;
|
||||
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 forceSenderName = !isChatWithSelf && isAnonymousOwnMessage(message);
|
||||
@ -1418,6 +1470,10 @@ export default memo(withGlobal<OwnProps>(
|
||||
: undefined;
|
||||
const replyMessageSender = replyMessage && selectReplySender(global, replyMessage, Boolean(forwardInfo));
|
||||
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 isFocused = messageListType === 'thread' && (
|
||||
@ -1485,6 +1541,8 @@ export default memo(withGlobal<OwnProps>(
|
||||
isThreadTop,
|
||||
replyMessage,
|
||||
replyMessageSender,
|
||||
replyStory,
|
||||
storySender,
|
||||
isInDocumentGroup,
|
||||
isProtected: selectIsMessageProtected(global, message),
|
||||
isChatProtected: selectIsChatProtected(global, chatId),
|
||||
@ -1536,6 +1594,7 @@ export default memo(withGlobal<OwnProps>(
|
||||
hasLinkedChat: Boolean(chatFullInfo?.linkedChatId),
|
||||
withReactionEffects: selectPerformanceSettingsValue(global, 'reactionEffects'),
|
||||
withStickerEffects: selectPerformanceSettingsValue(global, 'stickerEffects'),
|
||||
webPageStory,
|
||||
isConnected,
|
||||
...((canShowSender || isLocation) && { sender }),
|
||||
...(isOutgoing && { outgoingStatus: selectOutgoingStatus(global, message, messageListType === 'scheduled') }),
|
||||
|
||||
@ -302,7 +302,7 @@ const MessageContextMenu: FC<OwnProps> = ({
|
||||
return enableScrolling;
|
||||
}, [withScroll]);
|
||||
|
||||
const handleOpenReactionPicker = useLastCallback((position: IAnchorPosition) => {
|
||||
const handleOpenMessageReactionPicker = useLastCallback((position: IAnchorPosition) => {
|
||||
onReactionPickerOpen!(position);
|
||||
hideItems();
|
||||
});
|
||||
@ -337,7 +337,7 @@ const MessageContextMenu: FC<OwnProps> = ({
|
||||
canBuyPremium={canBuyPremium}
|
||||
isCurrentUserPremium={isCurrentUserPremium}
|
||||
canPlayAnimatedEmojis={canPlayAnimatedEmojis}
|
||||
onShowMore={handleOpenReactionPicker}
|
||||
onShowMore={handleOpenMessageReactionPicker}
|
||||
className={buildClassName(areItemsHidden && 'ReactionSelector-hidden')}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -5,13 +5,9 @@ import type { OwnProps } from './ReactionPicker';
|
||||
import { Bundles } from '../../../util/moduleLoader';
|
||||
import useModuleLoader from '../../../hooks/useModuleLoader';
|
||||
|
||||
interface LocalOwnProps {
|
||||
shouldLoad?: boolean;
|
||||
}
|
||||
|
||||
const ReactionPickerAsync: FC<OwnProps & LocalOwnProps> = (props) => {
|
||||
const { isOpen, shouldLoad } = props;
|
||||
const ReactionPicker = useModuleLoader(Bundles.Extra, 'ReactionPicker', !isOpen && !shouldLoad);
|
||||
const ReactionPickerAsync: FC<OwnProps> = (props) => {
|
||||
const { isOpen } = props;
|
||||
const ReactionPicker = useModuleLoader(Bundles.Extra, 'ReactionPicker', !isOpen);
|
||||
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
return ReactionPicker ? <ReactionPicker {...props} /> : undefined;
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
:global(body:not(.no-menu-blur)) & {
|
||||
--color-background: var(--color-background-compact-menu);
|
||||
|
||||
backdrop-filter: blur(10px);
|
||||
backdrop-filter: blur(25px);
|
||||
}
|
||||
|
||||
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 {
|
||||
height: auto;
|
||||
transform-origin: 9rem 1.75rem !important;
|
||||
|
||||
@ -1,21 +1,28 @@
|
||||
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 {
|
||||
ApiMessage, ApiReaction, ApiSticker, ApiReactionCustomEmoji,
|
||||
ApiMessage, ApiReaction, ApiSticker, ApiReactionCustomEmoji, ApiStory, ApiStorySkipped,
|
||||
ApiMessageEntity,
|
||||
} from '../../../api/types';
|
||||
import type { IAnchorPosition } from '../../../types';
|
||||
|
||||
import { REM } from '../../common/helpers/mediaDimensions';
|
||||
import buildClassName from '../../../util/buildClassName';
|
||||
import { isUserId } from '../../../global/helpers';
|
||||
import {
|
||||
selectChat, selectChatFullInfo, selectChatMessage, selectIsContextMenuTranslucent, selectTabState,
|
||||
selectChat, selectChatFullInfo, selectChatMessage, selectIsContextMenuTranslucent, selectIsCurrentUserPremium,
|
||||
selectTabState, selectUserStory,
|
||||
} 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 useCurrentOrPrev from '../../../hooks/useCurrentOrPrev';
|
||||
import useMenuPosition from '../../../hooks/useMenuPosition';
|
||||
import { getIsMobile } from '../../../hooks/useAppLayout';
|
||||
|
||||
import CustomEmojiPicker from '../../common/CustomEmojiPicker';
|
||||
import ReactionPickerLimited from './ReactionPickerLimited';
|
||||
@ -30,24 +37,35 @@ export type OwnProps = {
|
||||
interface StateProps {
|
||||
withCustomReactions?: boolean;
|
||||
message?: ApiMessage;
|
||||
story?: ApiStory | ApiStorySkipped;
|
||||
isCurrentUserPremium?: boolean;
|
||||
position?: IAnchorPosition;
|
||||
isTranslucent?: boolean;
|
||||
}
|
||||
|
||||
const FULL_PICKER_SHIFT_DELTA = { x: -23, y: -64 };
|
||||
const LIMITED_PICKER_SHIFT_DELTA = { x: -21, y: -10 };
|
||||
const REACTION_SELECTOR_WIDTH = 16.375 * REM;
|
||||
|
||||
const ReactionPicker: FC<OwnProps & StateProps> = ({
|
||||
isOpen,
|
||||
message,
|
||||
story,
|
||||
position,
|
||||
isTranslucent,
|
||||
isCurrentUserPremium,
|
||||
withCustomReactions,
|
||||
}) => {
|
||||
const { toggleReaction, closeReactionPicker } = getActions();
|
||||
const {
|
||||
toggleReaction, closeReactionPicker, sendMessage, showNotification,
|
||||
} = getActions();
|
||||
|
||||
const lang = useLang();
|
||||
|
||||
const renderedMessageId = useCurrentOrPrev(message?.id, true);
|
||||
const renderedChatId = useCurrentOrPrev(message?.chatId, true);
|
||||
const renderedStoryUserId = useCurrentOrPrev(story?.userId, true);
|
||||
const renderedStoryId = useCurrentOrPrev(story?.id);
|
||||
const storedPosition = useCurrentOrPrev(position, true);
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
@ -56,14 +74,24 @@ const ReactionPicker: FC<OwnProps & StateProps> = ({
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (renderedStoryId) {
|
||||
return storedPosition;
|
||||
}
|
||||
|
||||
return {
|
||||
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),
|
||||
};
|
||||
}, [storedPosition, withCustomReactions]);
|
||||
}, [renderedStoryId, storedPosition, withCustomReactions]);
|
||||
|
||||
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 {
|
||||
positionX, positionY, transformOriginX, transformOriginY, style,
|
||||
} = useMenuPosition(renderingPosition, getTriggerElement, getRootElement, getMenuElement, getLayout);
|
||||
@ -93,6 +121,41 @@ const ReactionPicker: FC<OwnProps & StateProps> = ({
|
||||
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(() => {
|
||||
return (message?.reactions?.results || []).reduce<string[]>((acc, { chosenOrder, reaction }) => {
|
||||
if (chosenOrder !== undefined) {
|
||||
@ -108,7 +171,11 @@ const ReactionPicker: FC<OwnProps & StateProps> = ({
|
||||
isOpen={isOpen}
|
||||
ref={menuRef}
|
||||
className={buildClassName(styles.menu, 'ReactionPicker')}
|
||||
bubbleClassName={buildClassName(styles.menuContent, !withCustomReactions && styles.onlyReactions)}
|
||||
bubbleClassName={buildClassName(
|
||||
styles.menuContent,
|
||||
!withCustomReactions && !renderedStoryId && styles.onlyReactions,
|
||||
renderedStoryId && styles.storyMenu,
|
||||
)}
|
||||
withPortal
|
||||
noCompact
|
||||
positionX={positionX}
|
||||
@ -121,20 +188,20 @@ const ReactionPicker: FC<OwnProps & StateProps> = ({
|
||||
>
|
||||
<CustomEmojiPicker
|
||||
idPrefix="message-emoji-set-"
|
||||
isHidden={!isOpen || !withCustomReactions}
|
||||
isHidden={!isOpen || !(withCustomReactions || renderedStoryId)}
|
||||
loadAndPlay={Boolean(isOpen && withCustomReactions)}
|
||||
isReactionPicker
|
||||
className={!withCustomReactions ? styles.hidden : undefined}
|
||||
className={!withCustomReactions && !renderedStoryId ? styles.hidden : undefined}
|
||||
selectedReactionIds={selectedReactionIds}
|
||||
isTranslucent={isTranslucent}
|
||||
onCustomEmojiSelect={handleToggleCustomReaction}
|
||||
onReactionSelect={handleToggleReaction}
|
||||
onCustomEmojiSelect={renderedStoryId ? handleStoryReactionSelect : handleToggleCustomReaction}
|
||||
onReactionSelect={renderedStoryId ? handleStoryReactionSelect : handleToggleReaction}
|
||||
/>
|
||||
{!withCustomReactions && Boolean(renderedChatId) && (
|
||||
<ReactionPickerLimited
|
||||
chatId={renderedChatId}
|
||||
loadAndPlay={isOpen}
|
||||
onReactionSelect={handleToggleReaction}
|
||||
onReactionSelect={renderedStoryId ? handleStoryReactionSelect : handleToggleReaction}
|
||||
selectedReactionIds={selectedReactionIds}
|
||||
/>
|
||||
)}
|
||||
@ -144,22 +211,29 @@ const ReactionPicker: FC<OwnProps & StateProps> = ({
|
||||
|
||||
export default memo(withGlobal<OwnProps>((global): StateProps => {
|
||||
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 chatFullInfo = chatId ? selectChatFullInfo(global, chatId) : 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 areCustomReactionsAllowed = chatFullInfo?.enabledReactions?.type === 'all'
|
||||
&& chatFullInfo?.enabledReactions?.areCustomAllowed;
|
||||
|
||||
return {
|
||||
message,
|
||||
story,
|
||||
position,
|
||||
withCustomReactions: chat?.isForbidden || areSomeReactionsAllowed
|
||||
? false
|
||||
: areCustomReactionsAllowed || isPrivateChat,
|
||||
isTranslucent: selectIsContextMenuTranslucent(global),
|
||||
isCurrentUserPremium: selectIsCurrentUserPremium(global),
|
||||
};
|
||||
})(ReactionPicker));
|
||||
|
||||
|
||||
@ -28,7 +28,7 @@
|
||||
|
||||
body:not(.no-menu-blur) & {
|
||||
background: var(--color-background-compact-menu);
|
||||
backdrop-filter: blur(10px);
|
||||
backdrop-filter: blur(25px);
|
||||
}
|
||||
|
||||
body.is-safari & {
|
||||
|
||||
43
src/components/middle/message/Story.tsx
Normal file
43
src/components/middle/message/Story.tsx
Normal 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));
|
||||
102
src/components/middle/message/StoryMention.tsx
Normal file
102
src/components/middle/message/StoryMention.tsx
Normal 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));
|
||||
@ -20,6 +20,11 @@
|
||||
background: var(--accent-color);
|
||||
border-radius: 0.125rem;
|
||||
}
|
||||
|
||||
&.is-story {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
}
|
||||
|
||||
&--quick-button {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user