Stories: New features and fixes (#3773)
This commit is contained in:
parent
25e6a8f72f
commit
a7c7c8d95c
@ -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';
|
||||
|
||||
@ -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, (
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
644
src/api/gramjs/apiBuilders/messageContent.ts
Normal file
644
src/api/gramjs/apiBuilders/messageContent.ts
Normal 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
@ -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';
|
||||
|
||||
@ -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) {
|
||||
|
||||
103
src/api/gramjs/apiBuilders/reactions.ts
Normal file
103
src/api/gramjs/apiBuilders/reactions.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
144
src/api/gramjs/apiBuilders/stories.ts
Normal file
144
src/api/gramjs/apiBuilders/stories.ts
Normal 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)]);
|
||||
}
|
||||
@ -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({
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -212,7 +212,7 @@ export interface ApiPaymentCredentials {
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface ApiGeoPoint {
|
||||
export interface ApiGeoPoint {
|
||||
long: number;
|
||||
lat: number;
|
||||
accessHash: string;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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.
@ -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';
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -81,8 +81,7 @@ const OutlinedMicrophoneIcon: FC<OwnProps> = ({
|
||||
size={28}
|
||||
color={microphoneColor}
|
||||
className={className}
|
||||
forceOnHeavyAnimation
|
||||
forceInBackground
|
||||
forceAlways
|
||||
nonInteractive
|
||||
/>
|
||||
);
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
.root {
|
||||
display: flex;
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
|
||||
|
||||
@ -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)}
|
||||
</>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 [
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
:global(.subtitle) {
|
||||
.subtitle {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -34,7 +34,7 @@
|
||||
.main {
|
||||
padding: 1rem 0.5rem;
|
||||
height: 100%;
|
||||
overflow: scroll;
|
||||
overflow-y: scroll;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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));
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -108,7 +108,7 @@ const EmojiInteractionAnimation: FC<OwnProps & StateProps> = ({
|
||||
tgsUrl={effectTgsUrl}
|
||||
play
|
||||
quality={IS_ANDROID ? 0.5 : undefined}
|
||||
forceOnHeavyAnimation
|
||||
forceAlways
|
||||
noLoop
|
||||
onLoad={startPlaying}
|
||||
/>
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
/>
|
||||
|
||||
@ -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),
|
||||
};
|
||||
},
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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="" />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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));
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)!);
|
||||
}
|
||||
|
||||
17
src/components/modals/mapModal/MapModal.async.tsx
Normal file
17
src/components/modals/mapModal/MapModal.async.tsx
Normal 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;
|
||||
10
src/components/modals/mapModal/MapModal.module.scss
Normal file
10
src/components/modals/mapModal/MapModal.module.scss
Normal file
@ -0,0 +1,10 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
100
src/components/modals/mapModal/MapModal.tsx
Normal file
100
src/components/modals/mapModal/MapModal.tsx
Normal 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);
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
55
src/components/story/MediaAreaOverlay.tsx
Normal file
55
src/components/story/MediaAreaOverlay.tsx
Normal 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);
|
||||
53
src/components/story/StealthModeModal.module.scss
Normal file
53
src/components/story/StealthModeModal.module.scss
Normal 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;
|
||||
}
|
||||
135
src/components/story/StealthModeModal.tsx
Normal file
135
src/components/story/StealthModeModal.tsx
Normal 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));
|
||||
@ -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));
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
152
src/components/story/StoryView.tsx
Normal file
152
src/components/story/StoryView.tsx
Normal 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));
|
||||
125
src/components/story/StoryViewModal.module.scss
Normal file
125
src/components/story/StoryViewModal.module.scss
Normal 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
Loading…
x
Reference in New Issue
Block a user