Introduce Stories (#3154)

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

View File

@ -5,7 +5,12 @@ import { Api as GramJs } from '../../../lib/gramjs';
import type { ApiAppConfig } from '../../types';
import type { 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,
};
}

View File

@ -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 || [],
};
}

View File

@ -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,
) {

View File

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

View File

@ -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;
}

View File

@ -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;

View File

@ -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) => {

View File

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

View File

@ -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;
}

View File

@ -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';

View File

@ -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

View File

@ -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) }),

View File

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

View File

@ -0,0 +1,343 @@
import BigInt from 'big-integer';
import { invokeRequest } from './client';
import type {
ApiUser, ApiUserStories, ApiReportReason, ApiTypeStory,
} from '../../types';
import type { PrivacyVisibility } from '../../../types';
import { Api as GramJs } from '../../../lib/gramjs';
import { addEntitiesToLocalDb, addStoryToLocalDb } from '../helpers';
import { buildApiUser } from '../apiBuilders/users';
import { buildApiStory, buildApiUsersStories } from '../apiBuilders/messages';
import { buildApiPeerId } from '../apiBuilders/peers';
import {
buildInputPeer,
buildInputPeerFromLocalDb,
buildInputPrivacyRules,
buildInputReportReason,
} from '../gramjsBuilders';
import { STORY_LIST_LIMIT } from '../../../config';
import { buildCollectionByCallback } from '../../../util/iteratees';
export async function fetchAllStories({
stateHash,
isFirstRequest = false,
isHidden = false,
}: {
isFirstRequest?: boolean;
stateHash?: string;
isHidden?: boolean;
}): Promise<
undefined
| { state: string }
| { users: ApiUser[]; userStories: Record<string, ApiUserStories>; hasMore?: true; state: string }> {
const params: ConstructorParameters<typeof GramJs.stories.GetAllStories>[0] = isFirstRequest
? (isHidden ? { hidden: true } : {})
: { state: stateHash, next: true, ...(isHidden && { hidden: true }) };
const result = await invokeRequest(new GramJs.stories.GetAllStories(params));
if (!result) {
return undefined;
}
if (result instanceof GramJs.stories.AllStoriesNotModified) {
return {
state: result.state,
};
}
addEntitiesToLocalDb(result.users);
result.userStories.forEach((userStories) => (
userStories.stories.forEach((story) => addStoryToLocalDb(story, buildApiPeerId(userStories.userId, 'user')))
));
const allUserStories = result.userStories.reduce<Record<string, ApiUserStories>>((acc, userStories) => {
const userId = buildApiPeerId(userStories.userId, 'user');
const stories = buildApiUsersStories(userStories);
const { pinnedIds, orderedIds, lastUpdatedAt } = Object.values(stories).reduce<
{
pinnedIds: number[];
orderedIds: number[];
lastUpdatedAt?: number;
}
>((dataAcc, story) => {
if ('isPinned' in story && story.isPinned) {
dataAcc.pinnedIds.push(story.id);
}
if (!('isDeleted' in story)) {
dataAcc.orderedIds.push(story.id);
dataAcc.lastUpdatedAt = Math.max(story.date, dataAcc.lastUpdatedAt || 0);
}
return dataAcc;
}, {
pinnedIds: [],
orderedIds: [],
lastUpdatedAt: undefined,
});
if (orderedIds.length === 0) {
return acc;
}
acc[userId] = {
byId: stories,
orderedIds,
pinnedIds,
lastUpdatedAt,
lastReadId: userStories.maxReadId,
};
return acc;
}, {});
return {
users: result.users.map(buildApiUser).filter(Boolean),
userStories: allUserStories,
hasMore: result.hasMore,
state: result.state,
};
}
export async function fetchUserStories({
user,
}: {
user: ApiUser;
}) {
const result = await invokeRequest(new GramJs.stories.GetUserStories({
userId: buildInputPeer(user.id, user.accessHash),
}));
if (!result) {
return undefined;
}
addEntitiesToLocalDb(result.users);
result.stories.stories.forEach((story) => addStoryToLocalDb(story, user.id));
const users = result.users.map(buildApiUser).filter(Boolean);
const stories = buildCollectionByCallback(result.stories.stories, (story) => (
[story.id, buildApiStory(user.id, story)]
));
return {
users,
stories,
lastReadStoryId: result.stories.maxReadId,
};
}
export function fetchUserPinnedStories({
user, offsetId,
}: {
user: ApiUser;
offsetId?: number;
}) {
return fetchCommonStoriesRequest({
method: new GramJs.stories.GetPinnedStories({
userId: buildInputPeer(user.id, user.accessHash),
offsetId,
limit: STORY_LIST_LIMIT,
}),
userId: user.id,
});
}
export function fetchStoriesArchive({
currentUserId,
offsetId,
}: {
currentUserId: string;
offsetId?: number;
}) {
return fetchCommonStoriesRequest({
method: new GramJs.stories.GetStoriesArchive({
offsetId,
limit: STORY_LIST_LIMIT,
}),
userId: currentUserId,
});
}
export async function fetchUserStoriesByIds({ user, ids }: { user: ApiUser; ids: number[] }) {
const result = await invokeRequest(new GramJs.stories.GetStoriesByID({
userId: buildInputPeer(user.id, user.accessHash),
id: ids,
}));
if (!result) {
return undefined;
}
addEntitiesToLocalDb(result.users);
result.stories.forEach((story) => addStoryToLocalDb(story, user.id));
const users = result.users.map(buildApiUser).filter(Boolean);
const stories = ids.reduce<Record<string, ApiTypeStory>>((acc, id) => {
const story = result.stories.find(({ id: currentId }) => currentId === id);
if (story) {
acc[id] = buildApiStory(user.id, story);
} else {
acc[id] = {
id,
userId: user.id,
isDeleted: true,
};
}
return acc;
}, {});
return {
users,
stories,
};
}
export function viewStory({ user, storyId }: { user: ApiUser; storyId: number }) {
return invokeRequest(new GramJs.stories.IncrementStoryViews({
userId: buildInputPeer(user.id, user.accessHash),
id: [storyId],
}));
}
export function markStoryRead({ user, storyId }: { user: ApiUser; storyId: number }) {
return invokeRequest(new GramJs.stories.ReadStories({
userId: buildInputPeer(user.id, user.accessHash),
maxId: storyId,
}));
}
export function deleteStory({ storyId }: { storyId: number }) {
return invokeRequest(new GramJs.stories.DeleteStories({ id: [storyId] }));
}
export function toggleStoryPinned({ storyId, isPinned }: { storyId: number; isPinned?: boolean }) {
return invokeRequest(new GramJs.stories.TogglePinned({ id: [storyId], pinned: isPinned }));
}
export async function fetchStorySeenBy({
storyId, limit = STORY_LIST_LIMIT, offsetDate = 0, offsetId = 0,
}: {
storyId: number;
limit?: number;
offsetDate?: number;
offsetId?: number;
}) {
const result = await invokeRequest(new GramJs.stories.GetStoryViewsList({
id: storyId,
limit,
offsetDate,
offsetId: BigInt(offsetId),
}));
if (!result) {
return undefined;
}
addEntitiesToLocalDb(result.users);
const users = result.users.map(buildApiUser).filter(Boolean);
const seenByDates = result.views.reduce<Record<string, number>>((acc, view) => {
acc[buildApiPeerId(view.userId, 'user')] = view.date;
return acc;
}, {});
return { users, seenByDates, count: result.count };
}
export async function fetchStoryLink({ userId, storyId }: { userId: string; storyId: number }) {
const inputUser = buildInputPeerFromLocalDb(userId);
if (!inputUser) {
return undefined;
}
const result = await invokeRequest(new GramJs.stories.ExportStoryLink({
userId: inputUser,
id: storyId,
}));
if (!result) {
return undefined;
}
return result.link;
}
export function reportStory({
user,
storyId,
reason,
description,
}: {
user: ApiUser; storyId: number; reason: ApiReportReason; description?: string;
}) {
return invokeRequest(new GramJs.stories.Report({
userId: buildInputPeer(user.id, user.accessHash),
id: [storyId],
reason: buildInputReportReason(reason),
message: description,
}));
}
export function editStoryPrivacy({
id, visibility, allowedUserList, deniedUserList,
}: {
id: number;
visibility: PrivacyVisibility;
allowedUserList?: ApiUser[];
deniedUserList?: ApiUser[];
}) {
return invokeRequest(new GramJs.stories.EditStory({
id,
privacyRules: buildInputPrivacyRules(visibility, allowedUserList, deniedUserList),
}), {
shouldReturnTrue: true,
});
}
export function toggleStoriesHidden({
user,
isHidden,
}: {
user: ApiUser;
isHidden: boolean;
}) {
return invokeRequest(new GramJs.contacts.ToggleStoriesHidden({
id: buildInputPeer(user.id, user.accessHash),
hidden: isHidden,
}));
}
export function fetchStoriesMaxIds({
users,
}: {
users: ApiUser[];
}) {
return invokeRequest(new GramJs.users.GetStoriesMaxIDs({
id: users.map((user) => buildInputPeer(user.id, user.accessHash)),
}));
}
async function fetchCommonStoriesRequest({ method, userId }: {
method: GramJs.stories.GetPinnedStories | GramJs.stories.GetStoriesArchive;
userId: string;
}) {
const result = await invokeRequest(method);
if (!result) {
return undefined;
}
addEntitiesToLocalDb(result.users);
result.stories.forEach((story) => addStoryToLocalDb(story, userId));
const users = result.users.map(buildApiUser).filter(Boolean);
const stories = buildCollectionByCallback(result.stories, (story) => (
[story.id, buildApiStory(userId, story)]
));
return {
users,
stories,
};
}

View File

@ -301,6 +301,14 @@ export function updateEmojiStatus(emojiStatus: ApiSticker, expires?: number) {
});
}
export function saveCloseFriends(userIds: string[]) {
const id = userIds.map((userId) => buildMtpPeerId(userId, 'user'));
return invokeRequest(new GramJs.contacts.EditCloseFriends({ id }), {
shouldReturnTrue: true,
});
}
function updateLocalDb(result: (GramJs.photos.Photos | GramJs.photos.PhotosSlice | GramJs.messages.Chats)) {
if ('chats' in result) {
addEntitiesToLocalDb(result.chats);

View File

@ -2,6 +2,7 @@ import type { GroupCallConnectionData } from '../../lib/secret-sauce';
import { Api as GramJs, connection } from '../../lib/gramjs';
import 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);

View File

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

View File

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

View File

@ -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;

View File

@ -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
View File

@ -0,0 +1,59 @@
import type { ApiMessage } from './messages';
import type { ApiPrivacySettings } from '../../types';
export interface ApiStory {
'@type'?: 'story';
id: number;
userId: string;
date: number;
expireDate: number;
content: ApiMessage['content'];
isPinned?: boolean;
isEdited?: boolean;
isForCloseFriends?: boolean;
isForContacts?: boolean;
isForSelectedContacts?: boolean;
isPublic?: boolean;
noForwards?: boolean;
viewsCount?: number;
recentViewerIds?: string[];
visibility?: ApiPrivacySettings;
}
export interface ApiStorySkipped {
'@type'?: 'storySkipped';
id: number;
userId: string;
isForCloseFriends?: boolean;
date: number;
expireDate: number;
}
export interface ApiStoryDeleted {
'@type'?: 'storyDeleted';
id: number;
userId: string;
isDeleted: true;
}
export type ApiTypeStory = ApiStory | ApiStorySkipped | ApiStoryDeleted;
export type ApiUserStories = {
byId: Record<number, ApiTypeStory>;
orderedIds: number[]; // Actual user stories
pinnedIds: number[]; // Profile Shared Media: Pinned Stories tab
archiveIds?: number[]; // Profile Shared Media: Archive Stories tab
lastUpdatedAt?: number;
lastReadId?: number;
};
export type ApiMessageStoryData = {
id: number;
userId: string;
isMention?: boolean;
};
export type ApiWebPageStoryData = {
id: number;
userId: string;
};

View File

@ -33,6 +33,7 @@ import type {
} from './calls';
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;

View File

@ -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.

View File

@ -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';

View File

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

View File

@ -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;

View File

@ -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>
);
};

View File

@ -0,0 +1,199 @@
import React, {
memo, useLayoutEffect, useMemo, useRef,
} from '../../lib/teact/teact';
import { withGlobal } from '../../global';
import type { AvatarSize } from './Avatar';
import type { ThemeKey } from '../../types';
import { REM } from './helpers/mediaDimensions';
import { DPR } from '../../util/windowEnvironment';
import { selectTheme, selectUser, selectUserStories } from '../../global/selectors';
import buildClassName from '../../util/buildClassName';
interface OwnProps {
// eslint-disable-next-line react/no-unused-prop-types
userId: string;
className?: string;
size: AvatarSize;
withExtraGap?: boolean;
}
interface StateProps {
isCloseFriend?: boolean;
storyIds?: number[];
lastReadId?: number;
appTheme: ThemeKey;
}
const SIZES: Record<AvatarSize, number> = {
micro: 1.125 * DPR * REM,
tiny: 2.125 * DPR * REM,
mini: 1.625 * DPR * REM,
small: 2.25 * DPR * REM,
'small-mobile': 2.625 * DPR * REM,
medium: 2.875 * DPR * REM,
large: 3.5 * DPR * REM,
jumbo: 7.625 * DPR * REM,
};
const BLUE = ['#34C578', '#3CA3F3'];
const GREEN = ['#C9EB38', '#09C167'];
const GRAY = '#C4C9CC';
const DARK_GRAY = '#737373';
const STROKE_WIDTH = 0.125 * DPR * REM;
const STROKE_WIDTH_READ = 0.0625 * DPR * REM;
const GAP_PERCENT = 2;
const SEGMENTS_MAX = 45; // More than this breaks rendering in Safari and Chrome
const GAP_PERCENT_EXTRA = 10;
const EXTRA_GAP_ANGLE = Math.PI / 4;
const EXTRA_GAP_SIZE = (GAP_PERCENT_EXTRA / 100) * (2 * Math.PI);
const EXTRA_GAP_START = EXTRA_GAP_ANGLE - EXTRA_GAP_SIZE / 2;
const EXTRA_GAP_END = EXTRA_GAP_ANGLE + EXTRA_GAP_SIZE / 2;
function AvatarStoryCircle({
size = 'large',
className,
isCloseFriend,
storyIds,
lastReadId,
withExtraGap,
appTheme,
}: OwnProps & StateProps) {
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLCanvasElement>(null);
const values = useMemo(() => {
return (storyIds || []).reduce((acc, id) => {
acc.total += 1;
if (lastReadId && id <= lastReadId) {
acc.read += 1;
}
return acc;
}, { total: 0, read: 0 });
}, [lastReadId, storyIds]);
useLayoutEffect(() => {
if (!ref.current) {
return;
}
drawGradientCircle({
canvas: ref.current,
size: SIZES[size],
segmentsCount: values.total,
color: isCloseFriend ? 'green' : 'blue',
readSegmentsCount: values.read,
withExtraGap,
readSegmentColor: appTheme === 'dark' ? DARK_GRAY : GRAY,
});
}, [appTheme, isCloseFriend, size, values.read, values.total, withExtraGap]);
if (!values.total) {
return undefined;
}
const maxSize = SIZES[size] / DPR;
return (
<canvas
ref={ref}
className={buildClassName('story-circle', size, className)}
style={`max-width: ${maxSize}px; max-height: ${maxSize}px;`}
/>
);
}
export default memo(withGlobal<OwnProps>((global, { userId }): StateProps => {
const user = selectUser(global, userId);
const userStories = selectUserStories(global, userId);
const appTheme = selectTheme(global);
return {
isCloseFriend: user?.isCloseFriend,
storyIds: userStories?.orderedIds,
lastReadId: userStories?.lastReadId,
appTheme,
};
})(AvatarStoryCircle));
function drawGradientCircle({
canvas,
size,
color,
segmentsCount,
readSegmentsCount = 0,
withExtraGap = false,
readSegmentColor,
}: {
canvas: HTMLCanvasElement;
size: number;
color: string;
segmentsCount: number;
readSegmentsCount?: number;
withExtraGap?: boolean;
readSegmentColor: string;
}) {
if (segmentsCount > SEGMENTS_MAX) {
readSegmentsCount = Math.round(readSegmentsCount * (SEGMENTS_MAX / segmentsCount));
segmentsCount = SEGMENTS_MAX;
}
const ctx = canvas.getContext('2d');
if (!ctx) {
return;
}
canvas.width = size;
canvas.height = size;
const centerCoordinate = size / 2;
const radius = (size - STROKE_WIDTH) / 2;
const segmentAngle = (2 * Math.PI) / segmentsCount;
const gapSize = (GAP_PERCENT / 100) * (2 * Math.PI);
const gradient = ctx.createLinearGradient(
0,
0,
Math.ceil(size * Math.cos(Math.PI / 2)),
Math.ceil(size * Math.sin(Math.PI / 2)),
);
const colorStops = color === 'green' ? GREEN : BLUE;
colorStops.forEach((colorStop, index) => {
gradient.addColorStop(index / (colorStops.length - 1), colorStop);
});
ctx.lineCap = 'round';
ctx.clearRect(0, 0, size, size);
Array.from({ length: segmentsCount }).forEach((_, i) => {
const isRead = i < readSegmentsCount;
let startAngle = i * segmentAngle - Math.PI / 2 + gapSize / 2;
let endAngle = startAngle + segmentAngle - (segmentsCount > 1 ? gapSize : 0);
ctx.strokeStyle = isRead ? readSegmentColor : gradient;
ctx.lineWidth = isRead ? STROKE_WIDTH_READ : STROKE_WIDTH;
if (withExtraGap) {
if (startAngle >= EXTRA_GAP_START && endAngle <= EXTRA_GAP_END) { // Segment is inside extra gap
return;
} else if (startAngle < EXTRA_GAP_START && endAngle > EXTRA_GAP_END) { // Extra gap is inside segment
ctx.beginPath();
ctx.arc(centerCoordinate, centerCoordinate, radius, EXTRA_GAP_END, endAngle);
ctx.stroke();
endAngle = EXTRA_GAP_START;
} else if (startAngle < EXTRA_GAP_START && endAngle > EXTRA_GAP_START) { // Segment ends in extra gap
endAngle = EXTRA_GAP_START;
} else if (startAngle < EXTRA_GAP_END && endAngle > EXTRA_GAP_END) { // Segment starts in extra gap
startAngle = EXTRA_GAP_END;
}
}
ctx.beginPath();
ctx.arc(centerCoordinate, centerCoordinate, radius, startAngle, endAngle);
ctx.stroke();
});
}

View File

@ -75,6 +75,7 @@ const ChatExtra: FC<OwnProps & StateProps> = ({
showNotification,
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);

View File

@ -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}
>

View File

@ -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;
}
}

View File

@ -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}

View File

@ -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 {

View File

@ -0,0 +1,118 @@
import React, { useRef } from '../../lib/teact/teact';
import { getActions } from '../../global';
import type { FC } from '../../lib/teact/teact';
import type { ApiUser, ApiChat, ApiTypeStory } from '../../api/types';
import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import {
getSenderTitle,
getUserColorKey,
getStoryMediaHash,
} from '../../global/helpers';
import renderText from './helpers/renderText';
import { getPictogramDimensions } from './helpers/mediaDimensions';
import buildClassName from '../../util/buildClassName';
import { useIsIntersecting } from '../../hooks/useIntersectionObserver';
import useMedia from '../../hooks/useMedia';
import useLang from '../../hooks/useLang';
import { useFastClick } from '../../hooks/useFastClick';
import useLastCallback from '../../hooks/useLastCallback';
import './EmbeddedMessage.scss';
type OwnProps = {
story?: ApiTypeStory;
sender?: ApiUser | ApiChat;
noUserColors?: boolean;
isProtected?: boolean;
observeIntersectionForLoading?: ObserveFn;
onClick: NoneToVoidFunction;
};
const NBSP = '\u00A0';
const EmbeddedStory: FC<OwnProps> = ({
story,
sender,
noUserColors,
isProtected,
observeIntersectionForLoading,
onClick,
}) => {
const { showNotification } = getActions();
const lang = useLang();
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null);
const isIntersecting = useIsIntersecting(ref, observeIntersectionForLoading);
const isFullStory = story && 'content' in story;
const isExpiredStory = story && 'isDeleted' in story;
const isVideoStory = isFullStory && Boolean(story.content.video);
const title = isFullStory ? 'Story' : (isExpiredStory ? 'ExpiredStory' : 'Loading');
const mediaBlobUrl = useMedia(isFullStory && getStoryMediaHash(story, 'pictogram'), !isIntersecting);
const mediaThumbnail = isVideoStory ? story.content.video!.thumbnail?.dataUri : undefined;
const pictogramUrl = mediaBlobUrl || mediaThumbnail;
const senderTitle = sender ? getSenderTitle(lang, sender) : undefined;
const handleFastClick = useLastCallback(() => {
if (story && !isExpiredStory) {
onClick();
} else {
showNotification({
message: lang('StoryNotFound'),
});
}
});
const { handleClick, handleMouseDown } = useFastClick(handleFastClick);
return (
<div
ref={ref}
className={buildClassName(
'EmbeddedMessage',
sender && !noUserColors && `color-${getUserColorKey(sender)}`,
)}
onClick={handleClick}
onMouseDown={handleMouseDown}
>
{pictogramUrl && renderPictogram(pictogramUrl, isProtected)}
<div className={buildClassName('message-text', isExpiredStory && 'with-message-color')}>
<p dir="auto">
{isExpiredStory && (
<i className="icon icon-story-expired" aria-hidden />
)}
{lang(title)}
</p>
<div className="message-title" dir="auto">{renderText(senderTitle || NBSP)}</div>
</div>
</div>
);
};
function renderPictogram(
srcUrl: string,
isProtected?: boolean,
) {
const { width, height } = getPictogramDimensions();
return (
<div className="embedded-thumb">
<img
src={srcUrl}
width={width}
height={height}
alt=""
className="pictogram"
draggable={false}
/>
{isProtected && <span className="protector" />}
</div>
);
}
export default EmbeddedStory;

View File

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

View File

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

View File

@ -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,

View File

@ -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} />
)}

View File

@ -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));

View File

@ -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>
);
};

View File

@ -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}

View File

@ -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

View File

@ -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}

View File

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

View File

@ -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);
}
}
}

View File

@ -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}

View File

@ -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));

View File

@ -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 {

View File

@ -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')} />

View File

@ -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));

View File

@ -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}

View File

@ -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 ? (

View File

@ -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 */

View File

@ -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 && (

View File

@ -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) {

View File

@ -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>
);

View File

@ -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);

View File

@ -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" />
)}

View File

@ -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));

View File

@ -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;
}
}
}

View File

@ -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),

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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;

View File

@ -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);

View File

@ -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 && (

View File

@ -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 {

View File

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

View File

@ -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}

View File

@ -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}

View File

@ -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));

View File

@ -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>

View File

@ -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

View File

@ -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}

View File

@ -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;
}
}

View File

@ -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) {

View File

@ -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}

View File

@ -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'}

View File

@ -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();

View File

@ -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);

View File

@ -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]);

View File

@ -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';
}
});

View File

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

View File

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

View File

@ -40,7 +40,7 @@ import {
getMessageVideo,
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(() => {

View File

@ -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) });
}
};

View File

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

View File

@ -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') }),

View File

@ -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')}
/>
)}

View File

@ -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;

View File

@ -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;

View File

@ -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));

View File

@ -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 & {

View File

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

View File

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

View File

@ -20,6 +20,11 @@
background: var(--accent-color);
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