Stories: New features and fixes (#3773)

This commit is contained in:
Alexander Zinchuk 2023-09-04 04:05:50 +02:00
parent 25e6a8f72f
commit a7c7c8d95c
150 changed files with 4775 additions and 2172 deletions

View File

@ -16,7 +16,7 @@ import type {
import { pick } from '../../../util/iteratees';
import { buildApiPhoto, buildApiThumbnailFromStripped } from './common';
import { buildApiDocument, buildApiWebDocument, buildVideoFromDocument } from './messages';
import { buildApiDocument, buildApiWebDocument, buildVideoFromDocument } from './messageContent';
import { buildStickerFromDocument } from './symbols';
import localDb from '../localDb';
import { buildApiPeerId } from './peers';

View File

@ -23,7 +23,7 @@ import {
} from './peers';
import { omitVirtualClassFields } from './helpers';
import { getServerTime, getServerTimeOffset } from '../../../util/serverTime';
import { buildApiReaction } from './messages';
import { buildApiReaction } from './reactions';
import { buildApiUsernames } from './common';
type PeerEntityApiChatFields = Omit<ApiChat, (

View File

@ -1,12 +1,24 @@
import { Api as GramJs } from '../../../lib/gramjs';
import { strippedPhotoToJpg } from '../../../lib/gramjs/Utils';
import type {
ApiPhoto, ApiPhotoSize, ApiThumbnail, ApiVideoSize, ApiUsername,
ApiPhoto,
ApiPhotoSize,
ApiThumbnail,
ApiVideoSize,
ApiUsername,
ApiFormattedText,
ApiMessageEntity,
ApiMessageEntityDefault,
} from '../../types';
import {
ApiMessageEntityTypes,
} from '../../types';
import type { ApiPrivacySettings, PrivacyVisibility } from '../../../types';
import { bytesToDataUri } from './helpers';
import { pathBytesToSvg } from './pathBytesToSvg';
import { compact } from '../../../util/iteratees';
import { buildApiPeerId } from './peers';
const DEFAULT_THUMB_SIZE = { w: 100, h: 100 };
@ -132,3 +144,104 @@ export function buildApiUsernames(mtpPeer: GramJs.User | GramJs.Channel | GramJs
return usernames;
}
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 || [],
};
}
export function buildApiFormattedText(textWithEntities: GramJs.TextWithEntities): ApiFormattedText {
const { text, entities } = textWithEntities;
return {
text,
entities: entities.map(buildApiMessageEntity),
};
}
export function buildApiMessageEntity(entity: GramJs.TypeMessageEntity): ApiMessageEntity {
const {
className: type, offset, length,
} = entity;
if (entity instanceof GramJs.MessageEntityMentionName) {
return {
type: ApiMessageEntityTypes.MentionName,
offset,
length,
userId: buildApiPeerId(entity.userId, 'user'),
};
}
if (entity instanceof GramJs.MessageEntityTextUrl) {
return {
type: ApiMessageEntityTypes.TextUrl,
offset,
length,
url: entity.url,
};
}
if (entity instanceof GramJs.MessageEntityPre) {
return {
type: ApiMessageEntityTypes.Pre,
offset,
length,
language: entity.language,
};
}
if (entity instanceof GramJs.MessageEntityCustomEmoji) {
return {
type: ApiMessageEntityTypes.CustomEmoji,
offset,
length,
documentId: entity.documentId.toString(),
};
}
return {
type: type as `${ApiMessageEntityDefault['type']}`,
offset,
length,
};
}

View File

@ -0,0 +1,644 @@
import { Api as GramJs } from '../../../lib/gramjs';
import type {
ApiFormattedText,
ApiMessage,
ApiWebPage,
ApiWebPageStoryData,
ApiWebDocument,
ApiMessageExtendedMediaPreview,
ApiPoll,
ApiInvoice,
ApiGame,
ApiLocation,
ApiContact,
ApiDocument,
ApiVoice,
ApiAudio,
ApiVideo,
ApiPhoto,
ApiSticker,
ApiMessageStoryData,
} from '../../types';
import type { UniversalMessage } from './messages';
import { SUPPORTED_IMAGE_CONTENT_TYPES, SUPPORTED_VIDEO_CONTENT_TYPES, VIDEO_WEBM_TYPE } from '../../../config';
import { pick } from '../../../util/iteratees';
import { addStoryToLocalDb, serializeBytes } from '../helpers';
import {
buildApiMessageEntity,
buildApiPhoto,
buildApiPhotoSize,
buildApiThumbnailFromPath,
buildApiThumbnailFromStripped,
} from './common';
import { buildApiPeerId, getApiChatIdFromMtpPeer } from './peers';
import { buildStickerFromDocument } from './symbols';
export function buildMessageContent(
mtpMessage: UniversalMessage | GramJs.UpdateServiceNotification,
) {
let content: ApiMessage['content'] = {};
if (mtpMessage.media) {
content = {
...buildMessageMediaContent(mtpMessage.media),
};
}
const hasUnsupportedMedia = mtpMessage.media instanceof GramJs.MessageMediaUnsupported;
if (mtpMessage.message && !hasUnsupportedMedia
&& !content.sticker && !content.poll && !content.contact && !(content.video?.isRound)) {
content = {
...content,
text: buildMessageTextContent(mtpMessage.message, mtpMessage.entities),
};
}
return content;
}
export function buildMessageTextContent(
message: string,
entities?: GramJs.TypeMessageEntity[],
): ApiFormattedText {
return {
text: message,
...(entities && { entities: entities.map(buildApiMessageEntity) }),
};
}
export function buildMessageMediaContent(media: GramJs.TypeMessageMedia): ApiMessage['content'] | undefined {
if ('ttlSeconds' in media && media.ttlSeconds) {
return undefined;
}
if ('extendedMedia' in media && media.extendedMedia instanceof GramJs.MessageExtendedMedia) {
return buildMessageMediaContent(media.extendedMedia.media);
}
const sticker = buildSticker(media);
if (sticker) return { sticker };
const photo = buildPhoto(media);
if (photo) return { photo };
const video = buildVideo(media);
const altVideo = buildAltVideo(media);
if (video) return { video, altVideo };
const audio = buildAudio(media);
if (audio) return { audio };
const voice = buildVoice(media);
if (voice) return { voice };
const document = buildDocumentFromMedia(media);
if (document) return { document };
const contact = buildContact(media);
if (contact) return { contact };
const poll = buildPollFromMedia(media);
if (poll) return { poll };
const webPage = buildWebPage(media);
if (webPage) return { webPage };
const invoice = buildInvoiceFromMedia(media);
if (invoice) return { invoice };
const location = buildLocationFromMedia(media);
if (location) return { location };
const game = buildGameFromMedia(media);
if (game) return { game };
const storyData = buildMessageStoryData(media);
if (storyData) return { storyData };
return undefined;
}
function buildSticker(media: GramJs.TypeMessageMedia): ApiSticker | undefined {
if (
!(media instanceof GramJs.MessageMediaDocument)
|| !media.document
|| !(media.document instanceof GramJs.Document)
) {
return undefined;
}
return buildStickerFromDocument(media.document, media.nopremium);
}
function buildPhoto(media: GramJs.TypeMessageMedia): ApiPhoto | undefined {
if (!(media instanceof GramJs.MessageMediaPhoto) || !media.photo || !(media.photo instanceof GramJs.Photo)) {
return undefined;
}
return buildApiPhoto(media.photo, media.spoiler);
}
export function buildVideoFromDocument(document: GramJs.Document, isSpoiler?: boolean): ApiVideo | undefined {
if (document instanceof GramJs.DocumentEmpty) {
return undefined;
}
const {
id, mimeType, thumbs, size, attributes,
} = document;
// eslint-disable-next-line no-restricted-globals
if (mimeType === VIDEO_WEBM_TYPE && !(self as any).isWebmSupported) {
return undefined;
}
const videoAttr = attributes
.find((a: any): a is GramJs.DocumentAttributeVideo => a instanceof GramJs.DocumentAttributeVideo);
if (!videoAttr) {
return undefined;
}
const gifAttr = attributes
.find((a: any): a is GramJs.DocumentAttributeAnimated => a instanceof GramJs.DocumentAttributeAnimated);
const {
duration,
w: width,
h: height,
supportsStreaming = false,
roundMessage: isRound = false,
nosound,
} = videoAttr;
return {
id: String(id),
mimeType,
duration,
fileName: getFilenameFromDocument(document, 'video'),
width,
height,
supportsStreaming,
isRound,
isGif: Boolean(gifAttr),
thumbnail: buildApiThumbnailFromStripped(thumbs),
size: size.toJSNumber(),
isSpoiler,
...(nosound && { noSound: true }),
};
}
function buildVideo(media: GramJs.TypeMessageMedia): ApiVideo | undefined {
if (
!(media instanceof GramJs.MessageMediaDocument)
|| !(media.document instanceof GramJs.Document)
|| !media.document.mimeType.startsWith('video')
) {
return 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)
|| !media.document
|| !(media.document instanceof GramJs.Document)
) {
return undefined;
}
const audioAttribute = media.document.attributes
.find((attr: any): attr is GramJs.DocumentAttributeAudio => (
attr instanceof GramJs.DocumentAttributeAudio
));
if (!audioAttribute || audioAttribute.voice) {
return undefined;
}
const thumbnailSizes = media.document.thumbs && media.document.thumbs
.filter((thumb): thumb is GramJs.PhotoSize => thumb instanceof GramJs.PhotoSize)
.map((thumb) => buildApiPhotoSize(thumb));
return {
id: String(media.document.id),
fileName: getFilenameFromDocument(media.document, 'audio'),
thumbnailSizes,
size: media.document.size.toJSNumber(),
...pick(media.document, ['mimeType']),
...pick(audioAttribute, ['duration', 'performer', 'title']),
};
}
function buildVoice(media: GramJs.TypeMessageMedia): ApiVoice | undefined {
if (
!(media instanceof GramJs.MessageMediaDocument)
|| !media.document
|| !(media.document instanceof GramJs.Document)
) {
return undefined;
}
const audioAttribute = media.document.attributes
.find((attr: any): attr is GramJs.DocumentAttributeAudio => (
attr instanceof GramJs.DocumentAttributeAudio
));
if (!audioAttribute || !audioAttribute.voice) {
return undefined;
}
const { duration, waveform } = audioAttribute;
return {
id: String(media.document.id),
duration,
waveform: waveform ? Array.from(waveform) : undefined,
};
}
function buildDocumentFromMedia(media: GramJs.TypeMessageMedia) {
if (!(media instanceof GramJs.MessageMediaDocument) || !media.document) {
return undefined;
}
return buildApiDocument(media.document);
}
export function buildApiDocument(document: GramJs.TypeDocument): ApiDocument | undefined {
if (!(document instanceof GramJs.Document)) {
return undefined;
}
const {
id, size, mimeType, date, thumbs, attributes,
} = document;
const photoSize = thumbs && thumbs.find((s: any): s is GramJs.PhotoSize => s instanceof GramJs.PhotoSize);
let thumbnail = thumbs && buildApiThumbnailFromStripped(thumbs);
if (!thumbnail && thumbs && photoSize) {
const photoPath = thumbs.find((s: any): s is GramJs.PhotoPathSize => s instanceof GramJs.PhotoPathSize);
if (photoPath) {
thumbnail = buildApiThumbnailFromPath(photoPath, photoSize);
}
}
let mediaType: ApiDocument['mediaType'] | undefined;
let mediaSize: ApiDocument['mediaSize'] | undefined;
if (photoSize) {
mediaSize = {
width: photoSize.w,
height: photoSize.h,
};
if (SUPPORTED_IMAGE_CONTENT_TYPES.has(mimeType)) {
mediaType = 'photo';
const imageAttribute = attributes
.find((a: any): a is GramJs.DocumentAttributeImageSize => a instanceof GramJs.DocumentAttributeImageSize);
if (imageAttribute) {
const { w: width, h: height } = imageAttribute;
mediaSize = {
width,
height,
};
}
} else if (SUPPORTED_VIDEO_CONTENT_TYPES.has(mimeType)) {
mediaType = 'video';
const videoAttribute = attributes
.find((a: any): a is GramJs.DocumentAttributeVideo => a instanceof GramJs.DocumentAttributeVideo);
if (videoAttribute) {
const { w: width, h: height } = videoAttribute;
mediaSize = {
width,
height,
};
}
}
}
return {
id: String(id),
size: size.toJSNumber(),
mimeType,
timestamp: date,
fileName: getFilenameFromDocument(document),
thumbnail,
mediaType,
mediaSize,
};
}
function buildContact(media: GramJs.TypeMessageMedia): ApiContact | undefined {
if (!(media instanceof GramJs.MessageMediaContact)) {
return undefined;
}
const {
firstName, lastName, phoneNumber, userId,
} = media;
return {
firstName, lastName, phoneNumber, userId: buildApiPeerId(userId, 'user'),
};
}
function buildPollFromMedia(media: GramJs.TypeMessageMedia): ApiPoll | undefined {
if (!(media instanceof GramJs.MessageMediaPoll)) {
return undefined;
}
return buildPoll(media.poll, media.results);
}
function buildInvoiceFromMedia(media: GramJs.TypeMessageMedia): ApiInvoice | undefined {
if (!(media instanceof GramJs.MessageMediaInvoice)) {
return undefined;
}
return buildInvoice(media);
}
function buildLocationFromMedia(media: GramJs.TypeMessageMedia): ApiLocation | undefined {
if (media instanceof GramJs.MessageMediaGeo) {
return buildGeo(media);
}
if (media instanceof GramJs.MessageMediaVenue) {
return buildVenue(media);
}
if (media instanceof GramJs.MessageMediaGeoLive) {
return buildGeoLive(media);
}
return undefined;
}
function buildGeo(media: GramJs.MessageMediaGeo): ApiLocation | undefined {
const point = buildGeoPoint(media.geo);
return point && { type: 'geo', geo: point };
}
function buildVenue(media: GramJs.MessageMediaVenue): ApiLocation | undefined {
const {
geo, title, provider, address, venueId, venueType,
} = media;
const point = buildGeoPoint(geo);
return point && {
type: 'venue',
geo: point,
title,
provider,
address,
venueId,
venueType,
};
}
function buildGeoLive(media: GramJs.MessageMediaGeoLive): ApiLocation | undefined {
const { geo, period, heading } = media;
const point = buildGeoPoint(geo);
return point && {
type: 'geoLive',
geo: point,
period,
heading,
};
}
export function buildGeoPoint(geo: GramJs.TypeGeoPoint): ApiLocation['geo'] | undefined {
if (geo instanceof GramJs.GeoPointEmpty) return undefined;
const {
long, lat, accuracyRadius, accessHash,
} = geo;
return {
long,
lat,
accessHash: accessHash.toString(),
accuracyRadius,
};
}
function buildGameFromMedia(media: GramJs.TypeMessageMedia): ApiGame | undefined {
if (!(media instanceof GramJs.MessageMediaGame)) {
return undefined;
}
return buildGame(media);
}
function buildGame(media: GramJs.MessageMediaGame): ApiGame | undefined {
const {
id, accessHash, shortName, title, description, photo: apiPhoto, document: apiDocument,
} = media.game;
const photo = apiPhoto instanceof GramJs.Photo ? buildApiPhoto(apiPhoto) : undefined;
const document = apiDocument instanceof GramJs.Document ? buildApiDocument(apiDocument) : undefined;
return {
id: id.toString(),
accessHash: accessHash.toString(),
shortName,
title,
description,
photo,
document,
};
}
export 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 }) };
}
export function buildPoll(poll: GramJs.Poll, pollResults: GramJs.PollResults): ApiPoll {
const { id, answers: rawAnswers } = poll;
const answers = rawAnswers.map((answer) => ({
text: answer.text,
option: serializeBytes(answer.option),
}));
return {
id: String(id),
summary: {
isPublic: poll.publicVoters,
...pick(poll, [
'closed',
'multipleChoice',
'quiz',
'question',
'closePeriod',
'closeDate',
]),
answers,
},
results: buildPollResults(pollResults),
};
}
export function buildInvoice(media: GramJs.MessageMediaInvoice): ApiInvoice {
const {
description: text, title, photo, test, totalAmount, currency, receiptMsgId, extendedMedia,
} = media;
const preview = extendedMedia instanceof GramJs.MessageExtendedMediaPreview
? buildApiMessageExtendedMediaPreview(extendedMedia) : undefined;
return {
title,
text,
photo: buildApiWebDocument(photo),
receiptMsgId,
amount: Number(totalAmount),
currency,
isTest: test,
extendedMedia: preview,
};
}
export function buildPollResults(pollResults: GramJs.PollResults): ApiPoll['results'] {
const {
results: rawResults, totalVoters, recentVoters, solution, solutionEntities: entities, min,
} = pollResults;
const results = rawResults?.map(({
option, chosen, correct, voters,
}) => ({
isChosen: chosen,
isCorrect: correct,
option: serializeBytes(option),
votersCount: voters,
}));
return {
isMin: min,
totalVoters,
recentVoterIds: recentVoters?.map((peer) => getApiChatIdFromMtpPeer(peer)),
results,
solution,
...(entities && { solutionEntities: entities.map(buildApiMessageEntity) }),
};
}
export function buildWebPage(media: GramJs.TypeMessageMedia): ApiWebPage | undefined {
if (
!(media instanceof GramJs.MessageMediaWebPage)
|| !(media.webpage instanceof GramJs.WebPage)
) {
return undefined;
}
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),
...pick(media.webpage, [
'url',
'displayUrl',
'type',
'siteName',
'title',
'description',
'duration',
]),
photo: photo instanceof GramJs.Photo ? buildApiPhoto(photo) : undefined,
document: !video && document ? buildApiDocument(document) : undefined,
video,
story,
};
}
function getFilenameFromDocument(document: GramJs.Document, defaultBase = 'file') {
const { mimeType, attributes } = document;
const filenameAttribute = attributes
.find((a: any): a is GramJs.DocumentAttributeFilename => a instanceof GramJs.DocumentAttributeFilename);
if (filenameAttribute) {
return filenameAttribute.fileName;
}
const extension = mimeType.split('/')[1];
return `${defaultBase}${String(document.id)}.${extension}`;
}
export function buildApiMessageExtendedMediaPreview(
preview: GramJs.MessageExtendedMediaPreview,
): ApiMessageExtendedMediaPreview {
const {
w, h, thumb, videoDuration,
} = preview;
return {
width: w,
height: h,
duration: videoDuration,
thumbnail: thumb ? buildApiThumbnailFromStripped([thumb]) : undefined,
};
}
export function buildApiWebDocument(document?: GramJs.TypeWebDocument): ApiWebDocument | undefined {
if (!document) return undefined;
const {
url, size, mimeType,
} = document;
const accessHash = document instanceof GramJs.WebDocument ? document.accessHash.toString() : undefined;
const sizeAttr = document.attributes.find((attr): attr is GramJs.DocumentAttributeImageSize => (
attr instanceof GramJs.DocumentAttributeImageSize
));
const dimensions = sizeAttr && { width: sizeAttr.w, height: sizeAttr.h };
return {
url,
accessHash,
size,
mimeType,
dimensions,
};
}

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,8 @@ import type {
} from '../../types';
import type { ApiPrivacyKey } from '../../../types';
import { buildApiDocument, buildApiReaction } from './messages';
import { buildApiReaction } from './reactions';
import { buildApiDocument } from './messageContent';
import { buildApiPeerId, getApiChatIdFromMtpPeer } from './peers';
import { omit, pick } from '../../../util/iteratees';
import { getServerTime } from '../../../util/serverTime';

View File

@ -5,7 +5,8 @@ import type {
ApiPaymentForm, ApiReceipt, ApiLabeledPrice, ApiPaymentCredentials,
} from '../../types';
import { buildApiDocument, buildApiMessageEntity, buildApiWebDocument } from './messages';
import { buildApiDocument, buildApiWebDocument } from './messageContent';
import { buildApiMessageEntity } from './common';
import { omitVirtualClassFields } from './helpers';
export function buildShippingOptions(shippingOptions: GramJs.ShippingOption[] | undefined) {

View File

@ -0,0 +1,103 @@
import { Api as GramJs } from '../../../lib/gramjs';
import type {
ApiAvailableReaction,
ApiPeerReaction,
ApiReaction,
ApiReactionCount,
ApiReactionEmoji,
ApiReactions,
} from '../../types';
import { buildApiDocument } from './messageContent';
import { getApiChatIdFromMtpPeer } from './peers';
export function buildMessageReactions(reactions: GramJs.MessageReactions): ApiReactions {
const {
recentReactions, results, canSeeList,
} = reactions;
return {
canSeeList,
results: results.map(buildReactionCount).filter(Boolean).sort(reactionCountComparator),
recentReactions: recentReactions?.map(buildMessagePeerReaction).filter(Boolean),
};
}
function reactionCountComparator(a: ApiReactionCount, b: ApiReactionCount) {
const diff = b.count - a.count;
if (diff) return diff;
if (a.chosenOrder !== undefined && b.chosenOrder !== undefined) {
return a.chosenOrder - b.chosenOrder;
}
if (a.chosenOrder !== undefined) return 1;
if (b.chosenOrder !== undefined) return -1;
return 0;
}
function buildReactionCount(reactionCount: GramJs.ReactionCount): ApiReactionCount | undefined {
const { chosenOrder, count, reaction } = reactionCount;
const apiReaction = buildApiReaction(reaction);
if (!apiReaction) return undefined;
return {
chosenOrder,
count,
reaction: apiReaction,
};
}
export function buildMessagePeerReaction(userReaction: GramJs.MessagePeerReaction): ApiPeerReaction | undefined {
const {
peerId, reaction, big, unread, date, my,
} = userReaction;
const apiReaction = buildApiReaction(reaction);
if (!apiReaction) return undefined;
return {
peerId: getApiChatIdFromMtpPeer(peerId),
reaction: apiReaction,
addedDate: date,
isUnread: unread,
isBig: big,
isOwn: my,
};
}
export function buildApiReaction(reaction: GramJs.TypeReaction): ApiReaction | undefined {
if (reaction instanceof GramJs.ReactionEmoji) {
return {
emoticon: reaction.emoticon,
};
}
if (reaction instanceof GramJs.ReactionCustomEmoji) {
return {
documentId: reaction.documentId.toString(),
};
}
return undefined;
}
export function buildApiAvailableReaction(availableReaction: GramJs.AvailableReaction): ApiAvailableReaction {
const {
selectAnimation, staticIcon, reaction, title, appearAnimation,
inactive, aroundAnimation, centerIcon, effectAnimation, activateAnimation,
premium,
} = availableReaction;
return {
selectAnimation: buildApiDocument(selectAnimation),
appearAnimation: buildApiDocument(appearAnimation),
activateAnimation: buildApiDocument(activateAnimation),
effectAnimation: buildApiDocument(effectAnimation),
staticIcon: buildApiDocument(staticIcon),
aroundAnimation: aroundAnimation ? buildApiDocument(aroundAnimation) : undefined,
centerIcon: centerIcon ? buildApiDocument(centerIcon) : undefined,
reaction: { emoticon: reaction } as ApiReactionEmoji,
title,
isInactive: inactive,
isPremium: premium,
};
}

View File

@ -0,0 +1,144 @@
import { Api as GramJs } from '../../../lib/gramjs';
import type {
ApiMediaArea, ApiMediaAreaCoordinates, ApiMessage, ApiStealthMode, ApiStoryView, ApiTypeStory,
} from '../../types';
import { buildCollectionByCallback } from '../../../util/iteratees';
import { buildApiPeerId } from './peers';
import { buildPrivacyRules } from './common';
import { buildGeoPoint, buildMessageMediaContent, buildMessageTextContent } from './messageContent';
import { buildApiReaction } from './reactions';
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,
mediaAreas, sentReaction,
} = 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?.reactionsCount && { reactionsCount: views.reactionsCount }),
...(views?.recentViewers && {
recentViewerIds: views.recentViewers.map((viewerId) => buildApiPeerId(viewerId, 'user')),
}),
...(privacy && { visibility: buildPrivacyRules(privacy) }),
...(mediaAreas && { mediaAreas: mediaAreas.map(buildApiMediaArea).filter(Boolean) }),
...(sentReaction && { sentReaction: buildApiReaction(sentReaction) }),
};
}
export function buildApiStoryView(view: GramJs.TypeStoryView): ApiStoryView {
const {
userId, date, reaction, blockedMyStoriesFrom, blocked,
} = view;
return {
userId: userId.toString(),
date,
...(reaction && { reaction: buildApiReaction(reaction) }),
areStoriesBlocked: blocked || blockedMyStoriesFrom,
isUserBlocked: blocked,
};
}
export function buildApiStealthMode(stealthMode: GramJs.TypeStoriesStealthMode): ApiStealthMode {
return {
activeUntil: stealthMode.activeUntilDate,
cooldownUntil: stealthMode.cooldownUntilDate,
};
}
function buildApiMediaAreaCoordinates(coordinates: GramJs.TypeMediaAreaCoordinates): ApiMediaAreaCoordinates {
const {
x, y, w, h, rotation,
} = coordinates;
return {
x,
y,
width: w,
height: h,
rotation,
};
}
export function buildApiMediaArea(area: GramJs.TypeMediaArea): ApiMediaArea | undefined {
if (area instanceof GramJs.MediaAreaVenue) {
const { geo, title, coordinates } = area;
const point = buildGeoPoint(geo);
if (!point) return undefined;
return {
type: 'venue',
coordinates: buildApiMediaAreaCoordinates(coordinates),
geo: point,
title,
};
}
if (area instanceof GramJs.MediaAreaGeoPoint) {
const { geo, coordinates } = area;
const point = buildGeoPoint(geo);
if (!point) return undefined;
return {
type: 'geoPoint',
coordinates: buildApiMediaAreaCoordinates(coordinates),
geo: point,
};
}
return undefined;
}
export function buildApiUsersStories(userStories: GramJs.UserStories) {
const userId = buildApiPeerId(userStories.userId, 'user');
return buildCollectionByCallback(userStories.stories, (story) => [story.id, buildApiStory(userId, story)]);
}

View File

@ -667,11 +667,14 @@ export function buildInputPrivacyRules(
switch (visibility) {
case 'everybody':
rules.push(new GramJs.InputPrivacyValueAllowAll());
break;
case 'contacts': {
rules.push(new GramJs.InputPrivacyValueAllowContacts());
if (visibility === 'contacts') {
rules.push(new GramJs.InputPrivacyValueAllowContacts());
}
if (visibility === 'everybody') {
rules.push(new GramJs.InputPrivacyValueAllowAll());
}
const users = deniedUserList?.reduce<GramJs.InputUser[]>((acc, { id, accessHash }) => {
acc.push(new GramJs.InputPeerUser({

View File

@ -58,7 +58,7 @@ export {
} from './management';
export {
updateProfile, checkUsername, updateUsername, fetchBlockedContacts, blockContact, unblockContact,
updateProfile, checkUsername, updateUsername, fetchBlockedUsers, blockUser, unblockUser,
updateProfilePhoto, uploadProfilePhoto, deleteProfilePhotos, fetchWallpapers, uploadWallpaper,
fetchAuthorizations, terminateAuthorization, terminateAllAuthorizations,
fetchWebAuthorizations, terminateWebAuthorization, terminateAllWebAuthorizations,
@ -109,8 +109,4 @@ export {
broadcastLocalDbUpdateFull,
} from '../localDb';
export {
fetchAllStories, fetchUserStories, fetchUserPinnedStories, fetchUserStoriesByIds, viewStory, markStoryRead,
deleteStory, toggleStoryPinned, fetchStorySeenBy, fetchStoryLink, fetchStoriesArchive, reportStory, editStoryPrivacy,
toggleStoriesHidden, fetchStoriesMaxIds,
} from './stories';
export * from './stories';

View File

@ -20,6 +20,8 @@ const MEDIA_ENTITY_TYPES = new Set([
'msg', 'sticker', 'gif', 'wallpaper', 'photo', 'webDocument', 'document', 'videoAvatar',
]);
const JPEG_SIZE_TYPES = new Set(['s', 'm', 'x', 'y', 'w', 'a', 'b', 'c', 'd']);
export default async function downloadMedia(
{
url, mediaFormat, start, end, isHtmlAllowed,
@ -178,7 +180,11 @@ async function download(
mimeType = (entity as GramJs.TypeWebDocument).mimeType;
fullSize = (entity as GramJs.TypeWebDocument).size;
} else {
mimeType = (entity as GramJs.Document).mimeType;
if (JPEG_SIZE_TYPES.has(sizeType || '')) {
mimeType = 'image/jpeg';
} else {
mimeType = (entity as GramJs.Document).mimeType;
}
fullSize = (entity as GramJs.Document).size.toJSNumber();
}

View File

@ -38,12 +38,12 @@ import {
buildApiMessage,
buildLocalForwardedMessage,
buildLocalMessage,
buildWebPage,
buildApiSponsoredMessage,
buildApiFormattedText,
buildMessageTextContent,
buildMessageMediaContent,
} from '../apiBuilders/messages';
import {
buildMessageMediaContent, buildMessageTextContent, buildWebPage,
} from '../apiBuilders/messageContent';
import { buildApiFormattedText } from '../apiBuilders/common';
import { buildApiUser } from '../apiBuilders/users';
import {
buildInputEntity,

View File

@ -6,7 +6,7 @@ import type { ApiChat, ApiReaction } from '../../types';
import { REACTION_LIST_LIMIT, RECENT_REACTIONS_LIMIT, TOP_REACTIONS_LIMIT } from '../../../config';
import { buildInputPeer, buildInputReaction } from '../gramjsBuilders';
import { buildApiUser } from '../apiBuilders/users';
import { buildApiAvailableReaction, buildApiReaction, buildMessagePeerReaction } from '../apiBuilders/messages';
import { buildApiAvailableReaction, buildApiReaction, buildMessagePeerReaction } from '../apiBuilders/reactions';
import { invokeRequest } from './client';
import localDb from '../localDb';
import { addEntitiesToLocalDb } from '../helpers';

View File

@ -23,8 +23,7 @@ import {
buildApiWallpaper,
buildApiWebSession, buildLangPack, buildLangPackString,
} from '../apiBuilders/misc';
import { buildPrivacyRules } from '../apiBuilders/messages';
import { buildApiPhoto } from '../apiBuilders/common';
import { buildApiPhoto, buildPrivacyRules } from '../apiBuilders/common';
import { buildApiUser } from '../apiBuilders/users';
import { buildApiChatFromPreview } from '../apiBuilders/chats';
import { getApiChatIdFromMtpPeer } from '../apiBuilders/peers';
@ -224,8 +223,13 @@ export async function uploadWallpaper(file: File) {
return { wallpaper };
}
export async function fetchBlockedContacts() {
export async function fetchBlockedUsers({
isOnlyStories,
}: {
isOnlyStories?: true;
}) {
const result = await invokeRequest(new GramJs.contacts.GetBlocked({
myStoriesFrom: isOnlyStories,
limit: BLOCKED_LIST_LIMIT,
}));
if (!result) {
@ -242,15 +246,29 @@ export async function fetchBlockedContacts() {
};
}
export function blockContact(chatOrUserId: string, accessHash?: string) {
export function blockUser({
user,
isOnlyStories,
} : {
user: ApiUser;
isOnlyStories?: true;
}) {
return invokeRequest(new GramJs.contacts.Block({
id: buildInputPeer(chatOrUserId, accessHash),
id: buildInputPeer(user.id, user.accessHash),
myStoriesFrom: isOnlyStories,
}));
}
export function unblockContact(chatOrUserId: string, accessHash?: string) {
export function unblockUser({
user,
isOnlyStories,
} : {
user: ApiUser;
isOnlyStories?: true;
}) {
return invokeRequest(new GramJs.contacts.Unblock({
id: buildInputPeer(chatOrUserId, accessHash),
id: buildInputPeer(user.id, user.accessHash),
myStoriesFrom: isOnlyStories,
}));
}

View File

@ -1,18 +1,21 @@
import BigInt from 'big-integer';
import { invokeRequest } from './client';
import type {
ApiUser, ApiUserStories, ApiReportReason, ApiTypeStory,
ApiUser, ApiUserStories, ApiReportReason, ApiTypeStory, ApiReaction, ApiStealthMode,
} 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 {
buildApiStoryView, buildApiStory, buildApiUsersStories, buildApiStealthMode,
} from '../apiBuilders/stories';
import {
buildInputEntity,
buildInputPeer,
buildInputPeerFromLocalDb,
buildInputPrivacyRules,
buildInputReaction,
buildInputReportReason,
} from '../gramjsBuilders';
import { STORY_LIST_LIMIT } from '../../../config';
@ -28,8 +31,14 @@ export async function fetchAllStories({
isHidden?: boolean;
}): Promise<
undefined
| { state: string }
| { users: ApiUser[]; userStories: Record<string, ApiUserStories>; hasMore?: true; state: string }> {
| { state: string; stealthMode: ApiStealthMode }
| {
users: ApiUser[];
userStories: Record<string, ApiUserStories>;
hasMore?: true;
state: string;
stealthMode: ApiStealthMode;
}> {
const params: ConstructorParameters<typeof GramJs.stories.GetAllStories>[0] = isFirstRequest
? (isHidden ? { hidden: true } : {})
: { state: stateHash, next: true, ...(isHidden && { hidden: true }) };
@ -42,6 +51,7 @@ export async function fetchAllStories({
if (result instanceof GramJs.stories.AllStoriesNotModified) {
return {
state: result.state,
stealthMode: buildApiStealthMode(result.stealthMode),
};
}
@ -95,6 +105,7 @@ export async function fetchAllStories({
userStories: allUserStories,
hasMore: result.hasMore,
state: result.state,
stealthMode: buildApiStealthMode(result.stealthMode),
};
}
@ -215,19 +226,28 @@ export function toggleStoryPinned({ storyId, isPinned }: { storyId: number; isPi
return invokeRequest(new GramJs.stories.TogglePinned({ id: [storyId], pinned: isPinned }));
}
export async function fetchStorySeenBy({
storyId, limit = STORY_LIST_LIMIT, offsetDate = 0, offsetId = 0,
export async function fetchStoryViewList({
storyId,
areJustContacts,
query,
areReactionsFirst,
limit = STORY_LIST_LIMIT,
offset = '',
}: {
storyId: number;
areJustContacts?: true;
areReactionsFirst?: true;
query?: string;
limit?: number;
offsetDate?: number;
offsetId?: number;
offset?: string;
}) {
const result = await invokeRequest(new GramJs.stories.GetStoryViewsList({
id: storyId,
justContacts: areJustContacts,
q: query,
reactionsFirst: areReactionsFirst,
limit,
offsetDate,
offsetId: BigInt(offsetId),
offset,
}));
if (!result) {
@ -236,13 +256,15 @@ export async function fetchStorySeenBy({
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;
const views = result.views.map(buildApiStoryView);
return acc;
}, {});
return { users, seenByDates, count: result.count };
return {
users,
views,
nextOffset: result.nextOffset,
reactionsCount: result.reactionsCount,
viewsCount: result.count,
};
}
export async function fetchStoryLink({ userId, storyId }: { userId: string; storyId: number }) {
@ -341,3 +363,36 @@ async function fetchCommonStoriesRequest({ method, userId }: {
stories,
};
}
export function sendStoryReaction({
user, storyId, reaction, shouldAddToRecent,
}: {
user: ApiUser;
storyId: number;
reaction?: ApiReaction;
shouldAddToRecent?: boolean;
}) {
return invokeRequest(new GramJs.stories.SendReaction({
reaction: reaction ? buildInputReaction(reaction) : new GramJs.ReactionEmpty(),
userId: buildInputEntity(user.id, user.accessHash) as GramJs.InputUser,
storyId,
...(shouldAddToRecent && { addToRecent: true }),
}), {
shouldReturnTrue: true,
});
}
export function activateStealthMode({
isForPast,
isForFuture,
}: {
isForPast?: true;
isForFuture?: true;
}) {
return invokeRequest(new GramJs.stories.ActivateStealthMode({
past: isForPast,
future: isForFuture,
}), {
shouldReturnTrue: true,
});
}

View File

@ -10,7 +10,7 @@ import {
} from '../apiBuilders/symbols';
import { buildApiUserEmojiStatus } from '../apiBuilders/users';
import { buildInputStickerSet, buildInputDocument, buildInputStickerSetShortName } from '../gramjsBuilders';
import { buildVideoFromDocument } from '../apiBuilders/messages';
import { buildVideoFromDocument } from '../apiBuilders/messageContent';
import { DEFAULT_GIF_SEARCH_BOT_USERNAME, RECENT_STATUS_LIMIT, RECENT_STICKERS_LIMIT } from '../../../config';
import localDb from '../localDb';

View File

@ -5,23 +5,33 @@ import type {
ApiStory, ApiStorySkipped,
} from '../types';
import localDb from './localDb';
import { DEBUG, GENERAL_TOPIC_ID } from '../../config';
import { omit, pick } from '../../util/iteratees';
import { getServerTimeOffset, setServerTimeOffset } from '../../util/serverTime';
import {
addMessageToLocalDb,
addEntitiesToLocalDb,
addPhotoToLocalDb,
resolveMessageApiChatId,
serializeBytes,
log,
swapLocalInvoiceMedia,
isChatFolder,
addStoryToLocalDb,
} from './helpers';
import { scheduleMutedTopicUpdate, scheduleMutedChatUpdate } from './scheduleUnmute';
import {
buildApiMessage,
buildApiMessageFromShort,
buildApiMessageFromShortChat,
buildMessageMediaContent,
buildPoll,
buildPollResults,
buildApiMessageFromNotification,
buildMessageDraft,
buildMessageReactions,
buildApiMessageExtendedMediaPreview,
buildApiStory,
buildPrivacyRules,
} from './apiBuilders/messages';
import {
buildApiReaction,
buildMessageReactions,
} from './apiBuilders/reactions';
import {
buildChatMember,
buildChatMembers,
@ -36,30 +46,20 @@ import {
buildApiUserEmojiStatus,
buildApiUserStatus,
} from './apiBuilders/users';
import {
buildMessageFromUpdate,
isMessageWithMedia,
buildChatPhotoForLocalDb,
} from './gramjsBuilders';
import localDb from './localDb';
import { omitVirtualClassFields } from './apiBuilders/helpers';
import {
addMessageToLocalDb,
addEntitiesToLocalDb,
addPhotoToLocalDb,
resolveMessageApiChatId,
serializeBytes,
log,
swapLocalInvoiceMedia,
isChatFolder,
addStoryToLocalDb,
} from './helpers';
import {
buildApiNotifyException,
buildApiNotifyExceptionTopic,
buildPrivacyKey,
} from './apiBuilders/misc';
import { buildApiPhoto, buildApiUsernames } from './apiBuilders/common';
import {
buildApiMessageExtendedMediaPreview,
buildMessageMediaContent,
buildPoll,
buildPollResults,
} from './apiBuilders/messageContent';
import { buildApiStealthMode, buildApiStory } from './apiBuilders/stories';
import { buildApiPhoto, buildApiUsernames, buildPrivacyRules } from './apiBuilders/common';
import {
buildApiGroupCall,
buildApiGroupCallParticipant,
@ -69,7 +69,11 @@ import {
import { buildApiPeerId, getApiChatIdFromMtpPeer } from './apiBuilders/peers';
import { buildApiEmojiInteraction, buildStickerSet } from './apiBuilders/symbols';
import { buildApiBotMenuButton } from './apiBuilders/bots';
import { scheduleMutedTopicUpdate, scheduleMutedChatUpdate } from './scheduleUnmute';
import {
buildMessageFromUpdate,
isMessageWithMedia,
buildChatPhotoForLocalDb,
} from './gramjsBuilders';
export type Update = (
(GramJs.TypeUpdate | GramJs.TypeUpdates) & { _entities?: (GramJs.TypeUser | GramJs.TypeChat)[] }
@ -879,6 +883,7 @@ export function updater(update: Update) {
'@type': 'updatePeerBlocked',
id: getApiChatIdFromMtpPeer(update.peerId),
isBlocked: update.blocked,
isBlockedFromStories: update.blockedMyStoriesFrom,
});
} else if (update instanceof GramJs.UpdatePrivacy) {
const key = buildPrivacyKey(update.key);
@ -1086,6 +1091,18 @@ export function updater(update: Update) {
userId: buildApiPeerId(update.userId, 'user'),
lastReadId: update.maxId,
});
} else if (update instanceof GramJs.UpdateSentStoryReaction) {
onUpdate({
'@type': 'updateSentStoryReaction',
userId: buildApiPeerId(update.userId, 'user'),
storyId: update.storyId,
reaction: buildApiReaction(update.reaction),
});
} else if (update instanceof GramJs.UpdateStoriesStealthMode) {
onUpdate({
'@type': 'updateStealthMode',
stealthMode: buildApiStealthMode(update.stealthMode),
});
} else if (DEBUG) {
const params = typeof update === 'object' && 'className' in update ? update.className : update;
log('UNEXPECTED UPDATE', params);

View File

@ -212,7 +212,7 @@ export interface ApiPaymentCredentials {
title: string;
}
interface ApiGeoPoint {
export interface ApiGeoPoint {
long: number;
lat: number;
accessHash: string;

View File

@ -1,4 +1,4 @@
import type { ApiMessage } from './messages';
import type { ApiGeoPoint, ApiMessage, ApiReaction } from './messages';
import type { ApiPrivacySettings } from '../../types';
export interface ApiStory {
@ -16,8 +16,11 @@ export interface ApiStory {
isPublic?: boolean;
noForwards?: boolean;
viewsCount?: number;
reactionsCount?: number;
recentViewerIds?: string[];
visibility?: ApiPrivacySettings;
sentReaction?: ApiReaction;
mediaAreas?: ApiMediaArea[];
}
export interface ApiStorySkipped {
@ -57,3 +60,39 @@ export type ApiWebPageStoryData = {
id: number;
userId: string;
};
export type ApiStoryView = {
userId: string;
date: number;
reaction?: ApiReaction;
isUserBlocked?: true;
areStoriesBlocked?: true;
};
export type ApiStealthMode = {
activeUntil?: number;
cooldownUntil?: number;
};
export type ApiMediaAreaCoordinates = {
x: number;
y: number;
width: number;
height: number;
rotation: number;
};
export type ApiMediaAreaVenue = {
type: 'venue';
coordinates: ApiMediaAreaCoordinates;
geo: ApiGeoPoint;
title: string;
};
export type ApiMediaAreaGeoPoint = {
type: 'geoPoint';
coordinates: ApiMediaAreaCoordinates;
geo: ApiGeoPoint;
};
export type ApiMediaArea = ApiMediaAreaVenue | ApiMediaAreaGeoPoint;

View File

@ -18,6 +18,7 @@ import type {
ApiMessageExtendedMediaPreview,
ApiPhoto,
ApiPoll,
ApiReaction,
ApiReactions,
ApiStickerSet,
ApiThreadInfo,
@ -33,7 +34,7 @@ import type {
} from './calls';
import type { ApiBotMenuButton } from './bots';
import type { ApiPrivacyKey, PrivacyVisibility } from '../../types';
import type { ApiStory, ApiStorySkipped } from './stories';
import type { ApiStealthMode, ApiStory, ApiStorySkipped } from './stories';
export type ApiUpdateReady = {
'@type': 'updateApiReady';
@ -462,7 +463,8 @@ export type ApiUpdateTwoFaStateWaitCode = {
export type ApiUpdatePeerBlocked = {
'@type': 'updatePeerBlocked';
id: string;
isBlocked: boolean;
isBlocked?: boolean;
isBlockedFromStories?: boolean;
};
export type ApiUpdatePaymentVerificationNeeded = {
@ -643,6 +645,18 @@ export type ApiUpdateReadStories = {
lastReadId: number;
};
export type ApiUpdateSentStoryReaction = {
'@type': 'updateSentStoryReaction';
userId: string;
storyId: number;
reaction?: ApiReaction;
};
export type ApiUpdateStealthMode = {
'@type': 'updateStealthMode';
stealthMode: ApiStealthMode;
};
export type ApiRequestSync = {
'@type': 'requestSync';
};
@ -673,8 +687,9 @@ export type ApiUpdate = (
ApiUpdatePhoneCallConnectionState | ApiUpdateBotMenuButton | ApiUpdateTranscribedAudio | ApiUpdateUserEmojiStatus |
ApiUpdateMessageExtendedMedia | ApiUpdateConfig | ApiUpdateTopicNotifyExceptions | ApiUpdatePinnedTopic |
ApiUpdatePinnedTopicsOrder | ApiUpdateTopic | ApiUpdateTopics | ApiUpdateRecentEmojiStatuses |
ApiUpdateRecentReactions | ApiUpdateStory | ApiUpdateReadStories | ApiUpdateDeleteStory |
ApiRequestReconnectApi | ApiRequestSync | ApiUpdateFetchingDifference | ApiUpdateChannelMessages
ApiUpdateRecentReactions | ApiUpdateStory | ApiUpdateReadStories | ApiUpdateDeleteStory | ApiUpdateSentStoryReaction |
ApiRequestReconnectApi | ApiRequestSync | ApiUpdateFetchingDifference | ApiUpdateChannelMessages |
ApiUpdateStealthMode
);
export type OnApiUpdate = (update: ApiUpdate) => void;

Binary file not shown.

Binary file not shown.

View File

@ -7,6 +7,7 @@ export { default as AttachBotRecipientPicker } from '../components/main/AttachBo
export { default as Dialogs } from '../components/main/Dialogs';
export { default as Notifications } from '../components/main/Notifications';
export { default as SafeLinkModal } from '../components/main/SafeLinkModal';
export { default as MapModal } from '../components/modals/mapModal/MapModal';
export { default as UrlAuthModal } from '../components/main/UrlAuthModal';
export { default as HistoryCalendar } from '../components/main/HistoryCalendar';
export { default as NewContactModal } from '../components/main/NewContactModal';

View File

@ -29,7 +29,7 @@ import Button from '../../ui/Button';
import OutlinedMicrophoneIcon from './OutlinedMicrophoneIcon';
import FullNameTitle from '../../common/FullNameTitle';
import GroupCallParticipantMenu from './GroupCallParticipantMenu';
import Skeleton from '../../ui/Skeleton';
import Skeleton from '../../ui/placeholder/Skeleton';
import styles from './GroupCallParticipantVideo.module.scss';

View File

@ -151,8 +151,7 @@ const MicrophoneButton: FC<OwnProps & StateProps> = ({
play={playSegment.toString()}
playSegment={playSegment}
className={styles.icon}
forceOnHeavyAnimation
forceInBackground
forceAlways
/>
<Spinner className={buildClassName(styles.spinner, isConnecting && styles.spinnerVisible)} color="white" />
</Button>

View File

@ -81,8 +81,7 @@ const OutlinedMicrophoneIcon: FC<OwnProps> = ({
size={28}
color={microphoneColor}
className={className}
forceOnHeavyAnimation
forceInBackground
forceAlways
nonInteractive
/>
);

View File

@ -41,8 +41,8 @@ export type OwnProps = {
quality?: number;
color?: string;
isLowPriority?: boolean;
forceAlways?: boolean;
forceOnHeavyAnimation?: boolean;
forceInBackground?: boolean;
sharedCanvas?: HTMLCanvasElement;
sharedCanvasCoords?: { x: number; y: number };
onClick?: NoneToVoidFunction;
@ -67,8 +67,8 @@ const AnimatedSticker: FC<OwnProps> = ({
quality,
isLowPriority,
color,
forceAlways,
forceOnHeavyAnimation,
forceInBackground,
sharedCanvas,
sharedCanvasCoords,
onClick,
@ -181,7 +181,7 @@ const AnimatedSticker: FC<OwnProps> = ({
if (
!animation
|| !(playRef.current || playSegmentRef.current)
|| isFrozen(forceOnHeavyAnimation, forceInBackground)
|| isFrozen(forceAlways)
) {
return;
}
@ -221,13 +221,13 @@ const AnimatedSticker: FC<OwnProps> = ({
}
if (playKey) {
if (!isFrozen(forceOnHeavyAnimation, forceInBackground)) {
if (!isFrozen(forceAlways, forceOnHeavyAnimation)) {
playAnimation(noLoop);
}
} else {
pauseAnimation();
}
}, [animation, playKey, noLoop, playAnimation, pauseAnimation, forceOnHeavyAnimation, forceInBackground]);
}, [animation, playKey, noLoop, playAnimation, pauseAnimation, forceAlways, forceOnHeavyAnimation]);
useEffect(() => {
if (animation) {
@ -240,12 +240,12 @@ const AnimatedSticker: FC<OwnProps> = ({
}
}, [playAnimation, animation, tgsUrl]);
useHeavyAnimationCheck(pauseAnimation, playAnimation, !playKey || forceOnHeavyAnimation);
usePriorityPlaybackCheck(pauseAnimation, playAnimation, !playKey);
useHeavyAnimationCheck(pauseAnimation, playAnimation, !playKey || forceAlways || forceOnHeavyAnimation);
usePriorityPlaybackCheck(pauseAnimation, playAnimation, !playKey || forceAlways);
// Pausing frame may not happen in background,
// so we need to make sure it happens right after focusing,
// then we can play again.
useBackgroundMode(pauseAnimation, playAnimationOnRaf, !playKey || forceInBackground);
useBackgroundMode(pauseAnimation, playAnimationOnRaf, !playKey || forceAlways);
if (sharedCanvas) {
return undefined;
@ -268,8 +268,7 @@ const AnimatedSticker: FC<OwnProps> = ({
export default memo(AnimatedSticker);
function isFrozen(forceOnHeavyAnimation = false, forceInBackground = false) {
return (!forceOnHeavyAnimation && isHeavyAnimating())
|| isPriorityPlaybackActive()
|| (!forceInBackground && isBackgroundModeActive());
function isFrozen(forceAlways = false, forceOnHeavyAnimation = false) {
if (forceAlways) return false;
return (!forceOnHeavyAnimation && isHeavyAnimating()) || isPriorityPlaybackActive() || isBackgroundModeActive();
}

View File

@ -118,6 +118,17 @@
}
}
&.size-giant {
width: 5rem;
height: 5rem;
font-size: 2.5rem;
.emoji {
width: 2.5rem;
height: 2.5rem;
}
}
&.size-jumbo {
width: 7.5rem;
height: 7.5rem;

View File

@ -36,7 +36,7 @@ import './Avatar.scss';
const LOOP_COUNT = 3;
export type AvatarSize = 'micro' | 'tiny' | 'mini' | 'small' | 'small-mobile' | 'medium' | 'large' | 'jumbo';
export type AvatarSize = 'micro' | 'tiny' | 'mini' | 'small' | 'small-mobile' | 'medium' | 'large' | 'giant' | 'jumbo';
const cn = createClassNameBuilder('Avatar');
cn.media = cn('media');
@ -51,6 +51,7 @@ type OwnProps = {
isSavedMessages?: boolean;
withVideo?: boolean;
withStory?: boolean;
forPremiumPromo?: boolean;
withStoryGap?: boolean;
withStorySolid?: boolean;
storyViewerOrigin?: StoryViewerOrigin;
@ -70,6 +71,7 @@ const Avatar: FC<OwnProps> = ({
isSavedMessages,
withVideo,
withStory,
forPremiumPromo,
withStoryGap,
withStorySolid,
storyViewerOrigin,
@ -211,7 +213,7 @@ const Avatar: FC<OwnProps> = ({
isDeleted && 'deleted-account',
isReplies && 'replies-bot-account',
isForum && 'forum',
withStory && user?.hasStories && 'with-story-circle',
((withStory && user?.hasStories) || forPremiumPromo) && 'with-story-circle',
withStorySolid && user?.hasStories && 'with-story-solid',
withStorySolid && user?.hasUnreadStories && 'has-unread-story',
onClick && 'interactive',

View File

@ -34,11 +34,13 @@ const SIZES: Record<AvatarSize, number> = {
'small-mobile': 2.625 * DPR * REM,
medium: 2.875 * DPR * REM,
large: 3.5 * DPR * REM,
giant: 5.125 * DPR * REM,
jumbo: 7.625 * DPR * REM,
};
const BLUE = ['#34C578', '#3CA3F3'];
const GREEN = ['#C9EB38', '#09C167'];
const PURPLE = ['#A667FF', '#55A5FF'];
const GRAY = '#C4C9CC';
const DARK_GRAY = '#737373';
const STROKE_WIDTH = 0.125 * DPR * REM;
@ -119,7 +121,7 @@ export default memo(withGlobal<OwnProps>((global, { userId }): StateProps => {
};
})(AvatarStoryCircle));
function drawGradientCircle({
export function drawGradientCircle({
canvas,
size,
color,
@ -142,6 +144,8 @@ function drawGradientCircle({
segmentsCount = SEGMENTS_MAX;
}
const strokeModifier = Math.max(Math.max(size - SIZES.large, 0) / DPR / REM / 1.5, 1);
const ctx = canvas.getContext('2d');
if (!ctx) {
return;
@ -150,7 +154,7 @@ function drawGradientCircle({
canvas.width = size;
canvas.height = size;
const centerCoordinate = size / 2;
const radius = (size - STROKE_WIDTH) / 2;
const radius = (size - STROKE_WIDTH * strokeModifier) / 2;
const segmentAngle = (2 * Math.PI) / segmentsCount;
const gapSize = (GAP_PERCENT / 100) * (2 * Math.PI);
const gradient = ctx.createLinearGradient(
@ -160,7 +164,7 @@ function drawGradientCircle({
Math.ceil(size * Math.sin(Math.PI / 2)),
);
const colorStops = color === 'green' ? GREEN : BLUE;
const colorStops = color === 'purple' ? PURPLE : color === 'green' ? GREEN : BLUE;
colorStops.forEach((colorStop, index) => {
gradient.addColorStop(index / (colorStops.length - 1), colorStop);
});
@ -174,7 +178,7 @@ function drawGradientCircle({
let endAngle = startAngle + segmentAngle - (segmentsCount > 1 ? gapSize : 0);
ctx.strokeStyle = isRead ? readSegmentColor : gradient;
ctx.lineWidth = isRead ? STROKE_WIDTH_READ : STROKE_WIDTH;
ctx.lineWidth = (isRead ? STROKE_WIDTH_READ : STROKE_WIDTH) * strokeModifier;
if (withExtraGap) {
if (startAngle >= EXTRA_GAP_START && endAngle <= EXTRA_GAP_END) { // Segment is inside extra gap

View File

@ -427,6 +427,14 @@
right: 0.5rem;
}
}
&.story-reaction-button {
--custom-emoji-size: 1.5rem;
}
.story-reaction-heart {
color: var(--color-heart);
}
}
> .input-group {
@ -718,6 +726,11 @@
}
}
#story-input-text .placeholder-text {
bottom: 0.875rem;
left: 0.875rem;
}
.composer-tooltip {
position: absolute;
bottom: calc(100% + 0.5rem);

View File

@ -24,6 +24,7 @@ import type {
ApiMessageEntity,
ApiNewPoll,
ApiReaction,
ApiStealthMode,
ApiSticker,
ApiUser,
ApiVideo,
@ -68,6 +69,7 @@ import {
selectTheme,
selectUser,
selectUserFullInfo,
selectUserStory,
} from '../../global/selectors';
import {
getAllowedAttachmentOptions,
@ -146,6 +148,7 @@ import StickerTooltip from '../middle/composer/StickerTooltip.async';
import EmojiTooltip from '../middle/composer/EmojiTooltip.async';
import CustomSendMenu from '../middle/composer/CustomSendMenu.async';
import ReactionSelector from '../middle/message/ReactionSelector';
import ReactionStaticEmoji from './ReactionStaticEmoji';
import './Composer.scss';
@ -230,6 +233,8 @@ type StateProps =
canPlayAnimatedEmojis?: boolean;
canBuyPremium?: boolean;
shouldCollectDebugLogs?: boolean;
sentStoryReaction?: ApiReaction;
stealthMode?: ApiStealthMode;
};
enum MainButtonState {
@ -254,6 +259,10 @@ const MESSAGE_MAX_LENGTH = 4096;
const SENDING_ANIMATION_DURATION = 350;
const MOUNT_ANIMATION_DURATION = 430;
const HEART_REACTION: ApiReaction = {
emoticon: '❤',
};
const Composer: FC<OwnProps & StateProps> = ({
type,
isOnActiveTab,
@ -328,6 +337,8 @@ const Composer: FC<OwnProps & StateProps> = ({
canBuyPremium,
canPlayAnimatedEmojis,
shouldCollectDebugLogs,
sentStoryReaction,
stealthMode,
onForward,
}) => {
const {
@ -349,6 +360,7 @@ const Composer: FC<OwnProps & StateProps> = ({
showAllowedMessageTypesNotification,
openStoryReactionPicker,
closeReactionPicker,
sendStoryReaction,
} = getActions();
const lang = useLang();
@ -356,6 +368,9 @@ const Composer: FC<OwnProps & StateProps> = ({
// eslint-disable-next-line no-null/no-null
const inputRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const storyReactionRef = useRef<HTMLButtonElement>(null);
const [getHtml, setHtml] = useSignal('');
const [isMounted, setIsMounted] = useState(false);
const getSelectionRange = useGetSelectionRange(editableInputCssSelector);
@ -374,6 +389,9 @@ const Composer: FC<OwnProps & StateProps> = ({
const [isInputHasFocus, markInputHasFocus, unmarkInputHasFocus] = useFlag();
const [isAttachMenuOpen, onAttachMenuOpen, onAttachMenuClose] = useFlag();
const isSentStoryReactionHeart = sentStoryReaction && 'emoticon' in sentStoryReaction
? sentStoryReaction.emoticon === HEART_REACTION.emoticon : false;
useEffect(processMessageInputForCustomEmoji, [getHtml]);
const customEmojiNotificationNumber = useRef(0);
@ -398,10 +416,10 @@ const Composer: FC<OwnProps & StateProps> = ({
}, [chatId]);
useEffect(() => {
if (chatId && isReady) {
if (chatId && isReady && !isInStoryViewer) {
loadScheduledHistory({ chatId });
}
}, [isReady, chatId, threadId]);
}, [isReady, chatId, threadId, isInStoryViewer]);
useEffect(() => {
if (chatId && chat && !sendAsPeerIds && isReady && isChatSuperGroup(chat)) {
@ -521,22 +539,25 @@ const Composer: FC<OwnProps & StateProps> = ({
startRecordTimeRef,
} = useVoiceRecording();
const shouldSendRecordingStatus = isForCurrentMessageList && !isInStoryViewer;
useInterval(() => {
sendMessageAction({ type: 'recordAudio' });
}, activeVoiceRecording && SEND_MESSAGE_ACTION_INTERVAL);
}, shouldSendRecordingStatus ? activeVoiceRecording && SEND_MESSAGE_ACTION_INTERVAL : undefined);
useEffect(() => {
if (!isForCurrentMessageList || isInStoryViewer) return;
if (!activeVoiceRecording) {
sendMessageAction({ type: 'cancel' });
}
}, [activeVoiceRecording, sendMessageAction]);
}, [activeVoiceRecording, isForCurrentMessageList, isInStoryViewer, sendMessageAction]);
const isEditingRef = useStateRef(Boolean(editingMessage));
useEffect(() => {
if (!isForCurrentMessageList || isInStoryViewer) return;
if (getHtml() && !isEditingRef.current) {
sendMessageAction({ type: 'typing' });
}
}, [getHtml, isEditingRef, sendMessageAction]);
}, [getHtml, isEditingRef, isForCurrentMessageList, isInStoryViewer, sendMessageAction]);
const isAdmin = chat && isChatAdmin(chat);
@ -736,8 +757,28 @@ const Composer: FC<OwnProps & StateProps> = ({
handleContextMenuHide,
} = useContextMenuHandlers(mainButtonRef, !(mainButtonState === MainButtonState.Send && canShowCustomSendMenu));
const {
contextMenuPosition: storyReactionPickerPosition,
handleContextMenu: handleStoryPickerContextMenu,
handleBeforeContextMenu: handleBeforeStoryPickerContextMenu,
handleContextMenuHide: handleStoryPickerContextMenuHide,
} = useContextMenuHandlers(storyReactionRef, !isInStoryViewer);
useEffect(() => {
if (isReactionPickerOpen) return;
if (storyReactionPickerPosition) {
openStoryReactionPicker({
storyUserId: chatId,
storyId: storyId!,
position: storyReactionPickerPosition,
});
handleStoryPickerContextMenuHide();
}
}, [chatId, handleStoryPickerContextMenuHide, isReactionPickerOpen, storyId, storyReactionPickerPosition]);
useClipboardPaste(
isForCurrentMessageList,
isForCurrentMessageList || isInStoryViewer,
insertFormattedTextAndUpdateCursor,
handleSetAttachments,
setNextText,
@ -1226,6 +1267,18 @@ const Composer: FC<OwnProps & StateProps> = ({
return withBotMenuButton && !getHtml() && !activeVoiceRecording;
}, [withBotMenuButton, getHtml, activeVoiceRecording]);
const [timedPlaceholderLangKey, timedPlaceholderDate] = useMemo(() => {
if (slowMode?.nextSendDate) {
return ['SlowModeWait', slowMode.nextSendDate];
}
if (stealthMode?.activeUntil && isInStoryViewer) {
return ['StealthModeActiveHint', stealthMode.activeUntil];
}
return [];
}, [isInStoryViewer, slowMode?.nextSendDate, stealthMode?.activeUntil]);
const isComposerHasFocus = isBotKeyboardOpen || isSymbolMenuOpen || isEmojiTooltipOpen || isSendAsMenuOpen
|| isMentionTooltipOpen || isInlineBotTooltipOpen || isDeleteModalOpen || isBotCommandMenuOpen || isAttachMenuOpen
|| isStickerTooltipOpen || isBotCommandTooltipOpen || isCustomEmojiTooltipOpen || isBotMenuButtonOpen
@ -1348,7 +1401,17 @@ const Composer: FC<OwnProps & StateProps> = ({
});
const handleReactionPickerOpen = useLastCallback((position: IAnchorPosition) => {
openStoryReactionPicker({ storyUserId: chatId, storyId: storyId!, position });
openStoryReactionPicker({
storyUserId: chatId,
storyId: storyId!,
position,
sendAsMessage: true,
});
});
const handleLikeStory = useLastCallback(() => {
const reaction = sentStoryReaction ? undefined : HEART_REACTION;
sendStoryReaction({ userId: chatId, storyId: storyId!, reaction });
});
const handleSendScheduled = useLastCallback(() => {
@ -1586,6 +1649,7 @@ const Composer: FC<OwnProps & StateProps> = ({
canSendPlainText={!isComposerBlocked}
inputCssSelector={editableInputCssSelector}
idPrefix={type}
forceDarkTheme={isInStoryViewer}
/>
)}
<MessageInput
@ -1607,6 +1671,8 @@ const Composer: FC<OwnProps & StateProps> = ({
? (botKeyboardPlaceholder || inputPlaceholder || lang('Message'))
: lang('Chat.PlaceholderTextNotAllowed'))
}
timedPlaceholderDate={timedPlaceholderDate}
timedPlaceholderLangKey={timedPlaceholderLangKey}
forcedPlaceholder={inlineBotHelp}
canAutoFocus={isReady && isForCurrentMessageList && !hasAttachments && isInMessageList}
noFocusInterception={hasAttachments}
@ -1618,6 +1684,33 @@ const Composer: FC<OwnProps & StateProps> = ({
onFocus={markInputHasFocus}
onBlur={unmarkInputHasFocus}
/>
{isInStoryViewer && !activeVoiceRecording && (
<Button
round
className="story-reaction-button"
color="translucent"
onClick={handleLikeStory}
onContextMenu={handleStoryPickerContextMenu}
onMouseDown={handleBeforeStoryPickerContextMenu}
ariaLabel={lang('AccDescrLike')}
ref={storyReactionRef}
>
{sentStoryReaction && !isSentStoryReactionHeart && (
<ReactionStaticEmoji
reaction={sentStoryReaction}
availableReactions={availableReactions}
/>
)}
<i
className={buildClassName(
'icon',
!sentStoryReaction && 'icon-heart-outline',
isSentStoryReactionHeart && 'icon-heart story-reaction-heart',
)}
aria-hidden
/>
</Button>
)}
{isInMessageList && (
<>
{isInlineBotLoading && Boolean(inlineBotId) && (
@ -1730,7 +1823,7 @@ const Composer: FC<OwnProps & StateProps> = ({
<Button
ref={mainButtonRef}
round
color={isInMessageList ? 'secondary' : undefined}
color="secondary"
className={buildClassName(
mainButtonState,
'main-button',
@ -1772,7 +1865,7 @@ const Composer: FC<OwnProps & StateProps> = ({
export default memo(withGlobal<OwnProps>(
(global, {
chatId, threadId, messageListType, isMobile, type,
chatId, threadId, storyId, messageListType, isMobile, type,
}): StateProps => {
const chat = selectChat(global, chatId);
const chatBot = chatId !== REPLIES_USER_ID ? selectBot(global, chatId) : undefined;
@ -1802,10 +1895,15 @@ export default memo(withGlobal<OwnProps>(
const sendAsChat = !sendAsUser && sendAsId ? selectChat(global, sendAsId) : undefined;
const requestedDraftText = selectRequestedDraftText(global, chatId);
const requestedDraftFiles = selectRequestedDraftFiles(global, chatId);
const tabState = selectTabState(global);
const isStoryViewerOpen = Boolean(tabState.storyViewer.storyId);
const currentMessageList = selectCurrentMessageList(global);
const isForCurrentMessageList = chatId === currentMessageList?.chatId
&& threadId === currentMessageList?.threadId
&& messageListType === currentMessageList?.type;
&& messageListType === currentMessageList?.type
&& !isStoryViewerOpen;
const user = selectUser(global, chatId);
const canSendVoiceByPrivacy = (user && !selectUserFullInfo(global, user.id)?.noVoiceMessages) ?? true;
const slowMode = chatFullInfo?.slowMode;
@ -1817,7 +1915,8 @@ export default memo(withGlobal<OwnProps>(
const replyingToId = selectReplyingToId(global, chatId, threadId);
const tabState = selectTabState(global);
const story = storyId && selectUserStory(global, chatId, storyId);
const sentStoryReaction = story && 'sentReaction' in story ? story.sentReaction : undefined;
return {
availableReactions: type === 'story' ? global.availableReactions : undefined,
@ -1880,6 +1979,8 @@ export default memo(withGlobal<OwnProps>(
canBuyPremium: !isCurrentUserPremium && !selectIsPremiumPurchaseBlocked(global),
canPlayAnimatedEmojis: selectCanPlayAnimatedEmojis(global),
shouldCollectDebugLogs: global.settings.byKey.shouldCollectDebugLogs,
sentStoryReaction,
stealthMode: global.stories.stealthMode,
};
},
)(Composer));

View File

@ -36,6 +36,7 @@ type OwnProps = {
withTranslucentThumb?: boolean;
shouldPreloadPreview?: boolean;
forceOnHeavyAnimation?: boolean;
forceAlways?: boolean;
observeIntersectionForLoading?: ObserveFn;
observeIntersectionForPlaying?: ObserveFn;
onClick?: NoneToVoidFunction;
@ -58,6 +59,7 @@ const CustomEmoji: FC<OwnProps> = ({
sharedCanvasHqRef,
withTranslucentThumb,
shouldPreloadPreview,
forceAlways,
forceOnHeavyAnimation,
observeIntersectionForLoading,
observeIntersectionForPlaying,
@ -137,6 +139,7 @@ const CustomEmoji: FC<OwnProps> = ({
loopLimit={loopLimit}
shouldPreloadPreview={shouldPreloadPreview || noPlay || !canPlay}
forceOnHeavyAnimation={forceOnHeavyAnimation}
forceAlways={forceAlways}
observeIntersectionForLoading={observeIntersectionForLoading}
observeIntersectionForPlaying={observeIntersectionForPlaying}
withSharedAnimation={withSharedAnimation}

View File

@ -322,6 +322,7 @@ const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
<StickerSetCover
stickerSet={stickerSet as ApiStickerSet}
noPlay={!canAnimate || !loadAndPlay}
forcePlayback
observeIntersection={observeIntersectionForCovers}
sharedCanvasRef={withSharedCanvas ? (isHq ? sharedCanvasHqRef : sharedCanvasRef) : undefined}
/>
@ -345,6 +346,7 @@ const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
withTranslucentThumb={isTranslucent}
onClick={selectStickerSet}
clickArg={index}
forcePlayback
/>
);
}
@ -422,6 +424,7 @@ const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
onContextMenuOpen={onContextMenuOpen}
onContextMenuClose={onContextMenuClose}
onContextMenuClick={onContextMenuClick}
forcePlayback
/>
);
})}

View File

@ -63,7 +63,7 @@ const DeleteChatModal: FC<OwnProps & StateProps> = ({
deleteHistory,
deleteChannel,
deleteChatUser,
blockContact,
blockUser,
} = getActions();
const lang = useLang();
@ -77,10 +77,10 @@ const DeleteChatModal: FC<OwnProps & StateProps> = ({
const handleDeleteAndStop = useCallback(() => {
deleteHistory({ chatId: chat.id, shouldDeleteForAll: true });
blockContact({ contactId: chat.id, accessHash: chat.accessHash! });
blockUser({ userId: chat.id });
onClose();
}, [deleteHistory, chat.id, chat.accessHash, blockContact, onClose]);
}, [chat.id, onClose]);
const handleDeleteChat = useCallback(() => {
if (isPrivateChat) {

View File

@ -70,7 +70,8 @@
}
.icon {
font-size: 1.25rem;
font-size: 1.125rem;
margin-inline-end: 0.125rem;
line-height: 0.9375rem;
vertical-align: -0.1875rem;
}

View File

@ -81,11 +81,14 @@ const EmbeddedStory: FC<OwnProps> = ({
onMouseDown={handleMouseDown}
>
{pictogramUrl && renderPictogram(pictogramUrl, isProtected)}
<div className={buildClassName('message-text', isExpiredStory && 'with-message-color')}>
<div className="message-text with-message-color">
<p dir="auto">
{isExpiredStory && (
<i className="icon icon-story-expired" aria-hidden />
)}
{isFullStory && (
<i className="icon icon-story-reply" aria-hidden />
)}
{lang(title)}
</p>
<div className="message-title" dir="auto">{renderText(senderTitle || NBSP)}</div>

View File

@ -1,5 +1,5 @@
.root {
display: flex;
display: flex !important;
align-items: center;
gap: 0.25rem;

View File

@ -25,6 +25,7 @@ interface OwnProps {
withTranslucentThumbs?: boolean;
shouldRenderAsHtml?: boolean;
inChatList?: boolean;
forcePlayback?: boolean;
}
const MIN_CUSTOM_EMOJIS_FOR_SHARED_CANVAS = 3;
@ -43,6 +44,7 @@ function MessageText({
withTranslucentThumbs,
shouldRenderAsHtml,
inChatList,
forcePlayback,
}: OwnProps) {
// eslint-disable-next-line no-null/no-null
const sharedCanvasRef = useRef<HTMLCanvasElement>(null);
@ -94,6 +96,7 @@ function MessageText({
sharedCanvasRef,
sharedCanvasHqRef,
cacheBuster: textCacheBusterRef.current.toString(),
forcePlayback,
}),
].flat().filter(Boolean)}
</>

View File

@ -29,6 +29,7 @@ type OwnProps = {
observeIntersection?: ObserveFn;
sharedCanvasRef?: React.RefObject<HTMLCanvasElement>;
sharedCanvasHqRef?: React.RefObject<HTMLCanvasElement>;
forcePlayback?: boolean;
onClick: (reaction: ApiReaction) => void;
};
@ -40,6 +41,7 @@ const ReactionEmoji: FC<OwnProps> = ({
observeIntersection,
sharedCanvasRef,
sharedCanvasHqRef,
forcePlayback,
onClick,
}) => {
// eslint-disable-next-line no-null/no-null
@ -84,6 +86,7 @@ const ReactionEmoji: FC<OwnProps> = ({
sharedCanvasRef={sharedCanvasRef}
sharedCanvasHqRef={sharedCanvasHqRef}
withTranslucentThumb
forceAlways={forcePlayback}
/>
) : (
<AnimatedIconWithPreview

View File

@ -1,4 +1,14 @@
.ReactionStaticEmoji {
width: 1rem;
display: block;
// Unicorn reaction preview is too small
&.with-unicorn-fix {
transform: scale(2);
}
&.icon-heart {
color: var(--color-heart) !important;
margin: 0 !important;
}
}

View File

@ -21,6 +21,7 @@ type OwnProps = {
availableReactions?: ApiAvailableReaction[];
className?: string;
size?: number;
withIconHeart?: boolean;
observeIntersection?: ObserveFn;
};
@ -29,6 +30,7 @@ const ReactionStaticEmoji: FC<OwnProps> = ({
availableReactions,
className,
size,
withIconHeart,
observeIntersection,
}) => {
const isCustom = 'documentId' in reaction;
@ -40,6 +42,9 @@ const ReactionStaticEmoji: FC<OwnProps> = ({
const transitionClassNames = useMediaTransition(mediaData);
const shouldApplySizeFix = 'emoticon' in reaction && reaction.emoticon === '🦄';
const shouldReplaceWithHeartIcon = withIconHeart && 'emoticon' in reaction && reaction.emoticon === '❤';
if (isCustom) {
return (
<CustomEmoji
@ -51,9 +56,20 @@ const ReactionStaticEmoji: FC<OwnProps> = ({
);
}
if (shouldReplaceWithHeartIcon) {
return (
<i className="ReactionStaticEmoji icon icon-heart" style={`font-size: ${size}px; width: ${size}px`} />
);
}
return (
<img
className={buildClassName('ReactionStaticEmoji', transitionClassNames, className)}
className={buildClassName(
'ReactionStaticEmoji',
shouldApplySizeFix && 'with-unicorn-fix',
transitionClassNames,
className,
)}
style={size ? `width: ${size}px; height: ${size}px` : undefined}
src={mediaData || blankUrl}
alt={availableReaction?.title}

View File

@ -41,6 +41,7 @@ type OwnProps<T> = {
isCurrentUserPremium?: boolean;
sharedCanvasRef?: React.RefObject<HTMLCanvasElement>;
withTranslucentThumb?: boolean;
forcePlayback?: boolean;
observeIntersection: ObserveFn;
observeIntersectionForShowing?: ObserveFn;
noShowPremium?: boolean;
@ -79,6 +80,7 @@ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult |
noShowPremium,
sharedCanvasRef,
withTranslucentThumb,
forcePlayback,
onClick,
clickArg,
onFaveClick,
@ -301,6 +303,7 @@ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult |
sharedCanvasRef={sharedCanvasRef}
withTranslucentThumb={withTranslucentThumb}
customColor={customColor}
forceAlways={forcePlayback}
/>
)}
{!noShowPremium && isLocked && (

View File

@ -54,6 +54,7 @@ type OwnProps = {
withDefaultStatusIcon?: boolean;
isTranslucent?: boolean;
noContextMenus?: boolean;
forcePlayback?: boolean;
observeIntersection?: ObserveFn;
observeIntersectionForPlayingItems: ObserveFn;
observeIntersectionForShowingItems: ObserveFn;
@ -92,6 +93,7 @@ const StickerSet: FC<OwnProps> = ({
withDefaultStatusIcon,
isTranslucent,
noContextMenus,
forcePlayback,
observeIntersection,
observeIntersectionForPlayingItems,
observeIntersectionForShowingItems,
@ -222,8 +224,7 @@ const StickerSet: FC<OwnProps> = ({
}
}, [shouldRender, loadStickers, stickerSet]);
const isLocked = !isSavedMessages && !isRecent && isEmoji && !isCurrentUserPremium
&& stickerSet.stickers?.some(({ isFree }) => !isFree);
const isLocked = !isSavedMessages && !isCurrentUserPremium && isPremiumSet;
const isInstalled = stickerSet.installedDate && !stickerSet.isArchived;
const canCut = !isInstalled && stickerSet.id !== RECENT_SYMBOL_SET_ID && stickerSet.id !== POPULAR_SYMBOL_SET_ID;
@ -239,7 +240,16 @@ const StickerSet: FC<OwnProps> = ({
const favoriteStickerIdsSet = useMemo(() => (
favoriteStickers ? new Set(favoriteStickers.map(({ id }) => id)) : undefined
), [favoriteStickers]);
const withAddSetButton = !shouldHideHeader && !isRecent && isEmoji && !isInstalled && !isPopular;
const withAddSetButton = !shouldHideHeader && !isRecent && isEmoji && !isPopular
&& (!isInstalled || (!isCurrentUserPremium && !isSavedMessages));
const addSetButtonText = useMemo(() => {
if (isLocked) {
if (isInstalled) return lang('lng_emoji_premium_restore');
return lang('Unlock');
}
return lang('Add');
}, [isLocked, lang, isInstalled]);
return (
<div
@ -256,7 +266,9 @@ const StickerSet: FC<OwnProps> = ({
{isLocked && <i className="symbol-set-locked-icon icon icon-lock-badge" />}
{stickerSet.title}
{withAddSetButton && Boolean(stickerSet.stickers) && (
<span className="symbol-set-amount">{lang('Stickers', stickerSet.stickers.length, 'i')}</span>
<span className="symbol-set-amount">
{lang(isEmoji ? 'EmojiCount' : 'Stickers', stickerSet.stickers.length, 'i')}
</span>
)}
</p>
{isRecent && (
@ -271,7 +283,7 @@ const StickerSet: FC<OwnProps> = ({
size="tiny"
fluid
>
{isPremiumSet && isLocked ? lang('Unlock') : lang('Add')}
{addSetButtonText}
</Button>
)}
</div>
@ -321,6 +333,7 @@ const StickerSet: FC<OwnProps> = ({
onClick={onReactionSelect!}
sharedCanvasRef={sharedCanvasRef}
sharedCanvasHqRef={sharedCanvasHqRef}
forcePlayback={forcePlayback}
/>
);
})}
@ -358,6 +371,7 @@ const StickerSet: FC<OwnProps> = ({
onContextMenuOpen={onContextMenuOpen}
onContextMenuClose={onContextMenuClose}
onContextMenuClick={onContextMenuClick}
forcePlayback={forcePlayback}
/>
);
})}

View File

@ -38,6 +38,7 @@ type OwnProps = {
loopLimit?: number;
shouldLoop?: boolean;
shouldPreloadPreview?: boolean;
forceAlways?: boolean;
forceOnHeavyAnimation?: boolean;
observeIntersectionForLoading?: ObserveFn;
observeIntersectionForPlaying?: ObserveFn;
@ -65,6 +66,7 @@ const StickerView: FC<OwnProps> = ({
loopLimit,
shouldLoop = false,
shouldPreloadPreview,
forceAlways,
forceOnHeavyAnimation,
observeIntersectionForLoading,
observeIntersectionForPlaying,
@ -161,7 +163,8 @@ const StickerView: FC<OwnProps> = ({
tgsUrl={fullMediaData}
play={shouldPlay}
noLoop={!shouldLoop}
forceOnHeavyAnimation={forceOnHeavyAnimation}
forceOnHeavyAnimation={forceAlways || forceOnHeavyAnimation}
forceAlways={forceAlways}
isLowPriority={isSmall && !selectIsAlwaysHighPriorityEmoji(getGlobal(), stickerSetInfo)}
sharedCanvas={sharedCanvasRef?.current || undefined}
sharedCanvasCoords={coords}
@ -178,6 +181,7 @@ const StickerView: FC<OwnProps> = ({
playsInline
muted
loop={!loopLimit}
isPriority={forceAlways}
disablePictureInPicture
onReady={markPlayerReady}
onEnded={onVideoEnded}

View File

@ -14,15 +14,25 @@ import renderText from './renderText';
import { renderTextWithEntities } from './renderTextWithEntities';
import trimText from '../../../util/trimText';
export function renderMessageText(
message: ApiMessage,
highlight?: string,
emojiSize?: number,
isSimple?: boolean,
truncateLength?: number,
isProtected?: boolean,
shouldRenderAsHtml?: boolean,
) {
export function renderMessageText({
message,
highlight,
emojiSize,
isSimple,
truncateLength,
isProtected,
forcePlayback,
shouldRenderAsHtml,
} : {
message: ApiMessage;
highlight?: string;
emojiSize?: number;
isSimple?: boolean;
truncateLength?: number;
isProtected?: boolean;
forcePlayback?: boolean;
shouldRenderAsHtml?: boolean;
}) {
const { text, entities } = message.content.text || {};
if (!text) {
@ -39,6 +49,7 @@ export function renderMessageText(
messageId: message.id,
isSimple,
isProtected,
forcePlayback,
});
}
@ -67,7 +78,9 @@ export function renderMessageSummary(
const emoji = !noEmoji && getMessageSummaryEmoji(message);
const emojiWithSpace = emoji ? `${emoji} ` : '';
const text = renderMessageText(message, highlight, undefined, true, truncateLength);
const text = renderMessageText({
message, highlight, isSimple: true, truncateLength,
});
const description = getMessageSummaryDescription(lang, message, text);
return [

View File

@ -42,6 +42,7 @@ export function renderTextWithEntities({
sharedCanvasRef,
sharedCanvasHqRef,
cacheBuster,
forcePlayback,
}: {
text: string;
entities?: ApiMessageEntity[];
@ -57,8 +58,9 @@ export function renderTextWithEntities({
sharedCanvasRef?: React.RefObject<HTMLCanvasElement>;
sharedCanvasHqRef?: React.RefObject<HTMLCanvasElement>;
cacheBuster?: string;
forcePlayback?: boolean;
}) {
if (!entities || !entities.length) {
if (!entities?.length) {
return renderMessagePart(text, highlight, emojiSize, shouldRenderAsHtml, isSimple);
}
@ -131,7 +133,7 @@ export function renderTextWithEntities({
// Render the entity itself
const newEntity = shouldRenderAsHtml
? processEntityAsHtml(entity, entityContent, nestedEntityContent)
: processEntity(
: processEntity({
entity,
entityContent,
nestedEntityContent,
@ -146,7 +148,8 @@ export function renderTextWithEntities({
sharedCanvasRef,
sharedCanvasHqRef,
cacheBuster,
);
forcePlayback,
});
if (Array.isArray(newEntity)) {
renderResult.push(...newEntity);
@ -312,22 +315,39 @@ function organizeEntity(
};
}
function processEntity(
entity: ApiMessageEntity,
entityContent: TextPart,
nestedEntityContent: TextPart[],
highlight?: string,
messageId?: number,
isSimple?: boolean,
isProtected?: boolean,
observeIntersectionForLoading?: ObserveFn,
observeIntersectionForPlaying?: ObserveFn,
withTranslucentThumbs?: boolean,
emojiSize?: number,
sharedCanvasRef?: React.RefObject<HTMLCanvasElement>,
sharedCanvasHqRef?: React.RefObject<HTMLCanvasElement>,
cacheBuster?: string,
) {
function processEntity({
entity,
entityContent,
nestedEntityContent,
highlight,
messageId,
isSimple,
isProtected,
observeIntersectionForLoading,
observeIntersectionForPlaying,
withTranslucentThumbs,
emojiSize,
sharedCanvasRef,
sharedCanvasHqRef,
cacheBuster,
forcePlayback,
} : {
entity: ApiMessageEntity;
entityContent: TextPart;
nestedEntityContent: TextPart[];
highlight?: string;
messageId?: number;
isSimple?: boolean;
isProtected?: boolean;
observeIntersectionForLoading?: ObserveFn;
observeIntersectionForPlaying?: ObserveFn;
withTranslucentThumbs?: boolean;
emojiSize?: number;
sharedCanvasRef?: React.RefObject<HTMLCanvasElement>;
sharedCanvasHqRef?: React.RefObject<HTMLCanvasElement>;
cacheBuster?: string;
forcePlayback?: boolean;
}) {
const entityText = typeof entityContent === 'string' && entityContent;
const renderedContent = nestedEntityContent.length ? nestedEntityContent : entityContent;
@ -360,6 +380,7 @@ function processEntity(
observeIntersectionForLoading={observeIntersectionForLoading}
observeIntersectionForPlaying={observeIntersectionForPlaying}
withTranslucentThumb={withTranslucentThumbs}
forceAlways={forcePlayback}
/>
);
}
@ -485,6 +506,7 @@ function processEntity(
observeIntersectionForLoading={observeIntersectionForLoading}
observeIntersectionForPlaying={observeIntersectionForPlaying}
withTranslucentThumb={withTranslucentThumbs}
forceAlways={forcePlayback}
/>
);
default:

View File

@ -319,11 +319,11 @@
}
}
.colon, .forward {
.colon, .chat-prefix-icon {
margin-inline-end: 0.1875rem;
}
.forward {
.chat-prefix-icon {
display: inline-block;
color: var(--color-list-icon);
font-size: 0.875rem;

View File

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

View File

@ -37,7 +37,7 @@ const BlockUserModal: FC<OwnProps & StateProps> = ({
}) => {
const {
setUserSearchQuery,
blockContact,
blockUser,
} = getActions();
const lang = useLang();
@ -65,13 +65,9 @@ const BlockUserModal: FC<OwnProps & StateProps> = ({
}, [blockedIds, contactIds, currentUserId, search, localContactIds, usersById]);
const handleRemoveUser = useCallback((userId: string) => {
const { id: contactId, accessHash } = usersById[userId] || {};
if (!contactId || !accessHash) {
return;
}
blockContact({ contactId, accessHash });
blockUser({ userId });
onClose();
}, [blockContact, onClose, usersById]);
}, [onClose]);
return (
<ChatOrUserPicker

View File

@ -14,7 +14,7 @@
margin-bottom: 0 !important;
}
:global(.subtitle) {
.subtitle {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;

View File

@ -117,7 +117,7 @@ const SettingsActiveWebsites: FC<OwnProps & StateProps> = ({
<span className={buildClassName('subtitle', 'black', 'tight', styles.platform)}>
{session.domain}, {session.browser}, {session.platform}
</span>
<span className="subtitle">{session.ip} {session.region}</span>
<span className={buildClassName('subtitle', styles.subtitle)}>{session.ip} {session.region}</span>
</div>
</ListItem>
);

View File

@ -67,7 +67,7 @@ const SettingsPrivacy: FC<OwnProps & StateProps> = ({
}) => {
const {
loadPrivacySettings,
loadBlockedContacts,
loadBlockedUsers,
loadAuthorizations,
loadContentSettings,
updateContentSettings,
@ -79,12 +79,12 @@ const SettingsPrivacy: FC<OwnProps & StateProps> = ({
} = getActions();
useEffect(() => {
loadBlockedContacts();
loadBlockedUsers();
loadAuthorizations();
loadPrivacySettings();
loadContentSettings();
loadWebAuthorizations();
}, [loadBlockedContacts, loadAuthorizations, loadPrivacySettings, loadContentSettings, loadWebAuthorizations]);
}, []);
useEffect(() => {
if (isActive) {

View File

@ -39,13 +39,13 @@ const SettingsPrivacyBlockedUsers: FC<OwnProps & StateProps> = ({
blockedIds,
phoneCodeList,
}) => {
const { unblockContact } = getActions();
const { unblockUser } = getActions();
const lang = useLang();
const [isBlockUserModalOpen, openBlockUserModal, closeBlockUserModal] = useFlag();
const handleUnblockClick = useCallback((contactId: string) => {
unblockContact({ contactId });
}, [unblockContact]);
const handleUnblockClick = useCallback((userId: string) => {
unblockUser({ userId });
}, [unblockUser]);
useHistoryBack({
isActive,
@ -53,13 +53,13 @@ const SettingsPrivacyBlockedUsers: FC<OwnProps & StateProps> = ({
});
const blockedUsernamesById = useMemo(() => {
return blockedIds.reduce((acc, contactId) => {
const isPrivate = isUserId(contactId);
const user = isPrivate ? usersByIds[contactId] : undefined;
return blockedIds.reduce((acc, userId) => {
const isPrivate = isUserId(userId);
const user = isPrivate ? usersByIds[userId] : undefined;
const mainUsername = user && !user.phoneNumber && getMainUsername(user);
if (mainUsername) {
acc[contactId] = mainUsername;
acc[userId] = mainUsername;
}
return acc;

View File

@ -11,6 +11,7 @@ import type {
ApiAttachBot,
ApiChat,
ApiChatFolder,
ApiGeoPoint,
ApiMessage,
ApiUser,
} from '../../api/types';
@ -95,6 +96,7 @@ 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 MapModal from '../modals/mapModal/MapModal.async';
import './Main.scss';
@ -115,6 +117,8 @@ type StateProps = {
hasDialogs: boolean;
audioMessage?: ApiMessage;
safeLinkModalUrl?: string;
mapModalGeoPoint?: ApiGeoPoint;
mapModalZoom?: number;
isHistoryCalendarOpen: boolean;
shouldSkipHistoryAnimations?: boolean;
openedStickerSetShortName?: string;
@ -171,6 +175,8 @@ const Main: FC<OwnProps & StateProps> = ({
audioMessage,
activeGroupCallId,
safeLinkModalUrl,
mapModalGeoPoint,
mapModalZoom,
isHistoryCalendarOpen,
shouldSkipHistoryAnimations,
limitReached,
@ -245,6 +251,7 @@ const Main: FC<OwnProps & StateProps> = ({
loadRecentReactions,
loadFeaturedEmojiStickers,
setIsAppUpdateAvailable,
loadPremiumSetStickers,
} = getActions();
if (DEBUG && !DEBUG_isLogged) {
@ -326,6 +333,7 @@ const Main: FC<OwnProps & StateProps> = ({
if (isMasterTab && isCurrentUserPremium) {
loadDefaultStatusIcons();
loadRecentEmojiStatuses();
loadPremiumSetStickers();
}
}, [isCurrentUserPremium, isMasterTab]);
@ -517,6 +525,7 @@ const Main: FC<OwnProps & StateProps> = ({
<Dialogs isOpen={hasDialogs} />
{audioMessage && <AudioPlayer key={audioMessage.id} message={audioMessage} noUi />}
<SafeLinkModal url={safeLinkModalUrl} />
<MapModal geoPoint={mapModalGeoPoint} zoom={mapModalZoom} />
<UrlAuthModal urlAuth={urlAuth} currentUserName={currentUserName} />
<HistoryCalendar isOpen={isHistoryCalendarOpen} />
<StickerSetModal
@ -579,6 +588,7 @@ export default memo(withGlobal<OwnProps>(
urlAuth,
webApp,
safeLinkModalUrl,
mapModal,
openedStickerSetShortName,
openedCustomEmojiSetIds,
shouldSkipHistoryAnimations,
@ -623,6 +633,8 @@ export default memo(withGlobal<OwnProps>(
hasDialogs: Boolean(dialogs.length),
audioMessage,
safeLinkModalUrl,
mapModalGeoPoint: mapModal?.point,
mapModalZoom: mapModal?.zoom,
isHistoryCalendarOpen: Boolean(historyCalendarSelectedAt),
shouldSkipHistoryAnimations,
openedStickerSetShortName,

View File

@ -18,7 +18,6 @@
color: var(--color-text-secondary);
white-space: pre-wrap;
line-height: 1.25rem;
min-height: 2.5rem;
}
.icon {
@ -29,3 +28,11 @@
background: var(--item-color, #000);
margin-left: 1rem;
}
.font-icon {
font-size: 2rem !important;
align-self: center;
margin-right: 0 !important;
margin-left: 1rem;
color: var(--item-color, #000) !important;
}

View File

@ -3,6 +3,7 @@ import React, { memo } from '../../../lib/teact/teact';
import renderText from '../../common/helpers/renderText';
import { hexToRgb, lerpRgb } from '../../../util/switchTheme';
import buildClassName from '../../../util/buildClassName';
import ListItem from '../../ui/ListItem';
@ -10,11 +11,12 @@ import styles from './PremiumFeatureItem.module.scss';
type OwnProps = {
icon: string;
isFontIcon?: boolean;
title: string;
text: string;
onClick: VoidFunction;
index: number;
count: number;
onClick?: VoidFunction;
};
const COLORS = [
@ -24,6 +26,7 @@ const COLORS = [
const PremiumFeatureItem: FC<OwnProps> = ({
icon,
isFontIcon,
title,
text,
index,
@ -33,11 +36,19 @@ const PremiumFeatureItem: FC<OwnProps> = ({
const newIndex = (index / count) * COLORS.length;
const colorA = COLORS[Math.floor(newIndex)];
const colorB = COLORS[Math.ceil(newIndex)] ?? colorA;
const { r, g, b } = lerpRgb(colorA, colorB, 1);
const { r, g, b } = lerpRgb(colorA, colorB, 0.5);
return (
<ListItem buttonClassName={styles.root} onClick={onClick}>
<img src={icon} className={styles.icon} alt="" style={`--item-color: rgb(${r},${g},${b})`} />
<ListItem buttonClassName={styles.root} onClick={onClick} inactive={!onClick}>
{isFontIcon ? (
<i
className={buildClassName(styles.fontIcon, `icon icon-${icon}`)}
aria-hidden
style={`--item-color: rgb(${r},${g},${b})`}
/>
) : (
<img src={icon} className={styles.icon} alt="" style={`--item-color: rgb(${r},${g},${b})`} />
)}
<div className={styles.text}>
<div className={styles.title}>{renderText(title, ['br'])}</div>
<div className={styles.description}>{text}</div>

View File

@ -52,7 +52,7 @@
margin-bottom: 7.5rem;
}
.limits {
.limits, .stories {
background: var(--color-background);
}
@ -63,6 +63,10 @@
height: calc(var(--vh) * 55 + 41px);
}
.stories {
height: calc(var(--vh) * 55 + 100px);
}
.header {
padding-left: 4rem;
font-size: 1.25rem;
@ -140,4 +144,8 @@
.limits-content {
height: calc(var(--vh) * 100 - 193px);
}
.stories {
height: calc(var(--vh) * 100 - 135px);
}
}

View File

@ -19,6 +19,7 @@ import PremiumLimitPreview from './common/PremiumLimitPreview';
import PremiumFeaturePreviewVideo from './previews/PremiumFeaturePreviewVideo';
import SliderDots from '../../common/SliderDots';
import PremiumFeaturePreviewStickers from './previews/PremiumFeaturePreviewStickers';
import PremiumFeaturePreviewStories from './previews/PremiumFeaturePreviewStories';
import styles from './PremiumFeatureModal.module.scss';
@ -36,6 +37,7 @@ export const PREMIUM_FEATURE_TITLES: Record<string, string> = {
animated_userpics: 'PremiumPreviewAnimatedProfiles',
emoji_status: 'PremiumPreviewEmojiStatus',
translations: 'PremiumPreviewTranslations',
stories: 'PremiumPreviewStories',
};
export const PREMIUM_FEATURE_DESCRIPTIONS: Record<string, string> = {
@ -52,9 +54,11 @@ export const PREMIUM_FEATURE_DESCRIPTIONS: Record<string, string> = {
animated_userpics: 'PremiumPreviewAnimatedProfilesDescription',
emoji_status: 'PremiumPreviewEmojiStatusDescription',
translations: 'PremiumPreviewTranslationsDescription',
stories: 'PremiumPreviewStoriesDescription',
};
export const PREMIUM_FEATURE_SECTIONS = [
'stories',
'double_limits',
'more_upload',
'faster_download',
@ -267,6 +271,14 @@ const PremiumFeatureModal: FC<OwnProps> = ({
);
}
if (section === 'stories') {
return (
<div className={buildClassName(styles.slide, styles.stories)}>
<PremiumFeaturePreviewStories />
</div>
);
}
const i = promo.videoSections.indexOf(section);
if (i === -1) return undefined;
return (

View File

@ -34,7 +34,7 @@
.main {
padding: 1rem 0.5rem;
height: 100%;
overflow: scroll;
overflow-y: scroll;
display: flex;
flex-direction: column;
align-items: center;

View File

@ -57,6 +57,7 @@ const LIMIT_ACCOUNTS = 4;
const STATUS_EMOJI_SIZE = 8 * REM;
const PREMIUM_FEATURE_COLOR_ICONS: Record<string, string> = {
stories: PremiumStatus,
double_limits: PremiumLimits,
infinite_reactions: PremiumReactions,
premium_stickers: PremiumStickers,

View File

@ -0,0 +1,43 @@
.root {
display: flex;
flex-direction: column;
padding-top: 1.5rem;
height: 100%;
}
.header {
align-self: center;
position: relative;
}
.circle {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.title {
font-size: 1.25rem;
font-weight: 500;
align-self: center;
margin-top: 1rem;
margin-bottom: 1rem;
}
.features {
min-height: 5rem;
overflow-y: scroll;
border-top: 0.0625rem solid transparent;
transition: 0.2s ease-in-out border-color;
}
.mobile {
padding: 1rem 1rem 1rem 2rem;
color: var(--color-text-secondary);
font-size: 0.875rem;
}
.scrolled {
border-color: var(--color-borders);
}

View File

@ -0,0 +1,123 @@
import React, { memo, useLayoutEffect, useRef } from '../../../../lib/teact/teact';
import { withGlobal } from '../../../../global';
import type { ApiUser } from '../../../../api/types';
import { selectUser } from '../../../../global/selectors';
import { REM } from '../../../common/helpers/mediaDimensions';
import { DPR } from '../../../../util/windowEnvironment';
import { drawGradientCircle } from '../../../common/AvatarStoryCircle';
import buildClassName from '../../../../util/buildClassName';
import useLang from '../../../../hooks/useLang';
import useScrolledState from '../../../../hooks/useScrolledState';
import Avatar from '../../../common/Avatar';
import PremiumFeatureItem from '../PremiumFeatureItem';
import styles from './PremiumFeaturePreviewStories.module.scss';
type StateProps = {
currentUser: ApiUser;
};
const STORY_FEATURE_TITLES = {
stories_order: 'PremiumStoriesPriority',
stories_stealth: 'PremiumStoriesStealth',
stories_views: 'PremiumStoriesViews',
stories_timer: 'lng_premium_stories_subtitle_expiration',
stories_save: 'PremiumStoriesSaveToGallery',
stories_caption: 'lng_premium_stories_subtitle_caption',
stories_link: 'lng_premium_stories_subtitle_links',
};
const STORY_FEATURE_DESCRIPTIONS = {
stories_order: 'PremiumStoriesPriorityDescription',
stories_stealth: 'PremiumStoriesStealthDescription',
stories_views: 'PremiumStoriesViewsDescription',
stories_timer: 'PremiumStoriesExpirationDescription',
stories_save: 'PremiumStoriesSaveToGalleryDescription',
stories_caption: 'PremiumStoriesCaptionDescription',
stories_link: 'PremiumStoriesFormattingDescription',
};
const STORY_FEATURE_ICONS = {
stories_order: 'story-priority',
stories_stealth: 'eye-closed-outline',
stories_views: 'eye-outline',
stories_timer: 'timer',
stories_save: 'arrow-down-circle',
stories_caption: 'story-caption',
stories_link: 'link-badge',
};
const STORY_FEATURE_ORDER = Object.keys(STORY_FEATURE_TITLES) as (keyof typeof STORY_FEATURE_TITLES)[];
const CIRCLE_SIZE = 5.25 * DPR * REM;
const CIRCLE_SEGMENTS = 8;
const CIRCLE_READ_SEGMENTS = 0;
const PremiumFeaturePreviewVideo = ({
currentUser,
}: StateProps) => {
// eslint-disable-next-line no-null/no-null
const circleRef = useRef<HTMLCanvasElement>(null);
const lang = useLang();
useLayoutEffect(() => {
if (!circleRef.current) {
return;
}
drawGradientCircle({
canvas: circleRef.current,
size: CIRCLE_SIZE,
segmentsCount: CIRCLE_SEGMENTS,
color: 'purple',
readSegmentsCount: CIRCLE_READ_SEGMENTS,
readSegmentColor: 'transparent',
});
}, []);
const { handleScroll, isAtBeginning } = useScrolledState();
const maxSize = CIRCLE_SIZE / DPR;
return (
<div className={styles.root}>
<div className={styles.header}>
<Avatar forPremiumPromo peer={currentUser} size="giant" />
<canvas className={styles.circle} ref={circleRef} style={`max-width: ${maxSize}px; max-height: ${maxSize}px`} />
</div>
<div className={styles.title}>{lang('UpgradedStories')}</div>
<div
className={buildClassName(styles.features, !isAtBeginning && styles.scrolled, 'custom-scroll')}
onScroll={handleScroll}
>
{STORY_FEATURE_ORDER.map((section, index) => {
return (
<PremiumFeatureItem
key={section}
title={lang(STORY_FEATURE_TITLES[section])}
text={lang(STORY_FEATURE_DESCRIPTIONS[section])}
icon={STORY_FEATURE_ICONS[section]}
isFontIcon
index={index}
count={STORY_FEATURE_ORDER.length}
/>
);
})}
<div className={styles.mobile}>{lang('lng_premium_stories_about_mobile')}</div>
</div>
</div>
);
};
export default memo(withGlobal(
(global): StateProps => {
return {
currentUser: selectUser(global, global.currentUserId!)!,
};
},
)(PremiumFeaturePreviewVideo));

View File

@ -207,7 +207,7 @@ const MediaViewer: FC<StateProps> = ({
const prevMediaId = usePrevious(mediaId);
const prevAvatarOwner = usePrevious<ApiChat | ApiUser | undefined>(avatarOwner);
const prevBestImageData = usePrevious(bestImageData);
const textParts = message ? renderMessageText(message) : undefined;
const textParts = message ? renderMessageText({ message, forcePlayback: true }) : undefined;
const hasFooter = Boolean(textParts);
const shouldAnimateOpening = prevIsHidden && prevMediaId !== mediaId;

View File

@ -147,7 +147,7 @@ const MediaViewerContent: FC<OwnProps & StateProps> = (props) => {
if (!message) return undefined;
const textParts = message.content.action?.type === 'suggestProfilePhoto'
? lang('Conversation.SuggestedPhotoTitle')
: renderMessageText(message);
: renderMessageText({ message, forcePlayback: true });
const hasFooter = Boolean(textParts);
const posterSize = message && calculateMediaViewerDimensions(dimensions!, hasFooter, isVideo);

View File

@ -37,7 +37,7 @@ const ChatReportPanel: FC<OwnProps & StateProps> = ({
}) => {
const {
openAddContactDialog,
blockContact,
blockUser,
reportSpam,
deleteChat,
leaveChannel,
@ -51,7 +51,6 @@ const ChatReportPanel: FC<OwnProps & StateProps> = ({
const [isBlockUserModalOpen, openBlockUserModal, closeBlockUserModal] = useFlag();
const [shouldReportSpam, setShouldReportSpam] = useState<boolean>(true);
const [shouldDeleteChat, setShouldDeleteChat] = useState<boolean>(true);
const { accessHash } = chat || {};
const {
isAutoArchived, canReportSpam, canAddContact, canBlockContact,
} = settings || {};
@ -66,7 +65,7 @@ const ChatReportPanel: FC<OwnProps & StateProps> = ({
const handleConfirmBlock = useLastCallback(() => {
closeBlockUserModal();
blockContact({ contactId: chatId, accessHash: accessHash! });
blockUser({ userId: chatId });
if (canReportSpam && shouldReportSpam) {
reportSpam({ chatId });
}

View File

@ -108,7 +108,7 @@ const EmojiInteractionAnimation: FC<OwnProps & StateProps> = ({
tgsUrl={effectTgsUrl}
play
quality={IS_ANDROID ? 0.5 : undefined}
forceOnHeavyAnimation
forceAlways
noLoop
onLoad={startPlaying}
/>

View File

@ -19,7 +19,7 @@ import useMedia from '../../hooks/useMedia';
import useLang from '../../hooks/useLang';
import OptimizedVideo from '../ui/OptimizedVideo';
import Skeleton from '../ui/Skeleton';
import Skeleton from '../ui/placeholder/Skeleton';
import styles from './MessageListBotInfo.module.scss';

View File

@ -592,6 +592,7 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
canSendPlainText
className="attachment-modal-symbol-menu with-menu-transitions"
idPrefix="attachment"
forceDarkTheme={forceDarkTheme}
/>
<MessageInput
ref={inputRef}

View File

@ -32,6 +32,7 @@ import useAppLayout from '../../../hooks/useAppLayout';
import useDerivedState from '../../../hooks/useDerivedState';
import TextFormatter from './TextFormatter';
import TextTimer from '../../ui/TextTimer';
const CONTEXT_MENU_CLOSE_DELAY_MS = 100;
// Focus slows down animation, also it breaks transition layout in Chrome
@ -54,6 +55,8 @@ type OwnProps = {
isActive: boolean;
getHtml: Signal<string>;
placeholder: string;
timedPlaceholderLangKey?: string;
timedPlaceholderDate?: number;
forcedPlaceholder?: string;
noFocusInterception?: boolean;
canAutoFocus: boolean;
@ -114,6 +117,8 @@ const MessageInput: FC<OwnProps & StateProps> = ({
isActive,
getHtml,
placeholder,
timedPlaceholderLangKey,
timedPlaceholderDate,
forcedPlaceholder,
canSendPlainText,
canAutoFocus,
@ -165,6 +170,16 @@ const MessageInput: FC<OwnProps & StateProps> = ({
const { isMobile } = useAppLayout();
const isMobileDevice = isMobile && (IS_IOS || IS_ANDROID);
const [shouldDisplayTimer, setShouldDisplayTimer] = useState(false);
useEffect(() => {
setShouldDisplayTimer(Boolean(timedPlaceholderLangKey && timedPlaceholderDate));
}, [timedPlaceholderDate, timedPlaceholderLangKey]);
const handleTimerEnd = useLastCallback(() => {
setShouldDisplayTimer(false);
});
useInputCustomEmojis(
getHtml,
inputRef,
@ -572,7 +587,9 @@ const MessageInput: FC<OwnProps & StateProps> = ({
>
{!isAttachmentModalInput && !canSendPlainText
&& <i className="icon icon-lock-badge placeholder-icon" />}
{placeholder}
{shouldDisplayTimer ? (
<TextTimer langKey={timedPlaceholderLangKey!} endsAt={timedPlaceholderDate!} onEnd={handleTimerEnd} />
) : placeholder}
</span>
)}
<canvas ref={sharedCanvasRef} className="shared-canvas" />

View File

@ -294,6 +294,7 @@ const StickerPicker: FC<OwnProps & StateProps> = ({
noPlay={!canAnimate || !loadAndPlay}
observeIntersection={observeIntersectionForCovers}
sharedCanvasRef={withSharedCanvas ? sharedCanvasRef : undefined}
forcePlayback
/>
)}
</Button>
@ -314,6 +315,7 @@ const StickerPicker: FC<OwnProps & StateProps> = ({
withTranslucentThumb={isTranslucent}
onClick={selectStickerSet}
clickArg={index}
forcePlayback
/>
);
}
@ -375,6 +377,7 @@ const StickerPicker: FC<OwnProps & StateProps> = ({
onStickerUnfave={handleStickerUnfave}
onStickerFave={handleStickerFave}
onStickerRemoveRecent={handleRemoveRecentSticker}
forcePlayback
/>
))}
</div>

View File

@ -29,6 +29,7 @@ type OwnProps = {
stickerSet: ApiStickerSet;
size?: number;
noPlay?: boolean;
forcePlayback?: boolean;
observeIntersection: ObserveFn;
sharedCanvasRef?: React.RefObject<HTMLCanvasElement>;
};
@ -37,6 +38,7 @@ const StickerSetCover: FC<OwnProps> = ({
stickerSet,
size = STICKER_SIZE_PICKER_HEADER,
noPlay,
forcePlayback,
observeIntersection,
sharedCanvasRef,
}) => {
@ -90,6 +92,7 @@ const StickerSetCover: FC<OwnProps> = ({
isLowPriority={!selectIsAlwaysHighPriorityEmoji(getGlobal(), stickerSet)}
sharedCanvas={sharedCanvasRef?.current || undefined}
sharedCanvasCoords={coords}
forceAlways={forcePlayback}
/>
) : (isVideo && !shouldFallbackToStatic) ? (
<OptimizedVideo
@ -97,6 +100,7 @@ const StickerSetCover: FC<OwnProps> = ({
src={mediaData}
canPlay={shouldPlay}
style={colorFilter}
isPriority={forcePlayback}
loop
disablePictureInPicture
/>

View File

@ -2,7 +2,7 @@ import React, {
memo, useEffect, useLayoutEffect, useRef, useState,
} from '../../../lib/teact/teact';
import { requestMutation } from '../../../lib/fasterdom/fasterdom';
import { getActions, withGlobal } from '../../../global';
import { withGlobal } from '../../../global';
import type { FC } from '../../../lib/teact/teact';
import type { ApiSticker, ApiVideo } from '../../../api/types';
@ -10,7 +10,7 @@ import type { GlobalActions } from '../../../global';
import { IS_TOUCH_ENV } from '../../../util/windowEnvironment';
import buildClassName from '../../../util/buildClassName';
import { selectTabState, selectIsCurrentUserPremium, selectIsContextMenuTranslucent } from '../../../global/selectors';
import { selectTabState, selectIsContextMenuTranslucent } from '../../../global/selectors';
import useLastCallback from '../../../hooks/useLastCallback';
import useShowTransition from '../../../hooks/useShowTransition';
@ -69,7 +69,6 @@ export type OwnProps = {
type StateProps = {
isLeftColumnShown: boolean;
isCurrentUserPremium?: boolean;
isBackgroundTranslucent?: boolean;
};
@ -83,7 +82,6 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
canSendGifs,
isMessageComposer,
isLeftColumnShown,
isCurrentUserPremium,
idPrefix,
isAttachmentModal,
canSendPlainText,
@ -105,7 +103,6 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
addRecentEmoji,
addRecentCustomEmoji,
}) => {
const { loadPremiumSetStickers } = getActions();
const [activeTab, setActiveTab] = useState<number>(0);
const [recentEmojis, setRecentEmojis] = useState<string[]>([]);
const [recentCustomEmojis, setRecentCustomEmojis] = useState<string[]>([]);
@ -130,12 +127,6 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
setActiveTab(STICKERS_TAB_INDEX);
}, [canSendPlainText]);
useEffect(() => {
if (isCurrentUserPremium) {
loadPremiumSetStickers();
}
}, [isCurrentUserPremium, loadPremiumSetStickers]);
useLayoutEffect(() => {
if (!isMobile || !isOpen || isAttachmentModal) {
return undefined;
@ -356,7 +347,6 @@ export default memo(withGlobal<OwnProps>(
(global): StateProps => {
return {
isLeftColumnShown: selectTabState(global).isLeftColumnShown,
isCurrentUserPremium: selectIsCurrentUserPremium(global),
isBackgroundTranslucent: selectIsContextMenuTranslucent(global),
};
},

View File

@ -29,6 +29,7 @@ type OwnProps = {
canSendStickers?: boolean;
isMessageComposer?: boolean;
idPrefix: string;
forceDarkTheme?: boolean;
openSymbolMenu: VoidFunction;
closeSymbolMenu: VoidFunction;
onCustomEmojiSelect: (emoji: ApiSticker) => void;
@ -65,6 +66,7 @@ const SymbolMenuButton: FC<OwnProps> = ({
canSendPlainText,
isSymbolMenuForced,
className,
forceDarkTheme,
inputCssSelector = EDITABLE_INPUT_CSS_SELECTOR,
openSymbolMenu,
closeSymbolMenu,
@ -196,7 +198,7 @@ const SymbolMenuButton: FC<OwnProps> = ({
addRecentCustomEmoji={addRecentCustomEmoji}
isAttachmentModal={isAttachmentModal}
canSendPlainText={canSendPlainText}
className={className}
className={buildClassName(className, forceDarkTheme && 'component-theme-dark')}
positionX={isAttachmentModal ? positionX : undefined}
positionY={isAttachmentModal ? positionY : undefined}
transformOriginX={isAttachmentModal ? transformOriginX : undefined}

View File

@ -5,7 +5,9 @@ import type { ApiAttachment, ApiFormattedText, ApiMessage } from '../../../../ap
import { ApiMessageEntityTypes } from '../../../../api/types';
import buildAttachment from '../helpers/buildAttachment';
import { DEBUG, EDITABLE_INPUT_ID, EDITABLE_INPUT_MODAL_ID } from '../../../../config';
import {
DEBUG, EDITABLE_INPUT_ID, EDITABLE_INPUT_MODAL_ID, EDITABLE_STORY_INPUT_ID,
} from '../../../../config';
import getFilesFromDataTransferItems from '../helpers/getFilesFromDataTransferItems';
import parseMessageInput, { ENTITY_CLASS_BY_NODE_NAME } from '../../../../util/parseMessageInput';
import cleanDocsHtml from '../../../../lib/cleanDocsHtml';
@ -86,7 +88,7 @@ const useClipboardPaste = (
}
const input = document.activeElement;
if (input && ![EDITABLE_INPUT_ID, EDITABLE_INPUT_MODAL_ID].includes(input.id)) {
if (input && ![EDITABLE_INPUT_ID, EDITABLE_INPUT_MODAL_ID, EDITABLE_STORY_INPUT_ID].includes(input.id)) {
return;
}

View File

@ -66,7 +66,7 @@ const AnimatedEmoji: FC<OwnProps & StateProps> = ({
noLoad={!isIntersecting}
forcePreview={forceLoadPreview}
play={isIntersecting}
forceOnHeavyAnimation
forceAlways
ref={ref}
className={buildClassName('AnimatedEmoji media-inner', sticker?.id === LIKE_STICKER_ID && 'like-sticker-thumb')}
style={style}

View File

@ -8,7 +8,7 @@ import { getGamePreviewPhotoHash, getGamePreviewVideoHash, getMessageText } from
import useMedia from '../../../hooks/useMedia';
import Skeleton from '../../ui/Skeleton';
import Skeleton from '../../ui/placeholder/Skeleton';
import './Game.scss';

View File

@ -16,7 +16,7 @@ import useLang from '../../../hooks/useLang';
import useMedia from '../../../hooks/useMedia';
import useBlurredMediaThumbRef from './hooks/useBlurredMediaThumbRef';
import Skeleton from '../../ui/Skeleton';
import Skeleton from '../../ui/placeholder/Skeleton';
import './Invoice.scss';

View File

@ -15,7 +15,7 @@ import {
} from '../../../global/helpers';
import { formatCountdownShort, formatLastUpdated } from '../../../util/dateFormat';
import {
getMetersPerPixel, getVenueColor, getVenueIconUrl, prepareMapUrl,
getMetersPerPixel, getVenueColor, getVenueIconUrl,
} from '../../../util/map';
import { getServerTime } from '../../../util/serverTime';
@ -29,7 +29,7 @@ import usePrevious from '../../../hooks/usePrevious';
import useInterval from '../../../hooks/useInterval';
import Avatar from '../../common/Avatar';
import Skeleton from '../../ui/Skeleton';
import Skeleton from '../../ui/placeholder/Skeleton';
import './Location.scss';
@ -57,7 +57,7 @@ const Location: FC<OwnProps> = ({
message,
peer,
}) => {
const { openUrl } = getActions();
const { openMapModal } = getActions();
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
@ -95,8 +95,7 @@ const Location: FC<OwnProps> = ({
}, [type, point, zoom]);
const handleClick = () => {
const url = prepareMapUrl(point.lat, point.long, zoom);
openUrl({ url });
openMapModal({ geoPoint: point, zoom });
};
const updateCountdown = useLastCallback((countdownEl: HTMLDivElement) => {
@ -194,6 +193,7 @@ const Location: FC<OwnProps> = ({
className="full-media map"
src={mapBlobUrl}
alt="Location on a map"
draggable={false}
style={`width: ${DEFAULT_MAP_CONFIG.width}px; height: ${DEFAULT_MAP_CONFIG.height}px;`}
/>
);
@ -224,14 +224,14 @@ const Location: FC<OwnProps> = ({
return (
<div className={pinClassName} style={`--pin-color: ${color}`}>
<PinSvg />
<img src={iconSrc} className="venue-icon" alt="" />
<img src={iconSrc} draggable={false} className="venue-icon" alt="" />
</div>
);
}
}
return (
<img className={pinClassName} src={mapPin} alt="" />
<img className={pinClassName} draggable={false} src={mapPin} alt="" />
);
}

View File

@ -33,7 +33,7 @@ import useAppLayout from '../../../hooks/useAppLayout';
import Menu from '../../ui/Menu';
import MenuItem from '../../ui/MenuItem';
import MenuSeparator from '../../ui/MenuSeparator';
import Skeleton from '../../ui/Skeleton';
import Skeleton from '../../ui/placeholder/Skeleton';
import ReactionSelector from './ReactionSelector';
import AvatarList from '../../common/AvatarList';

View File

@ -135,7 +135,7 @@ const ReactionAnimatedEmoji: FC<OwnProps> = ({
tgsUrl={mediaDataEffect}
play={isIntersecting}
noLoop
forceOnHeavyAnimation
forceAlways
onEnded={handleEnded}
/>
{isCustom ? (
@ -148,7 +148,7 @@ const ReactionAnimatedEmoji: FC<OwnProps> = ({
tgsUrl={mediaDataCenterIcon}
play={isIntersecting}
noLoop
forceOnHeavyAnimation
forceAlways
onLoad={markAnimationLoaded}
onEnded={unmarkAnimationLoaded}
/>

View File

@ -41,6 +41,7 @@ interface StateProps {
isCurrentUserPremium?: boolean;
position?: IAnchorPosition;
isTranslucent?: boolean;
sendAsMessage?: boolean;
}
const FULL_PICKER_SHIFT_DELTA = { x: -23, y: -64 };
@ -55,9 +56,10 @@ const ReactionPicker: FC<OwnProps & StateProps> = ({
isTranslucent,
isCurrentUserPremium,
withCustomReactions,
sendAsMessage,
}) => {
const {
toggleReaction, closeReactionPicker, sendMessage, showNotification,
toggleReaction, closeReactionPicker, sendMessage, showNotification, sendStoryReaction,
} = getActions();
const lang = useLang();
@ -121,33 +123,42 @@ const ReactionPicker: FC<OwnProps & StateProps> = ({
closeReactionPicker();
});
const handleStoryReactionSelect = useLastCallback((reaction: ApiReaction | ApiSticker) => {
const handleStoryReactionSelect = useLastCallback((item: ApiReaction | ApiSticker) => {
const reaction = 'id' in item ? { documentId: item.id } : item;
const sticker = 'documentId' in item
? getGlobal().customEmojis.byId[item.documentId] : 'emoticon' in item ? undefined : item;
if (sticker && !sticker.isFree && !isCurrentUserPremium) {
showNotification({
message: lang('UnlockPremiumEmojiHint'),
action: {
action: 'openPremiumModal',
payload: { initialSection: 'animated_emoji' },
},
actionText: lang('PremiumMore'),
});
closeReactionPicker();
return;
}
if (!sendAsMessage) {
sendStoryReaction({
userId: renderedStoryUserId!, storyId: renderedStoryId!, reaction, shouldAddToRecent: true,
});
closeReactionPicker();
return;
}
let text: string | undefined;
let entities: ApiMessageEntity[] | undefined;
if ('emoticon' in reaction) {
text = reaction.emoticon;
if ('emoticon' in item) {
text = item.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));
const customEmojiMessage = parseMessageInput(buildCustomEmojiHtml(sticker!));
text = customEmojiMessage.text;
entities = customEmojiMessage.entities;
}
@ -212,7 +223,7 @@ const ReactionPicker: FC<OwnProps & StateProps> = ({
export default memo(withGlobal<OwnProps>((global): StateProps => {
const state = selectTabState(global);
const {
chatId, messageId, storyUserId, storyId, position,
chatId, messageId, storyUserId, storyId, position, sendAsMessage,
} = state.reactionPicker || {};
const story = storyUserId && storyId
? selectUserStory(global, storyUserId, storyId) as ApiStory | ApiStorySkipped
@ -234,6 +245,7 @@ export default memo(withGlobal<OwnProps>((global): StateProps => {
: areCustomReactionsAllowed || isPrivateChat,
isTranslucent: selectIsContextMenuTranslucent(global),
isCurrentUserPremium: selectIsCurrentUserPremium(global),
sendAsMessage,
};
})(ReactionPicker));

View File

@ -64,6 +64,7 @@ const ReactionSelectorReaction: FC<OwnProps> = ({
noLoop
size={REACTION_SIZE}
onEnded={unmarkIsFirstPlay}
forceAlways
/>
)}
{!isFirstPlay && !noAppearAnimation && (
@ -75,6 +76,7 @@ const ReactionSelectorReaction: FC<OwnProps> = ({
size={REACTION_SIZE}
onLoad={markAnimationLoaded}
onEnded={deactivate}
forceAlways
/>
)}
</div>

View File

@ -88,7 +88,7 @@ export function getMessageCopyOptions(
document.execCommand('copy');
} else {
const clipboardText = renderMessageText(
message, undefined, undefined, undefined, undefined, undefined, true,
{ message, shouldRenderAsHtml: true },
);
if (clipboardText) copyHtmlToClipboard(clipboardText.join(''), getMessageTextWithSpoilers(message)!);
}

View File

@ -0,0 +1,17 @@
import type { FC } from '../../../lib/teact/teact';
import React from '../../../lib/teact/teact';
import { Bundles } from '../../../util/moduleLoader';
import type { OwnProps } from './MapModal';
import useModuleLoader from '../../../hooks/useModuleLoader';
const MapModalAsync: FC<OwnProps> = (props) => {
const { geoPoint } = props;
const MapModal = useModuleLoader(Bundles.Extra, 'MapModal', !geoPoint);
// eslint-disable-next-line react/jsx-props-no-spreading
return MapModal ? <MapModal {...props} /> : undefined;
};
export default MapModalAsync;

View File

@ -0,0 +1,10 @@
.root {
display: flex;
flex-direction: column;
}
.buttons {
display: flex;
flex-direction: column;
gap: 0.5rem;
}

View File

@ -0,0 +1,100 @@
import React, { memo, useMemo } from '../../../lib/teact/teact';
import { getActions } from '../../../global';
import type { ApiGeoPoint } from '../../../api/types';
import { IS_IOS, IS_MAC_OS } from '../../../util/windowEnvironment';
import { prepareMapUrl } from '../../../util/map';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import Modal from '../../ui/Modal';
import Button from '../../ui/Button';
import styles from './MapModal.module.scss';
export type OwnProps = {
geoPoint?: ApiGeoPoint;
zoom?: number;
};
const OpenMapModal = ({ geoPoint, zoom }: OwnProps) => {
const { closeMapModal } = getActions();
const lang = useLang();
const isOpen = Boolean(geoPoint);
const handleClose = useLastCallback(() => {
closeMapModal();
});
const [googleUrl, bingUrl, appleUrl, osmUrl] = useMemo(() => {
if (!geoPoint) {
return [];
}
const google = prepareMapUrl('google', geoPoint, zoom);
const bing = prepareMapUrl('bing', geoPoint, zoom);
const osm = prepareMapUrl('osm', geoPoint, zoom);
const apple = prepareMapUrl('apple', geoPoint, zoom);
return [google, bing, apple, osm];
}, [geoPoint, zoom]);
const openUrl = useLastCallback((url: string) => {
closeMapModal();
window.open(url, '_blank', 'noopener');
});
const handleGoogleClick = useLastCallback(() => {
openUrl(googleUrl!);
});
const handleBingClick = useLastCallback(() => {
openUrl(bingUrl!);
});
const handleAppleClick = useLastCallback(() => {
openUrl(appleUrl!);
});
const handleOsmClick = useLastCallback(() => {
openUrl(osmUrl!);
});
return (
<Modal
contentClassName={styles.root}
title={lang('OpenMapWith')}
isOpen={isOpen}
onClose={handleClose}
isSlim
>
<div className={styles.buttons}>
{(IS_IOS || IS_MAC_OS) && (
<Button fluid size="smaller" onClick={handleAppleClick}>
Apple Maps
</Button>
)}
<Button fluid size="smaller" onClick={handleGoogleClick}>
Google Maps
</Button>
<Button fluid size="smaller" onClick={handleBingClick}>
Bing Maps
</Button>
<Button fluid size="smaller" onClick={handleOsmClick}>
Open Street Maps
</Button>
</div>
<div className="dialog-buttons mt-2">
<Button className="confirm-dialog-button" isText onClick={handleClose}>
{lang('Cancel')}
</Button>
</div>
</Modal>
);
};
export default memo(OpenMapModal);

View File

@ -16,7 +16,7 @@ import useLang from '../../hooks/useLang';
import useMedia from '../../hooks/useMedia';
import Checkbox from '../ui/Checkbox';
import Skeleton from '../ui/Skeleton';
import Skeleton from '../ui/placeholder/Skeleton';
import SafeLink from '../common/SafeLink';
import ListItem from '../ui/ListItem';

View File

@ -38,6 +38,7 @@ import {
selectTabState,
selectTheme,
selectUser,
selectUserFullInfo,
selectUserStories,
} from '../../global/selectors';
import { captureEvents, SwipeDirection } from '../../util/captureEvents';
@ -610,8 +611,9 @@ export default memo(withGlobal<OwnProps>(
if (isUserId(chatId)) {
resolvedUserId = chatId;
user = selectUser(global, resolvedUserId);
const userFullInfo = selectUserFullInfo(global, chatId);
hasCommonChatsTab = user && !user.isSelf && !isUserBot(user);
hasStoriesTab = user?.isSelf || (user?.hasStories && !user.areStoriesHidden);
hasStoriesTab = user && (user.isSelf || (!user.areStoriesHidden && userFullInfo?.hasPinnedStories));
const userStories = hasStoriesTab ? selectUserStories(global, user!.id) : undefined;
storyIds = userStories?.pinnedIds;
storyByIds = userStories?.byId;

View File

@ -0,0 +1,55 @@
import React, { memo } from '../../lib/teact/teact';
import { getActions } from '../../global';
import type { ApiMediaArea } from '../../api/types';
import type { IDimensions } from '../../global/types';
import buildStyle from '../../util/buildStyle';
import buildClassName from '../../util/buildClassName';
import styles from './StoryViewer.module.scss';
type OwnProps = {
mediaAreas?: ApiMediaArea[];
mediaDimensions: IDimensions;
};
const MediaAreaOverlay = ({ mediaAreas, mediaDimensions }: OwnProps) => {
const { openMapModal } = getActions();
const handleMediaAreaClick = (mediaArea: ApiMediaArea) => {
if (mediaArea.geo) {
openMapModal({ geoPoint: mediaArea.geo });
}
};
return (
<div
className={buildClassName(styles.mediaAreaOverlay, styles.media)}
style={buildStyle(`aspect-ratio: ${mediaDimensions.width} / ${mediaDimensions.height}`)}
>
{mediaAreas?.map((mediaArea) => (
<div
className={styles.mediaArea}
style={prepareStyle(mediaArea)}
onClick={() => handleMediaAreaClick(mediaArea)}
/>
))}
</div>
);
};
function prepareStyle(mediaArea: ApiMediaArea) {
const {
x, y, width, height, rotation,
} = mediaArea.coordinates;
return buildStyle(
`left: ${x}%`,
`top: ${y}%`,
`width: ${width}%`,
`height: ${height}%`,
`transform: rotate(${rotation}deg) translate(-50%, -50%)`,
);
}
export default memo(MediaAreaOverlay);

View File

@ -0,0 +1,53 @@
.root {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
.close {
position: absolute;
top: 0.5rem;
left: 0.5rem;
}
.stealthIcon {
width: 5rem;
height: 5rem;
font-size: 3rem;
background-color: var(--color-primary);
border-radius: 50%;
display: grid;
place-items: center;
}
.title {
margin-top: 0.75rem;
font-weight: 500;
color: var(--color-text);
}
.description {
font-size: 0.875rem;
margin-top: 0.5rem;
text-align: center;
color: var(--color-text-secondary);
}
.listItem {
align-self: stretch;
}
.icon {
color: var(--color-primary) !important;
margin-right: 1rem !important;
}
.button {
margin-top: 1rem;
}
.subtitle {
line-height: 1.25rem !important;
}

View File

@ -0,0 +1,135 @@
import React, { memo, useEffect, useState } from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global';
import type { ApiStealthMode } from '../../api/types';
import { selectIsCurrentUserPremium, selectTabState } from '../../global/selectors';
import buildClassName from '../../util/buildClassName';
import { getServerTime } from '../../util/serverTime';
import useLang from '../../hooks/useLang';
import useLastCallback from '../../hooks/useLastCallback';
import Modal from '../ui/Modal';
import Button from '../ui/Button';
import ListItem from '../ui/ListItem';
import TextTimer from '../ui/TextTimer';
import styles from './StealthModeModal.module.scss';
type StateProps = {
isOpen?: boolean;
stealthMode?: ApiStealthMode;
isCurrentUserPremium?: boolean;
};
const StealthModeModal = ({ isOpen, stealthMode, isCurrentUserPremium } : StateProps) => {
const {
toggleStealthModal,
activateStealthMode,
showNotification,
openPremiumModal,
} = getActions();
const [isOnCooldown, setIsOnCooldown] = useState(false);
useEffect(() => {
if (!stealthMode) return;
const serverTime = getServerTime();
if (stealthMode.cooldownUntil && stealthMode.cooldownUntil > serverTime) {
setIsOnCooldown(true);
}
}, [stealthMode, isOpen]);
const lang = useLang();
const handleTimerEnds = useLastCallback(() => {
setIsOnCooldown(false);
});
const handleClose = useLastCallback(() => {
toggleStealthModal({ isOpen: false });
});
const handleActivate = useLastCallback(() => {
if (!isCurrentUserPremium) {
openPremiumModal({ initialSection: 'stories' });
return;
}
activateStealthMode();
showNotification({
title: lang('StealthModeOn'),
message: lang('StealthModeOnHint'),
});
toggleStealthModal({ isOpen: false });
});
return (
<Modal
className="component-theme-dark"
contentClassName={styles.root}
isOpen={isOpen}
isSlim
onClose={handleClose}
>
<Button
round
color="translucent"
size="smaller"
className={styles.close}
ariaLabel={lang('Close')}
onClick={handleClose}
>
<i className="icon icon-close" />
</Button>
<div className={styles.stealthIcon}>
<i className="icon icon-eye-closed-outline" />
</div>
<div className={styles.title}>{lang('StealthMode')}</div>
<div className={styles.description}>
{lang(isCurrentUserPremium ? 'StealthModeHint' : 'StealthModePremiumHint')}
</div>
<ListItem
className={buildClassName(styles.listItem, 'smaller-icon')}
multiline
inactive
leftElement={<i className={buildClassName('icon icon-stealth-past', styles.icon)} />}
>
<span className="title">{lang('HideRecentViews')}</span>
<span className={buildClassName('subtitle', styles.subtitle)}>{lang('HideRecentViewsDescription')}</span>
</ListItem>
<ListItem
className={buildClassName(styles.listItem, 'smaller-icon')}
multiline
inactive
leftElement={<i className={buildClassName('icon icon-stealth-future', styles.icon)} aria-hidden />}
>
<span className="title">{lang('HideNextViews')}</span>
<span className={buildClassName('subtitle', styles.subtitle)}>{lang('HideNextViewsDescription')}</span>
</ListItem>
<Button
className={styles.button}
size="smaller"
disabled={isOnCooldown}
isShiny={!isCurrentUserPremium}
withPremiumGradient={!isCurrentUserPremium}
onClick={handleActivate}
>
{!isCurrentUserPremium ? lang('UnlockStealthMode')
: isOnCooldown
? (<TextTimer langKey="AvailableIn" endsAt={stealthMode!.cooldownUntil!} onEnd={handleTimerEnds} />)
: lang('EnableStealthMode')}
</Button>
</Modal>
);
};
export default memo(withGlobal((global): StateProps => {
const tabState = selectTabState(global);
return {
isOpen: tabState.storyViewer?.isStealthModalOpen,
stealthMode: global.stories.stealthMode,
isCurrentUserPremium: selectIsCurrentUserPremium(global),
};
})(StealthModeModal));

View File

@ -4,20 +4,24 @@ import React, {
import { getActions, getGlobal, withGlobal } from '../../global';
import type { FC } from '../../lib/teact/teact';
import type { ApiStory, ApiTypeStory, ApiUser } from '../../api/types';
import type {
ApiStealthMode, ApiStory, ApiTypeStory, ApiUser,
} from '../../api/types';
import type { IDimensions } from '../../global/types';
import type { Signal } from '../../util/signals';
import { MAIN_THREAD_ID } from '../../api/types';
import { EDITABLE_STORY_INPUT_CSS_SELECTOR, EDITABLE_STORY_INPUT_ID } from '../../config';
import buildClassName from '../../util/buildClassName';
import renderText from '../common/helpers/renderText';
import { formatMediaDuration, formatRelativeTime } from '../../util/dateFormat';
import { getUserFirstOrLastName } from '../../global/helpers';
import { formatRelativeTime } from '../../util/dateFormat';
import { getServerTime } from '../../util/serverTime';
import {
selectChat, selectTabState, selectUserStory, selectUserStories, selectIsCurrentUserPremium,
} from '../../global/selectors';
import captureKeyboardListeners from '../../util/captureKeyboardListeners';
import download from '../../util/download';
import useAppLayout, { getIsMobile } from '../../hooks/useAppLayout';
import useLang from '../../hooks/useLang';
@ -33,7 +37,7 @@ import useLongPress from '../../hooks/useLongPress';
import useUnsupportedMedia from '../../hooks/media/useUnsupportedMedia';
import useCanvasBlur from '../../hooks/useCanvasBlur';
import useMediaTransition from '../../hooks/useMediaTransition';
import { useStoryProps } from './hooks/useStoryProps';
import useStoryProps from './hooks/useStoryProps';
import Button from '../ui/Button';
import Avatar from '../common/Avatar';
@ -42,9 +46,10 @@ import StoryProgress from './StoryProgress';
import Composer from '../common/Composer';
import MenuItem from '../ui/MenuItem';
import DropdownMenu from '../ui/DropdownMenu';
import Skeleton from '../ui/Skeleton';
import Skeleton from '../ui/placeholder/Skeleton';
import StoryCaption from './StoryCaption';
import AvatarList from '../common/AvatarList';
import MediaAreaOverlay from './MediaAreaOverlay';
import styles from './StoryViewer.module.scss';
@ -77,6 +82,7 @@ interface StateProps {
isChatExist?: boolean;
areChatSettingsLoaded?: boolean;
isCurrentUserPremium?: boolean;
stealthMode: ApiStealthMode;
}
const VIDEO_MIN_READY_STATE = 4;
@ -85,6 +91,8 @@ const SPACEBAR_CODE = 32;
const PRIMARY_VIDEO_MIME = 'video/mp4; codecs=hvc1.1.6.L63.00';
const SECONDARY_VIDEO_MIME = 'video/mp4; codecs=avc1.64001E';
const STEALTH_MODE_NOTIFICATION_DURATION = 4000;
function Story({
isSelf,
userId,
@ -104,6 +112,7 @@ function Story({
areChatSettingsLoaded,
getIsAnimating,
isCurrentUserPremium,
stealthMode,
onDelete,
onClose,
onReport,
@ -115,7 +124,7 @@ function Story({
openNextStory,
loadUserSkippedStories,
openForwardMenu,
openStorySeenBy,
openStoryViewModal,
copyStoryLink,
toggleStoryPinned,
openChat,
@ -123,7 +132,8 @@ function Story({
openStoryPrivacyEditor,
loadChatSettings,
fetchChat,
loadStorySeenBy,
loadStoryViews,
toggleStealthModal,
} = getActions();
const serverTime = getServerTime();
@ -137,6 +147,7 @@ function Story({
const [isCaptionExpanded, expandCaption, foldCaption] = useFlag(false);
const [isPausedBySpacebar, setIsPausedBySpacebar] = useState(false);
const [isPausedByLongPress, markIsPausedByLongPress, unmarkIsPausedByLongPress] = useFlag(false);
const [isDropdownMenuOpen, markDropdownMenuOpen, unmarkDropdownMenuOpen] = useFlag(false);
// eslint-disable-next-line no-null/no-null
const videoRef = useRef<HTMLVideoElement>(null);
const {
@ -151,10 +162,15 @@ function Story({
altMediaData,
hasFullData,
hasThumb,
} = useStoryProps(story);
mediaAreas,
canDownload,
downloadMediaData,
} = useStoryProps(story, isCurrentUserPremium, isDropdownMenuOpen);
const isLoadedStory = story && 'content' in story;
const isChangelog = userId === storyChangelogUserId;
const canPinToProfile = useCurrentOrPrev(
isSelf && isLoadedStory ? !story.isPinned : undefined,
true,
@ -164,12 +180,12 @@ function Story({
true,
);
const areViewsExpired = Boolean(
isSelf && !isCurrentUserPremium && isLoadedStory && (story!.date + viewersExpirePeriod) < getServerTime(),
isSelf && isLoadedStory && (story!.date + viewersExpirePeriod) < getServerTime(),
);
const canCopyLink = Boolean(
isLoadedStory
&& story.isPublic
&& userId !== storyChangelogUserId
&& !isChangelog
&& user?.usernames?.length,
);
@ -177,17 +193,24 @@ function Story({
isLoadedStory
&& story.isPublic
&& !story.noForwards
&& userId !== storyChangelogUserId
&& !isChangelog
&& !isCaptionExpanded,
);
const canShareOwn = Boolean(
isSelf
&& isLoadedStory
&& story.isPublic
&& !story.noForwards,
);
const canPlayStory = Boolean(
hasFullData && !shouldForcePause && isAppFocused && !isComposerHasFocus && !isCaptionExpanded
&& !isPausedBySpacebar && !isPausedByLongPress,
);
const {
shouldRender: shouldRenderSkeleton, transitionClassNames: skeletonTransitionClassNames,
} = useShowTransition((isVideo && !hasFullData) || (!isVideo && !previewBlobUrl));
} = useShowTransition(!hasFullData);
const {
transitionClassNames: mediaTransitionClassNames,
@ -199,7 +222,7 @@ function Story({
const {
shouldRender: shouldRenderComposer,
transitionClassNames: composerAppearanceAnimationClassNames,
} = useShowTransition(!isSelf);
} = useShowTransition(!isSelf && !isChangelog);
const {
shouldRender: shouldRenderCaptionBackdrop,
@ -255,6 +278,16 @@ function Story({
unmarkIsPausedByLongPress();
});
const handleDropdownMenuOpen = useLastCallback(() => {
markDropdownMenuOpen();
handlePauseStory();
});
const handleDropdownMenuClose = useLastCallback(() => {
unmarkDropdownMenuOpen();
handlePlayStory();
});
const {
onMouseDown: handleLongPressMouseDown,
onMouseUp: handleLongPressMouseUp,
@ -279,7 +312,7 @@ function Story({
if (!isSelf || isDeletedStory || areViewsExpired) return;
// Refresh recent viewers list each time
loadStorySeenBy({ storyId });
loadStoryViews({ storyId, isPreload: true });
}, [isDeletedStory, areViewsExpired, isSelf, storyId]);
useEffect(() => {
@ -394,8 +427,8 @@ function Story({
handlePauseStory();
});
const handleOpenStorySeenBy = useLastCallback(() => {
openStorySeenBy({ storyId });
const handleOpenStoryViewModal = useLastCallback(() => {
openStoryViewModal({ storyId });
});
const handleInfoPrivacyEdit = useLastCallback(() => {
@ -437,6 +470,25 @@ function Story({
setStoryViewerMuted({ isMuted: !isMuted });
});
const handleOpenStealthModal = useLastCallback(() => {
if (stealthMode.activeUntil && getServerTime() < stealthMode.activeUntil) {
const diff = stealthMode.activeUntil - getServerTime();
showNotification({
title: lang('StealthModeOn'),
message: lang('Story.ToastStealthModeActiveText', formatMediaDuration(diff)),
duration: STEALTH_MODE_NOTIFICATION_DURATION,
});
return;
}
toggleStealthModal({ isOpen: true });
});
const handleDownload = useLastCallback(() => {
if (!downloadMediaData) return;
download(downloadMediaData, `story-${userId}-${storyId}.${isVideo ? 'mp4' : 'jpg'}`);
});
useEffect(() => {
if (!isDeletedStory) return;
@ -457,7 +509,7 @@ function Story({
onClick={onTrigger}
ariaLabel={lang('AccDescrOpenMenu2')}
>
<i className="icon icon-more" aria-hidden />
<i className={buildClassName('icon icon-more', styles.topIcon)} aria-hidden />
</Button>
);
};
@ -571,6 +623,7 @@ function Story({
className={buildClassName(
'icon',
isMuted || noSound ? 'icon-speaker-muted-story' : 'icon-speaker-story',
styles.topIcon,
)}
aria-hidden
/>
@ -580,8 +633,8 @@ function Story({
className={buildClassName(styles.button, styles.buttonMenu)}
trigger={MenuButton}
positionX="right"
onOpen={handlePauseStory}
onClose={handlePlayStory}
onOpen={handleDropdownMenuOpen}
onClose={handleDropdownMenuClose}
>
{canCopyLink && <MenuItem icon="copy" onClick={handleCopyStoryLink}>{lang('CopyLink')}</MenuItem>}
{canPinToProfile && (
@ -590,8 +643,14 @@ function Story({
{canUnpinFromProfile && (
<MenuItem icon="delete" onClick={handleUnpinClick}>{lang('ArchiveStory')}</MenuItem>
)}
{canDownload && (
<MenuItem icon="download" disabled={!downloadMediaData} onClick={handleDownload}>
{lang('lng_media_download')}
</MenuItem>
)}
<MenuItem icon="eye-closed-outline" onClick={handleOpenStealthModal}>{lang('StealthMode')}</MenuItem>
{!isSelf && <MenuItem icon="flag" onClick={handleReportStoryClick}>{lang('lng_report_story')}</MenuItem>}
{isSelf && <MenuItem icon="delete" destructive onClick={handleDeleteStoryClick}>{lang('Delete')}</MenuItem>}
{!isSelf && <MenuItem icon="flag" onClick={handleReportStoryClick}>{lang('Report')}</MenuItem>}
</DropdownMenu>
</div>
</div>
@ -608,7 +667,7 @@ function Story({
}, [story]);
function renderRecentViewers() {
const { viewsCount } = story as ApiStory;
const { viewsCount, reactionsCount } = story as ApiStory;
if (!viewsCount) {
return (
@ -625,16 +684,23 @@ function Story({
styles.recentViewersInteractive,
appearanceAnimationClassNames,
)}
onClick={handleOpenStorySeenBy}
onClick={handleOpenStoryViewModal}
>
{!areViewsExpired && Boolean(recentViewers?.length) && (
<AvatarList
size="small"
peers={recentViewers}
className={styles.recentViewersAvatars}
/>
)}
<span className={styles.recentViewersCount}>{lang('Views', viewsCount, 'i')}</span>
<span>{lang('Views', viewsCount, 'i')}</span>
{Boolean(reactionsCount) && (
<span className={styles.reactionCount}>
<i className={buildClassName(styles.reactionCountHeart, 'icon icon-heart')} />
{reactionsCount}
</span>
)}
</div>
);
}
@ -662,11 +728,7 @@ function Story({
<img src={previewBlobUrl} alt="" className={buildClassName(styles.media, previewTransitionClassNames)} />
)}
{shouldRenderSkeleton && (
<Skeleton
width={dimensions.width}
height={dimensions.height}
className={buildClassName(skeletonTransitionClassNames, styles.skeleton)}
/>
<Skeleton className={buildClassName(skeletonTransitionClassNames, styles.fullSize)} />
)}
{!isVideo && fullMediaData && (
<img
@ -713,9 +775,22 @@ function Story({
/>
</>
)}
<MediaAreaOverlay mediaAreas={mediaAreas} mediaDimensions={dimensions} />
</div>
{isSelf && renderRecentViewers()}
{canShareOwn && (
<Button
className={styles.ownForward}
color="translucent"
size="smaller"
round
onClick={handleForwardClick}
ariaLabel={lang('Forward')}
>
<i className="icon icon-forward" aria-hidden />
</Button>
)}
{shouldRenderCaptionBackdrop && (
<div
tabIndex={0}
@ -725,12 +800,14 @@ function Story({
aria-label={lang('Close')}
/>
)}
{hasText && <div className={styles.captionGradient} />}
{hasText && (
<StoryCaption
key={`caption-${storyId}-${userId}`}
story={story as ApiStory}
isExpanded={isCaptionExpanded}
onExpand={expandCaption}
onFold={foldCaption}
className={appearanceAnimationClassNames}
/>
)}
@ -743,8 +820,8 @@ function Story({
isReady={!isSelf}
messageListType="thread"
isMobile={getIsMobile()}
editableInputCssSelector="#editable-story-input-text"
editableInputId="editable-story-input-text"
editableInputCssSelector={EDITABLE_STORY_INPUT_CSS_SELECTOR}
editableInputId={EDITABLE_STORY_INPUT_ID}
inputId="story-input-text"
className={buildClassName(styles.composer, composerAppearanceAnimationClassNames)}
inputPlaceholder={lang('ReplyPrivately')}
@ -765,16 +842,23 @@ export default memo(withGlobal<OwnProps>((global, {
const chat = selectChat(global, userId);
const tabState = selectTabState(global);
const {
storyViewer: { isMuted, storyIdSeenBy, isPrivacyModalOpen },
storyViewer: {
isMuted,
viewModal,
isPrivacyModalOpen,
isStealthModalOpen,
},
forwardMessages: { storyId: forwardedStoryId },
premiumModal,
safeLinkModalUrl,
mapModal,
} = tabState;
const { isOpen: isPremiumModalOpen } = premiumModal || {};
const { orderedIds, pinnedIds, archiveIds } = selectUserStories(global, userId) || {};
const story = selectUserStory(global, userId, storyId);
const shouldForcePause = Boolean(
storyIdSeenBy || forwardedStoryId || tabState.reactionPicker?.storyId || isReportModalOpen || isPrivacyModalOpen
|| isPremiumModalOpen || isDeleteModalOpen,
viewModal || forwardedStoryId || tabState.reactionPicker?.storyId || isReportModalOpen || isPrivacyModalOpen
|| isPremiumModalOpen || isDeleteModalOpen || safeLinkModalUrl || isStealthModalOpen || mapModal,
);
return {
@ -789,5 +873,6 @@ export default memo(withGlobal<OwnProps>((global, {
viewersExpirePeriod: appConfig!.storyExpirePeriod + appConfig!.storyViewersExpirePeriod,
isChatExist: Boolean(chat),
areChatSettingsLoaded: Boolean(chat?.settings),
stealthMode: global.stories.stealthMode,
};
})(Story));

View File

@ -1,15 +1,17 @@
import React, {
memo, useEffect, useRef, useState,
} from '../../lib/teact/teact';
import { requestMutation } from '../../lib/fasterdom/fasterdom';
import { addExtraClass, removeExtraClass } from '../../lib/teact/teact-dom';
import type { ApiStory } from '../../api/types';
import buildClassName from '../../util/buildClassName';
import { requestMutation } from '../../lib/fasterdom/fasterdom';
import { addExtraClass, removeExtraClass } from '../../lib/teact/teact-dom';
import { REM } from '../common/helpers/mediaDimensions';
import usePrevDuringAnimation from '../../hooks/usePrevDuringAnimation';
import useLang from '../../hooks/useLang';
import useShowTransition from '../../hooks/useShowTransition';
import MessageText from '../common/MessageText';
@ -18,34 +20,39 @@ import styles from './StoryViewer.module.scss';
interface OwnProps {
story: ApiStory;
isExpanded: boolean;
onExpand: NoneToVoidFunction;
className?: string;
onExpand: NoneToVoidFunction;
onFold?: NoneToVoidFunction;
}
const EXPAND_ANIMATION_DURATION_MS = 400;
const OVERFLOW_THRESHOLD_PX = 4;
const OVERFLOW_THRESHOLD_PX = 5.75 * REM;
function StoryCaption({
story, isExpanded, className, onExpand,
story, isExpanded, className, onExpand, onFold,
}: OwnProps) {
const lang = useLang();
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const contentRef = useRef<HTMLDivElement>(null);
const [hasOverflow, setHasOverflow] = useState<boolean>(false);
const [height, setHeight] = useState<number>(0);
// eslint-disable-next-line no-null/no-null
const showMoreButtonRef = useRef<HTMLDivElement>(null);
const caption = story.content.text;
const [hasOverflow, setHasOverflow] = useState(false);
const prevIsExpanded = usePrevDuringAnimation(isExpanded || undefined, EXPAND_ANIMATION_DURATION_MS);
const isInExpandedState = isExpanded || prevIsExpanded;
useEffect(() => {
if (!ref.current) {
return;
}
const { scrollHeight, clientHeight } = ref.current;
setHasOverflow(scrollHeight - clientHeight > OVERFLOW_THRESHOLD_PX);
setHeight(scrollHeight - clientHeight);
}, []);
const { clientHeight } = ref.current;
setHasOverflow(clientHeight > OVERFLOW_THRESHOLD_PX);
}, [caption]);
useEffect(() => {
requestMutation(() => {
@ -59,14 +66,38 @@ function StoryCaption({
removeExtraClass(contentRef.current, styles.animate);
}
});
}, [height, isExpanded]);
}, [isExpanded]);
const canExpand = hasOverflow && !isInExpandedState;
const { shouldRender: shouldRenderShowMore, transitionClassNames } = useShowTransition(
canExpand, undefined, true, 'slow', true,
);
useEffect(() => {
if (!showMoreButtonRef.current || !contentRef.current) {
return;
}
const button = showMoreButtonRef.current;
const container = contentRef.current;
const { offsetWidth } = button;
requestMutation(() => {
container.style.setProperty('--expand-button-width', `${offsetWidth}px`);
});
}, [canExpand]);
useEffect(() => {
if (!isExpanded) {
ref.current?.scrollTo({ top: 0 });
}
}, [isExpanded]);
const canExpand = hasOverflow && !isExpanded;
const fullClassName = buildClassName(
styles.captionContent,
hasOverflow && !isExpanded && styles.hasOverflow,
(isExpanded || prevIsExpanded) && styles.expanded,
canExpand && styles.captionInteractive,
isInExpandedState && styles.expanded,
shouldRenderShowMore && styles.withShowMore,
);
return (
@ -75,22 +106,28 @@ function StoryCaption({
ref={contentRef}
className={fullClassName}
role={canExpand ? 'button' : undefined}
style={`--scroll-height: ${isExpanded ? height : 0}px;`}
onClick={canExpand ? () => onExpand() : undefined}
onClick={canExpand ? onExpand : onFold}
>
<div ref={ref} className={buildClassName(styles.captionInner, 'allow-selection', 'custom-scroll')}>
{hasOverflow && (
<div className={buildClassName(styles.captionExpand, isExpanded && styles.hidden)}>
{lang('Story.CaptionShowMore')}
</div>
)}
<div
ref={ref}
className={buildClassName(styles.captionInner, 'allow-selection', 'custom-scroll')}
>
<MessageText
messageOrStory={story}
withTranslucentThumbs
forcePlayback
/>
</div>
</div>
{shouldRenderShowMore && (
<div
ref={showMoreButtonRef}
className={buildClassName(styles.captionShowMore, transitionClassNames)}
onClick={onExpand}
>
{lang('Story.CaptionShowMore')}
</div>
)}
</div>
);
}

View File

@ -64,7 +64,6 @@
.option {
display: flex;
align-items: center;
width: 100%;
position: relative;
overflow: hidden;
margin-bottom: 0;
@ -147,11 +146,16 @@
}
.action {
width: 100%;
color: #8774E1;
cursor: var(--custom-cursor, pointer);
opacity: 0.8;
transition: opacity 200ms;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
> :global(.icon) {
font-size: 0.875rem;
line-height: 1;
@ -170,6 +174,7 @@
display: flex;
flex-direction: column;
align-items: flex-start;
overflow: hidden;
}
.footer {

View File

@ -38,7 +38,7 @@ interface StateProps {
currentUserId: string;
}
type PrivacyAction = 'blockUserIds' | 'closeFriends' | 'allowUserIds';
type PrivacyAction = 'blockUserIds' | 'closeFriends' | 'blockContactUserIds' | 'allowUserIds';
interface PrivacyOption {
name: string;
@ -53,13 +53,13 @@ const OPTIONS: PrivacyOption[] = [{
value: 'everybody',
color: ['#50ABFF', '#007AFF'],
icon: 'channel-filled',
actions: undefined,
actions: 'blockUserIds',
}, {
name: 'StoryPrivacyOptionContacts',
value: 'contacts',
color: ['#C36EFF', '#8B60FA'],
icon: 'user-filled',
actions: 'blockUserIds',
actions: 'blockContactUserIds',
}, {
name: 'StoryPrivacyOptionCloseFriends',
value: 'closeFriends',
@ -82,7 +82,13 @@ enum Screens {
}
function StorySettings({
isOpen, story, visibility, contactListIds, usersById, currentUserId, onClose,
isOpen,
story,
visibility,
contactListIds,
usersById,
currentUserId,
onClose,
}: OwnProps & StateProps) {
const { editStoryPrivacy, toggleStoryPinned } = getActions();
@ -91,6 +97,7 @@ function StorySettings({
const [privacy, setPrivacy] = useState<ApiPrivacySettings | undefined>(visibility);
const [isPinned, setIsPinned] = useState(story?.isPinned);
const [activeKey, setActiveKey] = useState<Screens>(Screens.privacy);
const [editingBlockingCategory, setEditingBlockingCategory] = useState<PrivacyVisibility>('everybody');
const isBackButton = activeKey !== Screens.privacy;
const closeFriendIds = useMemo(() => {
@ -107,6 +114,11 @@ function StorySettings({
return undefined;
}, [activeKey, currentUserId, privacy?.allowUserIds]);
const selectedBlockedIds = useMemo(() => {
if (editingBlockingCategory !== privacy?.visibility) return [];
return privacy?.blockUserIds || [];
}, [editingBlockingCategory, privacy?.blockUserIds, privacy?.visibility]);
const handleAllowUserIdsChange = useLastCallback((newIds: string[]) => {
setPrivacy({
...privacy!,
@ -118,6 +130,7 @@ function StorySettings({
setPrivacy({
...privacy!,
blockUserIds: newIds,
visibility: editingBlockingCategory,
});
});
@ -160,6 +173,12 @@ function StorySettings({
break;
case 'blockUserIds':
setActiveKey(Screens.denyList);
setEditingBlockingCategory('everybody');
break;
case 'blockContactUserIds':
setActiveKey(Screens.denyList);
setEditingBlockingCategory('contacts');
break;
}
}
@ -193,13 +212,14 @@ function StorySettings({
return lang('StoryPrivacyOptionPeople', closeFriendIds.length, 'i');
}
if (action === 'blockUserIds') {
if (!privacy?.blockUserIds || privacy.blockUserIds.length === 0) {
if ((action === 'blockUserIds' && privacy?.visibility === 'everybody')
|| (action === 'blockContactUserIds' && privacy?.visibility === 'contacts')) {
if (!privacy?.blockUserIds?.length) {
return lang('StoryPrivacyOptionContactsDetail');
}
if (privacy.blockUserIds.length === 1) {
return lang('StoryPrivacyOptionExcludePerson', getUserFullName(usersById[closeFriendIds[0]]));
return lang('StoryPrivacyOptionExcludePerson', getUserFullName(usersById[privacy.blockUserIds[0]]));
}
return lang('StoryPrivacyOptionExcludePeople', privacy.blockUserIds.length, 'i');
@ -254,7 +274,7 @@ function StorySettings({
contactListIds={contactListIds}
currentUserId={currentUserId}
usersById={usersById}
selectedIds={privacy?.blockUserIds}
selectedIds={selectedBlockedIds}
onSelect={handleDenyUserIdsChange}
/>
);
@ -309,7 +329,7 @@ function StorySettings({
tabIndex={0}
role="button"
className={styles.action}
aria-label={lang('Change List')}
aria-label={lang('Edit')}
onClick={(e) => { handleActionClick(e, option.actions!); }}
>
<span className={styles.actionInner}>{renderActionName(option.actions)}</span>

View File

@ -16,7 +16,8 @@ import useLastCallback from '../../hooks/useLastCallback';
import usePrevious from '../../hooks/usePrevious';
import useCurrentOrPrev from '../../hooks/useCurrentOrPrev';
import useSignal from '../../hooks/useSignal';
import { useSlideSizes } from './hooks/useSlideSizes';
import useSlideSizes from './hooks/useSlideSizes';
import useHistoryBack from '../../hooks/useHistoryBack';
import Story from './Story';
import StoryPreview from './StoryPreview';
@ -24,6 +25,7 @@ import StoryPreview from './StoryPreview';
import styles from './StoryViewer.module.scss';
interface OwnProps {
isOpen?: boolean;
isReportModalOpen?: boolean;
isDeleteModalOpen?: boolean;
onDelete: (storyId: number) => void;
@ -49,8 +51,20 @@ const ANIMATION_TO_ACTIVE_SCALE = '3';
const ANIMATION_FROM_ACTIVE_SCALE = `${FROM_ACTIVE_SCALE_VALUE}`;
function StorySlides({
userIds, currentUserId, currentStoryId, isSingleUser, isSingleStory, isPrivate, isArchive, byUserId,
isReportModalOpen, isDeleteModalOpen, onDelete, onClose, onReport,
userIds,
currentUserId,
currentStoryId,
isOpen,
isSingleUser,
isSingleStory,
isPrivate,
isArchive,
byUserId,
isReportModalOpen,
isDeleteModalOpen,
onDelete,
onClose,
onReport,
}: OwnProps & StateProps) {
const [renderingUserId, setRenderingUserId] = useState(currentUserId);
const [renderingStoryId, setRenderingStoryId] = useState(currentStoryId);
@ -64,6 +78,12 @@ function StorySlides({
const rendersRef = useRef<Record<string, { current: HTMLDivElement | null }>>({});
const [getIsAnimating, setIsAnimating] = useSignal(false);
useHistoryBack({
isActive: isOpen,
onBack: onClose,
shouldBeReplaced: true,
});
function setRef(ref: HTMLDivElement | null, userId: string) {
if (!ref) {
return;

View File

@ -0,0 +1,152 @@
import React, { memo, useMemo } from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global';
import type { ApiAvailableReaction, ApiStoryView, ApiUser } from '../../api/types';
import { selectUser } from '../../global/selectors';
import buildClassName from '../../util/buildClassName';
import { REM } from '../common/helpers/mediaDimensions';
import { formatDateAtTime } from '../../util/dateFormat';
import { getUserFullName } from '../../global/helpers';
import useLastCallback from '../../hooks/useLastCallback';
import useLang from '../../hooks/useLang';
import ListItem from '../ui/ListItem';
import ReactionStaticEmoji from '../common/ReactionStaticEmoji';
import PrivateChatInfo from '../common/PrivateChatInfo';
import styles from './StoryViewModal.module.scss';
type OwnProps = {
storyView: ApiStoryView;
};
type StateProps = {
user?: ApiUser;
availableReactions?: ApiAvailableReaction[];
};
const CLOSE_ANIMATION_DURATION = 100;
const DEFAULT_REACTION_SIZE = 1.5 * REM;
const StoryView = ({
storyView,
user,
availableReactions,
}: OwnProps & StateProps) => {
const {
openChat, closeStoryViewer, unblockUser, blockUser, deleteContact, updateStoryView,
} = getActions();
const lang = useLang();
const handleClick = useLastCallback(() => {
closeStoryViewer();
setTimeout(() => {
openChat({ id: storyView.userId });
}, CLOSE_ANIMATION_DURATION);
});
const contextActions = useMemo(() => {
const { userId, areStoriesBlocked, isUserBlocked } = storyView;
const { isContact } = user || {};
const fullName = getUserFullName(user);
const actions = [];
if (!isUserBlocked) {
if (!areStoriesBlocked) {
actions.push({
handler: () => {
blockUser({ userId, isOnlyStories: true });
updateStoryView({ userId, areStoriesBlocked: true });
},
title: lang('StoryHideFrom', fullName),
icon: 'hand-stop',
});
} else {
actions.push({
handler: () => {
unblockUser({ userId, isOnlyStories: true });
updateStoryView({ userId, areStoriesBlocked: false });
},
title: lang('StoryShowBackTo', fullName),
icon: 'play-story',
});
}
}
if (isContact) {
actions.push({
handler: () => {
deleteContact({ userId });
},
title: lang('DeleteContact'),
icon: 'delete-user',
destructive: true,
});
} else {
actions.push({
handler: () => {
if (isUserBlocked) {
unblockUser({ userId });
updateStoryView({ userId, isUserBlocked: false });
} else {
blockUser({ userId });
updateStoryView({ userId, isUserBlocked: true });
}
},
title: lang(isUserBlocked ? 'Unblock' : 'BlockUser'),
icon: isUserBlocked ? 'user' : 'delete-user',
destructive: !isUserBlocked,
});
}
return actions;
}, [lang, storyView, user]);
return (
<ListItem
key={storyView.userId}
className={buildClassName(
'chat-item-clickable small-icon',
styles.opacityFadeIn,
(storyView.isUserBlocked || storyView.areStoriesBlocked) && styles.blocked,
)}
// eslint-disable-next-line react/jsx-no-bind
onClick={() => handleClick()}
rightElement={storyView.reaction ? (
<ReactionStaticEmoji
reaction={storyView.reaction}
className={styles.viewReaction}
size={DEFAULT_REACTION_SIZE}
availableReactions={availableReactions}
withIconHeart
/>
) : undefined}
contextActions={contextActions}
withPortalForMenu
menuBubbleClassName={styles.menuBubble}
>
<PrivateChatInfo
userId={storyView.userId}
noStatusOrTyping
status={formatDateAtTime(lang, storyView.date * 1000)}
statusIcon="icon-message-read"
withStory
forceShowSelf
/>
</ListItem>
);
};
export default memo(withGlobal<OwnProps>((global, { storyView }) => {
const user = selectUser(global, storyView.userId);
return {
user,
availableReactions: global.availableReactions,
};
})(StoryView));

View File

@ -0,0 +1,125 @@
.views-list {
display: flex;
flex-direction: column;
padding-bottom: 0 !important;
height: 35rem;
@supports (height: min(80vh, 35rem)) {
height: min(80vh, 35rem);
}
}
.views-list-loading {
justify-content: center;
}
.info {
color: var(--color-text-secondary);
text-align: center;
}
.centeredInfo {
width: 100%;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.view-reaction {
--custom-emoji-size: 1.5rem;
margin-inline-start: 0.25rem;
}
.header {
display: flex;
flex-direction: column;
}
.content {
min-height: 17rem;
overflow-y: scroll;
position: relative;
flex-grow: 1;
}
.top-button {
height: 2rem !important;
line-height: 1;
}
.contact-filter {
display: flex;
gap: 0.5rem;
}
.selected {
pointer-events: none;
color: #FFFFFF !important;
background-color: var(--color-interactive-element-hover) !important;
}
.sort {
position: absolute;
top: 1rem;
right: 1rem;
}
.sort-button {
padding-inline: 0.5rem !important;
border-radius: 1rem !important;
}
.icon-sort {
font-size: 1.25rem;
color: #FFFFFF;
margin-inline-end: 0.25rem;
}
.icon-down {
font-size: 1rem;
color: #FFFFFF;
}
.search {
margin-block: 0.5rem;
}
.bottom-info {
font-size: 0.875rem;
margin: 0.25rem 0 0.5rem;
}
.scrolled {
border-bottom: 0.0625rem solid var(--color-borders);
}
.footer {
border-top: 0.0625rem solid var(--color-borders);
}
.close {
margin-block: 0.25rem;
}
.opacity-fade-in {
animation: fadeIn 0.2s ease-in;
}
.blocked {
opacity: 0.5;
}
.check {
margin: 0 0 0 auto !important;
}
.menuBubble {
max-width: min(25rem, 80vw);
}
@keyframes fadeIn {
from {
opacity: 0;
}
}

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