Profile: New Design (#1051)

This commit is contained in:
Alexander Zinchuk 2021-05-01 04:03:01 +03:00
parent 852d195842
commit 20d2656261
56 changed files with 1077 additions and 363 deletions

View File

@ -1,7 +1,9 @@
import { Api as GramJs } from '../../../lib/gramjs';
import { strippedPhotoToJpg } from '../../../lib/gramjs/Utils';
import { ApiThumbnail } from '../../types';
import {
ApiPhoto, ApiPhotoSize, ApiThumbnail,
} from '../../types';
import { bytesToDataUri } from './helpers';
import { pathBytesToSvg } from './pathBytesToSvg';
@ -59,3 +61,25 @@ export function buildApiThumbnailFromPath(
height: h,
};
}
export function buildApiPhoto(photo: GramJs.Photo): ApiPhoto {
const sizes = photo.sizes
.filter((s: any): s is GramJs.PhotoSize => s instanceof GramJs.PhotoSize)
.map(buildApiPhotoSize);
return {
id: String(photo.id),
thumbnail: buildApiThumbnailFromStripped(photo.sizes),
sizes,
};
}
export function buildApiPhotoSize(photoSize: GramJs.PhotoSize): ApiPhotoSize {
const { w, h, type } = photoSize;
return {
width: w,
height: h,
type: type as ('m' | 'x' | 'y'),
};
}

View File

@ -3,7 +3,6 @@ import {
ApiMessage,
ApiMessageForwardInfo,
ApiPhoto,
ApiPhotoSize,
ApiSticker,
ApiVideo,
ApiVoice,
@ -28,12 +27,14 @@ import { DELETED_COMMENTS_CHANNEL_ID, LOCAL_MESSAGE_ID_BASE, SERVICE_NOTIFICATIO
import { pick } from '../../../util/iteratees';
import { getApiChatIdFromMtpPeer } from './chats';
import { buildStickerFromDocument } from './symbols';
import { buildApiThumbnailFromStripped } from './common';
import { buildApiPhoto, buildApiThumbnailFromStripped, buildApiPhotoSize } from './common';
import { interpolateArray } from '../../../util/waveform';
import { getCurrencySign } from '../../../components/middle/helpers/getCurrencySign';
import { buildPeer } from '../gramjsBuilders';
import { addPhotoToLocalDb, resolveMessageApiChatId } from '../helpers';
const LOCAL_VIDEO_TEMP_ID = 'temp';
const LOCAL_IMAGE_UPLOADING_TEMP_ID = 'temp';
const LOCAL_VIDEO_UPLOADING_TEMP_ID = 'temp';
const INPUT_WAVEFORM_LENGTH = 63;
let localMessageCounter = LOCAL_MESSAGE_ID_BASE;
@ -54,14 +55,6 @@ export function buildApiMessage(mtpMessage: GramJs.TypeMessage): ApiMessage | un
return buildApiMessageWithChatId(chatId, mtpMessage);
}
export function resolveMessageApiChatId(mtpMessage: GramJs.TypeMessage) {
if (!(mtpMessage instanceof GramJs.Message || mtpMessage instanceof GramJs.MessageService)) {
return undefined;
}
return getApiChatIdFromMtpPeer(mtpMessage.peerId);
}
export function buildApiMessageFromShort(mtpMessage: GramJs.UpdateShortMessage): ApiMessage {
const chatId = getApiChatIdFromMtpPeer({ userId: mtpMessage.userId } as GramJs.TypePeer);
@ -271,24 +264,7 @@ function buildPhoto(media: GramJs.TypeMessageMedia): ApiPhoto | undefined {
return undefined;
}
const sizes = media.photo.sizes
.filter((s: any): s is GramJs.PhotoSize => s instanceof GramJs.PhotoSize)
.map(buildApiPhotoSize);
return {
thumbnail: buildApiThumbnailFromStripped(media.photo.sizes),
sizes,
};
}
function buildApiPhotoSize(photoSize: GramJs.PhotoSize): ApiPhotoSize {
const { w, h, type } = photoSize;
return {
width: w,
height: h,
type: type as ('m' | 'x' | 'y'),
};
return buildApiPhoto(media.photo);
}
export function buildVideoFromDocument(document: GramJs.Document): ApiVideo | undefined {
@ -541,6 +517,7 @@ export function buildWebPage(media: GramJs.TypeMessageMedia): ApiWebPage | undef
]),
photo: photo && photo instanceof GramJs.Photo
? {
id: String(photo.id),
thumbnail: buildApiThumbnailFromStripped(photo.sizes),
sizes: photo.sizes
.filter((s: any): s is GramJs.PhotoSize => s instanceof GramJs.PhotoSize)
@ -564,6 +541,7 @@ function buildAction(
let text = '';
let type: ApiAction['type'] = 'other';
let photo: ApiPhoto | undefined;
const targetUserId = 'users' in action
// Api returns array of userIds, but no action currently has multiple users in it
@ -625,11 +603,17 @@ function buildAction(
text = '%ACTION_NOT_IMPLEMENTED%';
}
if ('photo' in action && action.photo instanceof GramJs.Photo) {
addPhotoToLocalDb(action.photo);
photo = buildApiPhoto(action.photo);
}
return {
text,
type,
targetUserId,
targetChatId,
photo, // TODO Only used internally now, will be used for the UI in future
};
}
@ -814,6 +798,7 @@ function buildUploadingMedia(
if (mimeType.startsWith('image/')) {
return {
photo: {
id: LOCAL_IMAGE_UPLOADING_TEMP_ID,
sizes: [],
thumbnail: { width, height, dataUri: '' }, // Used only for dimensions
blobUrl,
@ -822,7 +807,7 @@ function buildUploadingMedia(
} else {
return {
video: {
id: LOCAL_VIDEO_TEMP_ID,
id: LOCAL_VIDEO_UPLOADING_TEMP_ID,
mimeType,
duration: duration || 0,
fileName,

View File

@ -297,6 +297,10 @@ export function isMessageWithMedia(message: GramJs.Message | GramJs.UpdateServic
);
}
export function isServiceMessageWithMedia(message: GramJs.MessageService) {
return 'photo' in message.action && message.action.photo instanceof GramJs.Photo;
}
export function buildChatPhotoForLocalDb(photo: GramJs.TypePhoto) {
if (photo instanceof GramJs.PhotoEmpty) {
return new GramJs.ChatPhotoEmpty();

View File

@ -1,14 +1,34 @@
import { Api as GramJs } from '../../lib/gramjs';
import localDb from './localDb';
import { resolveMessageApiChatId } from './apiBuilders/messages';
import { getApiChatIdFromMtpPeer } from './apiBuilders/chats';
export function addMessageToLocalDb(message: GramJs.Message) {
export function resolveMessageApiChatId(mtpMessage: GramJs.TypeMessage) {
if (!(mtpMessage instanceof GramJs.Message || mtpMessage instanceof GramJs.MessageService)) {
return undefined;
}
return getApiChatIdFromMtpPeer(mtpMessage.peerId);
}
export function addMessageToLocalDb(message: GramJs.Message | GramJs.MessageService) {
const messageFullId = `${resolveMessageApiChatId(message)}-${message.id}`;
localDb.messages[messageFullId] = message;
if (
message.media instanceof GramJs.MessageMediaDocument
message instanceof GramJs.Message
&& message.media instanceof GramJs.MessageMediaDocument
&& message.media.document instanceof GramJs.Document
) {
localDb.documents[String(message.media.document.id)] = message.media.document;
}
if (message instanceof GramJs.MessageService && 'photo' in message.action) {
addPhotoToLocalDb(message.action.photo);
}
}
export function addPhotoToLocalDb(photo: GramJs.TypePhoto) {
if (photo instanceof GramJs.Photo) {
localDb.photos[String(photo.id)] = photo;
}
}

View File

@ -6,9 +6,10 @@ interface LocalDb {
// Used for loading avatars and media through in-memory Gram JS instances.
chats: Record<number, GramJs.Chat | GramJs.Channel>;
users: Record<number, GramJs.User>;
messages: Record<string, GramJs.Message>;
messages: Record<string, GramJs.Message | GramJs.MessageService>;
documents: Record<string, GramJs.Document>;
stickerSets: Record<string, GramJs.StickerSet>;
photos: Record<string, GramJs.Photo>;
}
export default {
@ -18,4 +19,5 @@ export default {
messages: {},
documents: {},
stickerSets: {},
photos: {},
} as LocalDb;

View File

@ -26,7 +26,7 @@ export {
export {
fetchFullUser, fetchNearestCountry,
fetchTopUsers, fetchContactList, fetchUsers,
updateContact, deleteUser,
updateContact, deleteUser, fetchProfilePhotos,
} from './users';
export {

View File

@ -17,7 +17,7 @@ import { getEntityTypeById } from '../gramjsBuilders';
import { blobToDataUri } from '../../../util/files';
import * as cacheApi from '../../../util/cacheApi';
type EntityType = 'msg' | 'sticker' | 'wallpaper' | 'gif' | 'channel' | 'chat' | 'user' | 'stickerSet';
type EntityType = 'msg' | 'sticker' | 'wallpaper' | 'gif' | 'channel' | 'chat' | 'user' | 'photo' | 'stickerSet';
export default async function downloadMedia(
{
@ -70,7 +70,8 @@ async function download(
end?: number,
mediaFormat?: ApiMediaFormat,
) {
const mediaMatch = url.match(/(avatar|profile|msg|stickerSet|sticker|wallpaper|gif|file)([-\d\w./]+)(\?size=\w+)?/);
// eslint-disable-next-line max-len
const mediaMatch = url.match(/(avatar|profile|photo|msg|stickerSet|sticker|wallpaper|gif|file)([-\d\w./]+)(\?size=\w+)?/);
if (!mediaMatch) {
return undefined;
}
@ -89,7 +90,7 @@ async function download(
let entityId: string | number = mediaMatch[2];
const sizeType = mediaMatch[3] ? mediaMatch[3].replace('?size=', '') : undefined;
let entity: (
GramJs.User | GramJs.Chat | GramJs.Channel |
GramJs.User | GramJs.Chat | GramJs.Channel | GramJs.Photo |
GramJs.Message | GramJs.Document | GramJs.StickerSet | undefined
);
@ -97,7 +98,7 @@ async function download(
entityType = getEntityTypeById(Number(entityId));
entityId = Math.abs(Number(entityId));
} else {
entityType = mediaMatch[1] as 'msg' | 'sticker' | 'wallpaper' | 'gif' | 'stickerSet';
entityType = mediaMatch[1] as 'msg' | 'sticker' | 'wallpaper' | 'gif' | 'stickerSet' | 'photo';
}
switch (entityType) {
@ -116,6 +117,9 @@ async function download(
case 'wallpaper':
entity = localDb.documents[entityId as string];
break;
case 'photo':
entity = localDb.photos[entityId as string];
break;
case 'stickerSet':
entity = localDb.stickerSets[entityId as string];
break;
@ -125,7 +129,7 @@ async function download(
return undefined;
}
if (entityType === 'msg' || entityType === 'sticker' || entityType === 'gif' || entityType === 'wallpaper') {
if (['msg', 'sticker', 'gif', 'wallpaper', 'photo'].includes(entityType)) {
if (mediaFormat === ApiMediaFormat.Stream) {
onProgress!.acceptsBuffer = true;
}
@ -141,6 +145,8 @@ async function download(
if (entity.media instanceof GramJs.MessageMediaDocument && entity.media.document instanceof GramJs.Document) {
fullSize = entity.media.document.size;
}
} else if (entity instanceof GramJs.Photo) {
mimeType = 'image/jpeg';
} else if (entityType === 'sticker' && sizeType) {
mimeType = 'image/webp';
} else {

View File

@ -24,7 +24,6 @@ import {
buildLocalMessage,
buildWebPage,
buildForwardedMessage,
resolveMessageApiChatId,
} from '../apiBuilders/messages';
import { buildApiUser } from '../apiBuilders/users';
import {
@ -36,12 +35,13 @@ import {
buildInputPoll,
buildMtpMessageEntity,
isMessageWithMedia,
isServiceMessageWithMedia,
calculateResultHash,
} from '../gramjsBuilders';
import localDb from '../localDb';
import { buildApiChatFromPreview } from '../apiBuilders/chats';
import { fetchFile } from '../../../util/files';
import { addMessageToLocalDb } from '../helpers';
import { addMessageToLocalDb, resolveMessageApiChatId } from '../helpers';
import { interpolateArray } from '../../../util/waveform';
import { requestChatUpdate } from './chats';
@ -766,6 +766,9 @@ export async function searchMessagesLocal({
case 'voice':
filter = new GramJs.InputMessagesFilterVoice();
break;
case 'profilePhoto':
filter = new GramJs.InputMessagesFilterChatPhotos();
break;
case 'text':
default: {
filter = new GramJs.InputMessagesFilterEmpty();
@ -1085,7 +1088,9 @@ function updateLocalDb(result: (
});
result.messages.forEach((message) => {
if (message instanceof GramJs.Message && isMessageWithMedia(message)) {
if ((message instanceof GramJs.Message && isMessageWithMedia(message))
|| (message instanceof GramJs.MessageService && isServiceMessageWithMedia(message))
) {
addMessageToLocalDb(message);
}
});

View File

@ -1,7 +1,12 @@
import BigInt from 'big-integer';
import { Api as GramJs } from '../../../lib/gramjs';
import { OnApiUpdate, ApiUser, ApiChat } from '../../types';
import {
OnApiUpdate, ApiUser, ApiChat, ApiPhoto,
} from '../../types';
import { PROFILE_PHOTOS_LIMIT } from '../../../config';
import { invokeRequest } from './client';
import { searchMessagesLocal } from './messages';
import {
buildInputEntity,
calculateResultHash,
@ -9,8 +14,10 @@ import {
buildInputContact,
} from '../gramjsBuilders';
import { buildApiUser, buildApiUserFromFull } from '../apiBuilders/users';
import localDb from '../localDb';
import { buildApiChatFromPreview } from '../apiBuilders/chats';
import { buildApiPhoto } from '../apiBuilders/common';
import localDb from '../localDb';
import { addPhotoToLocalDb } from '../helpers';
let onUpdate: OnApiUpdate;
@ -152,3 +159,49 @@ export async function deleteUser({
id,
});
}
export async function fetchProfilePhotos(user?: ApiUser, chat?: ApiChat) {
if (user) {
const { id, accessHash } = user;
const result = await invokeRequest(new GramJs.photos.GetUserPhotos({
userId: buildInputEntity(id, accessHash) as GramJs.InputUser,
limit: PROFILE_PHOTOS_LIMIT,
offset: 0,
maxId: BigInt('0'),
}));
if (!result) {
return undefined;
}
updateLocalDb(result);
return {
photos: result.photos
.filter((photo): photo is GramJs.Photo => photo instanceof GramJs.Photo)
.map(buildApiPhoto),
};
}
const result = await searchMessagesLocal({
chatOrUser: chat!,
type: 'profilePhoto',
limit: PROFILE_PHOTOS_LIMIT,
});
if (!result) {
return undefined;
}
const { messages, users } = result;
return {
photos: messages.map((message) => message.content.action!.photo).filter<ApiPhoto>(Boolean as any),
users,
};
}
function updateLocalDb(result: (GramJs.photos.Photos | GramJs.photos.PhotosSlice)) {
result.photos.forEach(addPhotoToLocalDb);
}

View File

@ -8,7 +8,6 @@ import {
buildApiMessageFromShortChat,
buildMessageMediaContent,
buildMessageTextContent,
resolveMessageApiChatId,
buildPoll,
buildPollResults,
buildApiMessageFromNotification,
@ -32,8 +31,9 @@ import {
import localDb from './localDb';
import { omitVirtualClassFields } from './apiBuilders/helpers';
import { DEBUG } from '../../config';
import { addMessageToLocalDb } from './helpers';
import { addMessageToLocalDb, addPhotoToLocalDb, resolveMessageApiChatId } from './helpers';
import { buildPrivacyKey, buildPrivacyRules } from './apiBuilders/misc';
import { buildApiPhoto } from './apiBuilders/common';
type Update = (
(GramJs.TypeUpdate | GramJs.TypeUpdates) & { _entities?: (GramJs.TypeUser | GramJs.TypeChat)[] }
@ -181,12 +181,16 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) {
if (localDb.chats[localDbChatId]) {
localDb.chats[localDbChatId].photo = photo;
}
addPhotoToLocalDb(action.photo);
if (avatarHash) {
onUpdate({
'@type': 'updateChat',
id: message.chatId,
chat: { avatarHash },
chat: {
avatarHash,
},
...(action.photo instanceof GramJs.Photo && { newProfilePhoto: buildApiPhoto(action.photo) }),
});
}
} else if (action instanceof GramJs.MessageActionChatDeletePhoto) {
@ -264,6 +268,13 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) {
const ids = update.messages;
const existingIds = ids.filter((id) => localDb.messages[`${chatId}-${id}`]);
const missingIds = ids.filter((id) => !localDb.messages[`${chatId}-${id}`]);
const profilePhotoIds = ids.map((id) => {
const message = localDb.messages[`${chatId}-${id}`];
return message && message instanceof GramJs.MessageService && 'photo' in message.action
? String(message.action.photo.id)
: undefined;
}).filter<string>(Boolean as any);
if (existingIds.length) {
onUpdate({
@ -273,6 +284,14 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) {
});
}
if (profilePhotoIds.length) {
onUpdate({
'@type': 'deleteProfilePhotos',
ids: profilePhotoIds,
chatId,
});
}
// For some reason delete message update sometimes comes before new message update
if (missingIds.length) {
setTimeout(() => {

View File

@ -1,4 +1,4 @@
import { ApiMessage } from './messages';
import { ApiMessage, ApiPhoto } from './messages';
type ApiChatType = (
'chatTypePrivate' | 'chatTypeSecret' |
@ -28,6 +28,7 @@ export interface ApiChat {
membersCount?: number;
joinDate?: number;
isSupport?: boolean;
photos?: ApiPhoto[];
// Current user permissions
isNotJoined?: boolean;

View File

@ -11,6 +11,7 @@ export interface ApiThumbnail {
}
export interface ApiPhoto {
id: string;
thumbnail?: ApiThumbnail;
sizes: ApiPhotoSize[];
blobUrl?: string;
@ -144,6 +145,7 @@ export interface ApiAction {
targetUserId?: number;
targetChatId?: number;
type: 'historyClear' | 'other';
photo?: ApiPhoto;
}
export interface ApiWebPage {
@ -264,7 +266,7 @@ export interface ApiKeyboardButton {
export type ApiKeyboardButtons = ApiKeyboardButton[][];
export type ApiMessageSearchType = 'text' | 'media' | 'documents' | 'links' | 'audio';
export type ApiMessageSearchType = 'text' | 'media' | 'documents' | 'links' | 'audio' | 'profilePhoto';
export type ApiGlobalMessageSearchType = 'text' | 'media' | 'documents' | 'links' | 'audio' | 'voice';
export const MAIN_THREAD_ID = -1;

View File

@ -6,7 +6,7 @@ import {
ApiChatFolder,
} from './chats';
import {
ApiMessage, ApiPoll, ApiStickerSet, ApiThreadInfo,
ApiMessage, ApiPhoto, ApiPoll, ApiStickerSet, ApiThreadInfo,
} from './messages';
import { ApiUser, ApiUserFullInfo, ApiUserStatus } from './users';
@ -60,6 +60,7 @@ export type ApiUpdateChat = {
'@type': 'updateChat';
id: number;
chat: Partial<ApiChat>;
newProfilePhoto?: ApiPhoto;
};
export type ApiUpdateChatJoin = {
@ -240,6 +241,12 @@ export type ApiUpdateDeleteHistory = {
chatId: number;
};
export type ApiUpdateDeleteProfilePhotos = {
'@type': 'deleteProfilePhotos';
ids: string[];
chatId: number;
};
export type ApiUpdateResetMessages = {
'@type': 'resetMessages';
id: number;
@ -353,7 +360,7 @@ export type ApiUpdate = (
ApiUpdateNewMessage | ApiUpdateMessage | ApiUpdateThreadInfo | ApiUpdateCommonBoxMessages | ApiUpdateChannelMessages |
ApiUpdateDeleteMessages | ApiUpdateMessagePoll | ApiUpdateMessagePollVote | ApiUpdateDeleteHistory |
ApiUpdateMessageSendSucceeded | ApiUpdateMessageSendFailed |
ApiDeleteUser | ApiUpdateUser | ApiUpdateUserStatus | ApiUpdateUserFullInfo |
ApiDeleteUser | ApiUpdateUser | ApiUpdateUserStatus | ApiUpdateUserFullInfo | ApiUpdateDeleteProfilePhotos |
ApiUpdateAvatar | ApiUpdateMessageImage |
ApiUpdateError | ApiUpdateResetContacts |
ApiUpdateFavoriteStickers | ApiUpdateStickerSet |

View File

@ -1,3 +1,5 @@
import { ApiPhoto } from './messages';
export interface ApiUser {
id: number;
isMin: boolean;
@ -12,6 +14,7 @@ export interface ApiUser {
phoneNumber: string;
accessHash?: string;
avatarHash?: string;
photos?: ApiPhoto[];
// Obtained from GetFullUser / UserFullInfo
fullInfo?: ApiUserFullInfo;

View File

@ -123,40 +123,4 @@
width: 100%;
height: 100%;
}
&.color-bg-1 {
--color-user: var(--color-user-1);
}
&.color-bg-2 {
--color-user: var(--color-user-2);
}
&.color-bg-4 {
--color-user: var(--color-user-4);
}
&.color-bg-5 {
--color-user: var(--color-user-5);
}
&.color-bg-6 {
--color-user: var(--color-user-6);
}
&.color-bg-7 {
--color-user: var(--color-user-7);
}
&.color-bg-8 {
--color-user: var(--color-user-8);
}
&.saved-messages {
--color-user: var(--color-primary);
}
&.deleted-account {
--color-user: var(--color-gray);
}
}

View File

@ -36,13 +36,8 @@
.Tab {
flex: 0 0 auto;
padding-left: 0.625rem;
padding-right: 0.625rem;
> span {
padding-left: 0.5rem;
padding-right: 0.5rem;
}
padding-left: 1rem;
padding-right: 1rem;
}
> .Transition {

View File

@ -156,12 +156,11 @@ const SettingsFoldersMain: FC<OwnProps & StateProps & DispatchProps> = ({
<ListItem
className="mb-2"
narrow
multiline
onClick={() => onEditFolder(foldersById[folder.id])}
>
<div className="multiline-item">
<span className="title">{folder.title}</span>
<span className="subtitle">{folder.subtitle}</span>
</div>
<span className="title">{folder.title}</span>
<span className="subtitle">{folder.subtitle}</span>
</ListItem>
)) : userFolders && !userFolders.length ? (
<p className="settings-item-description my-4">

View File

@ -75,6 +75,7 @@ type StateProps = {
senderId?: number;
origin?: MediaViewerOrigin;
avatarOwner?: ApiChat | ApiUser;
profilePhotoIndex?: number;
message?: ApiMessage;
chatMessages?: Record<number, ApiMessage>;
collectionIds?: number[];
@ -92,6 +93,7 @@ const MediaViewer: FC<StateProps & DispatchProps> = ({
senderId,
origin,
avatarOwner,
profilePhotoIndex,
message,
chatMessages,
collectionIds,
@ -116,7 +118,9 @@ const MediaViewer: FC<StateProps & DispatchProps> = ({
const slideAnimation = animationLevel >= 1 ? 'mv-slide' : 'none';
const headerAnimation = animationLevel === 2 ? 'slide-fade' : 'none';
const isGhostAnimation = animationLevel === 2;
const fileName = avatarOwner ? `avatar${avatarOwner.id}.jpg` : message && getMessageMediaFilename(message);
const fileName = avatarOwner
? `avatar${avatarOwner.id}-${profilePhotoIndex}.jpg`
: message && getMessageMediaFilename(message);
const prevSenderId = usePrevious<number | undefined>(senderId);
const [canPanZoomWrap, setCanPanZoomWrap] = useState(false);
const [isZoomed, setIsZoomed] = useState<boolean>(false);
@ -137,8 +141,11 @@ const MediaViewer: FC<StateProps & DispatchProps> = ({
}
function getMediaHash(full?: boolean) {
if (avatarOwner) {
return getChatAvatarHash(avatarOwner, full ? 'big' : 'normal');
if (avatarOwner && profilePhotoIndex !== undefined) {
const { photos } = avatarOwner;
return photos && photos[profilePhotoIndex]
? `photo${photos[profilePhotoIndex].id}?size=c`
: getChatAvatarHash(avatarOwner, full ? 'big' : 'normal');
}
return message && getMessageMediaHash(message, full ? 'viewerFull' : 'viewerPreview');
@ -151,10 +158,13 @@ const MediaViewer: FC<StateProps & DispatchProps> = ({
undefined,
isGhostAnimation && ANIMATION_DURATION,
);
const previewMediaHash = getMediaHash();
const blobUrlPreview = useMedia(
getMediaHash(),
previewMediaHash,
undefined,
avatarOwner ? ApiMediaFormat.DataUri : ApiMediaFormat.BlobUrl,
avatarOwner && previewMediaHash && previewMediaHash.startsWith('profilePhoto')
? ApiMediaFormat.DataUri
: ApiMediaFormat.BlobUrl,
undefined,
isGhostAnimation && ANIMATION_DURATION,
);
@ -544,7 +554,7 @@ function renderPhoto(blobUrl?: string, imageSize?: IDimensions) {
export default memo(withGlobal(
(global): StateProps => {
const {
chatId, threadId, messageId, avatarOwnerId, origin,
chatId, threadId, messageId, avatarOwnerId, profilePhotoIndex, origin,
} = global.mediaViewer;
const {
animationLevel,
@ -571,12 +581,13 @@ export default memo(withGlobal(
}
if (avatarOwnerId) {
const sender = selectChat(global, avatarOwnerId) || selectUser(global, avatarOwnerId);
const sender = selectUser(global, avatarOwnerId) || selectChat(global, avatarOwnerId);
return {
messageId: -1,
senderId: avatarOwnerId,
avatarOwner: sender,
profilePhotoIndex: profilePhotoIndex || 0,
animationLevel,
origin,
};

View File

@ -148,7 +148,7 @@ export function animateClosing(origin: MediaViewerOrigin, bestImageData: string,
}
}
const ghost = createGhost(bestImageData || toImage);
const ghost = createGhost(bestImageData || toImage, origin === MediaViewerOrigin.ProfileAvatar);
applyStyles(ghost, {
top: `${toTop}px`,
left: `${toLeft}px`,
@ -179,7 +179,7 @@ export function animateClosing(origin: MediaViewerOrigin, bestImageData: string,
});
}
function createGhost(source: string | HTMLImageElement | HTMLVideoElement) {
function createGhost(source: string | HTMLImageElement | HTMLVideoElement, shouldAppendProfileInfo = false) {
const ghost = document.createElement('div');
ghost.classList.add('ghost');
@ -195,6 +195,14 @@ function createGhost(source: string | HTMLImageElement | HTMLVideoElement) {
ghost.appendChild(img);
if (shouldAppendProfileInfo) {
ghost.classList.add('ProfileInfo');
const profileInfo = document.querySelector('#RightColumn .ProfileInfo .info');
if (profileInfo) {
ghost.appendChild(profileInfo.cloneNode(true));
}
}
return ghost;
}
@ -283,7 +291,7 @@ function getNodes(origin: MediaViewerOrigin, message?: ApiMessage) {
break;
case MediaViewerOrigin.ProfileAvatar:
containerSelector = '#RightColumn .active .profile-info .Avatar';
containerSelector = '#RightColumn .ProfileInfo .active .ProfilePhoto';
mediaSelector = 'img.avatar-media';
break;
@ -313,12 +321,12 @@ function applyShape(ghost: HTMLDivElement, origin: MediaViewerOrigin) {
break;
case MediaViewerOrigin.SharedMedia:
case MediaViewerOrigin.ProfileAvatar:
case MediaViewerOrigin.SearchResult:
(ghost.firstChild as HTMLElement).style.objectFit = 'cover';
break;
case MediaViewerOrigin.MiddleHeaderAvatar:
case MediaViewerOrigin.ProfileAvatar:
ghost.classList.add('circle');
break;
}

View File

@ -1,71 +1,159 @@
import React, { FC, memo } from '../../lib/teact/teact';
import React, {
FC, memo, useCallback, useEffect,
} from '../../lib/teact/teact';
import { withGlobal } from '../../lib/teact/teactn';
import { ApiChat } from '../../api/types';
import { GlobalActions, GlobalState } from '../../global/types';
import { ApiChat, ApiUser } from '../../api/types';
import { selectChat } from '../../modules/selectors';
import { selectChat, selectUser } from '../../modules/selectors';
import {
getChatDescription, getChatLink, getHasAdminRight, isChatChannel, isUserRightBanned,
getChatDescription, getChatLink, getHasAdminRight, isChatChannel, isChatPrivate, isUserRightBanned,
} from '../../modules/helpers';
import renderText from '../common/helpers/renderText';
import { pick } from '../../util/iteratees';
import { copyTextToClipboard } from '../../util/clipboard';
import { formatPhoneNumberWithCode } from '../../util/phoneNumber';
import useLang from '../../hooks/useLang';
import SafeLink from '../common/SafeLink';
import ListItem from '../ui/ListItem';
import Switcher from '../ui/Switcher';
type OwnProps = {
chatId: number;
chatOrUserId: number;
forceShowSelf?: boolean;
};
type StateProps = {
user?: ApiUser;
chat?: ApiChat;
canInviteUsers?: boolean;
};
} & Pick<GlobalState, 'lastSyncTime'>;
const ChatExtra: FC<OwnProps & StateProps> = ({ chat, canInviteUsers }) => {
type DispatchProps = Pick<GlobalActions, 'loadFullUser' | 'updateChatMutedState' | 'showNotification'>;
const ChatExtra: FC<OwnProps & StateProps & DispatchProps> = ({
lastSyncTime,
user,
chat,
forceShowSelf,
canInviteUsers,
loadFullUser,
showNotification,
updateChatMutedState,
}) => {
const {
id: userId,
fullInfo,
username,
phoneNumber,
isSelf,
} = user || {};
const {
id: chatId,
isMuted: currentIsMuted,
username: chatUsername,
} = chat || {};
const lang = useLang();
if (!chat || chat.isRestricted) {
useEffect(() => {
if (lastSyncTime && userId) {
loadFullUser({ userId });
}
}, [loadFullUser, userId, lastSyncTime]);
const handleClick = useCallback((text: string, entity: string) => {
copyTextToClipboard(text);
showNotification({ message: `${entity} was copied` });
}, [showNotification]);
const handleNotificationChange = useCallback(() => {
updateChatMutedState({ chatId, isMuted: !currentIsMuted });
}, [chatId, currentIsMuted, updateChatMutedState]);
if (!chat || chat.isRestricted || (isSelf && !forceShowSelf)) {
return undefined;
}
const bio = fullInfo && fullInfo.bio;
const formattedNumber = phoneNumber && formatPhoneNumberWithCode(phoneNumber);
const description = getChatDescription(chat);
const link = getChatLink(chat);
const url = link.indexOf('http') === 0 ? link : `http://${link}`;
const printedUsername = username || chatUsername;
const printedDescription = bio || description;
return (
<div className="ChatExtra">
{description && !!description.length && (
<div className="item">
<i className="icon-info" />
<div>
<p className="title">{renderText(description, ['br', 'links', 'emoji'])}</p>
<p className="subtitle">{lang('Info')}</p>
</div>
</div>
{formattedNumber && !!formattedNumber.length && (
<ListItem icon="phone" multiline narrow ripple onClick={() => handleClick(formattedNumber, lang('Phone'))}>
<span className="title">{formattedNumber}</span>
<span className="subtitle">{lang('Phone')}</span>
</ListItem>
)}
{canInviteUsers && !!link.length && (
<div className="item">
<i className="icon-mention" />
<div>
{printedUsername && (
<ListItem
icon="mention"
multiline
narrow
ripple
onClick={() => handleClick(`@${printedUsername}`, lang('Username'))}
>
<span className="title">{renderText(printedUsername)}</span>
<span className="subtitle">{lang('Username')}</span>
</ListItem>
)}
{printedDescription && !!printedDescription.length && (
<ListItem
icon="info"
multiline
narrow
ripple
onClick={() => handleClick(printedDescription, lang(userId ? 'UserBio' : 'Info'))}
>
<span className="title">{renderText(printedDescription, ['br', 'links', 'emoji'])}</span>
<span className="subtitle">{lang(userId ? 'UserBio' : 'Info')}</span>
</ListItem>
)}
{canInviteUsers && !printedUsername && !!link.length && (
<ListItem icon="mention" multiline narrow ripple onClick={() => handleClick(link, lang('SetUrlPlaceholder'))}>
<div className="title">
<SafeLink url={url} className="title" text={link} />
<p className="subtitle">{lang('SetUrlPlaceholder')}</p>
</div>
</div>
<span className="subtitle">{lang('SetUrlPlaceholder')}</span>
</ListItem>
)}
<ListItem icon="unmute" onClick={handleNotificationChange}>
<span>{lang('Notifications')}</span>
<Switcher
id="group-notifications"
label={`${userId ? 'Toggle User Notifications' : 'Toggle Chat Notifications'}`}
checked={!currentIsMuted}
inactive
/>
</ListItem>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global, { chatId }): StateProps => {
const chat = selectChat(global, chatId);
(global, { chatOrUserId }): StateProps => {
const { lastSyncTime } = global;
const chat = chatOrUserId ? selectChat(global, chatOrUserId) : undefined;
const user = isChatPrivate(chatOrUserId) ? selectUser(global, chatOrUserId) : undefined;
const canInviteUsers = chat && (
(!isChatChannel(chat) && !isUserRightBanned(chat, 'inviteUsers'))
|| getHasAdminRight(chat, 'inviteUsers')
);
return { chat, canInviteUsers };
return {
lastSyncTime, chat, user, canInviteUsers,
};
},
(setGlobal, actions): DispatchProps => pick(actions, [
'loadFullUser', 'updateChatMutedState', 'showNotification',
]),
)(ChatExtra));

View File

@ -3,6 +3,10 @@
overflow-y: scroll;
overflow-x: hidden;
@supports (overflow-y: overlay) {
overflow-y: overlay !important;
}
> .profile-info > .ChatInfo {
grid-area: chat_info;
@ -11,37 +15,22 @@
}
}
> .profile-info >.ChatExtra {
padding: 0 1.5rem;
> .profile-info > .ChatExtra {
padding: .875rem .5rem .5rem;
box-shadow: inset 0 -.0625rem 0 0 var(--color-background-secondary-accent);
border-bottom: .625rem solid var(--color-background-secondary);
.item {
display: flex;
padding: .75rem 0 1rem;
text-align: left;
.narrow {
margin-bottom: 0;
}
i {
font-size: 1.5rem;
color: var(--color-text-secondary);
margin-right: 2rem;
}
.inactive.no-selection {
user-select: auto;
-webkit-user-select: auto !important;
}
.title {
font-size: 1rem;
line-height: 1.4375rem;
margin-bottom: 0;
font-weight: 400;
word-break: break-word;
}
a.title {
color: var(--color-text);
}
.subtitle {
margin-bottom: 0;
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.Switcher {
margin-left: auto;
}
}
}
@ -54,10 +43,9 @@
background: var(--color-background);
top: -1px;
.Tab {
padding: .6875rem .25rem;
padding: 1rem .25rem;
i {
padding-right: 1.5rem;
margin-left: -.75rem;
bottom: -1rem;
}
}
}
@ -81,10 +69,9 @@
&.media-list {
display: grid;
padding: .5rem;
grid-template-columns: repeat(3, 1fr);
grid-auto-rows: 1fr;
grid-gap: .25rem;
grid-gap: .0625rem;
}
&.documents-list {

View File

@ -39,11 +39,10 @@ import TabList from '../ui/TabList';
import Spinner from '../ui/Spinner';
import ListItem from '../ui/ListItem';
import PrivateChatInfo from '../common/PrivateChatInfo';
import GroupChatInfo from '../common/GroupChatInfo';
import ProfileInfo from './ProfileInfo';
import Document from '../common/Document';
import Audio from '../common/Audio';
import UserExtra from './UserExtra';
import GroupExtra from './ChatExtra';
import ChatExtra from './ChatExtra';
import Media from '../common/Media';
import WebLink from '../common/WebLink';
import NothingFound from '../common/NothingFound';
@ -74,7 +73,7 @@ type StateProps = {
type DispatchProps = Pick<GlobalActions, (
'setLocalMediaSearchType' | 'searchMediaMessagesLocal' | 'openMediaViewer' |
'openAudioPlayer' | 'openUserInfo' | 'focusMessage'
'openAudioPlayer' | 'openUserInfo' | 'focusMessage' | 'loadProfilePhotos'
)>;
const TABS = [
@ -108,6 +107,7 @@ const Profile: FC<OwnProps & StateProps & DispatchProps> = ({
openAudioPlayer,
openUserInfo,
focusMessage,
loadProfilePhotos,
}) => {
// eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(null);
@ -148,6 +148,12 @@ const Profile: FC<OwnProps & StateProps & DispatchProps> = ({
const profileId = resolvedUserId || chatId;
useEffect(() => {
if (lastSyncTime) {
loadProfilePhotos({ profileId });
}
}, [loadProfilePhotos, profileId, lastSyncTime]);
const handleSelectMedia = useCallback((messageId: number) => {
openMediaViewer({
chatId: profileId,
@ -331,23 +337,11 @@ const Profile: FC<OwnProps & StateProps & DispatchProps> = ({
function renderProfileInfo(chatId: number, resolvedUserId?: number) {
return (
<div className="profile-info">
{resolvedUserId ? (
<>
<PrivateChatInfo
userId={resolvedUserId}
avatarSize="jumbo"
forceShowSelf={resolvedUserId !== chatId}
withMediaViewer
withFullInfo
/>
<UserExtra userId={resolvedUserId} forceShowSelf={resolvedUserId !== chatId} />
</>
) : (
<>
<GroupChatInfo chatId={chatId} avatarSize="jumbo" withMediaViewer withFullInfo />
<GroupExtra chatId={chatId} />
</>
)}
<ProfileInfo
userId={resolvedUserId || chatId}
forceShowSelf={resolvedUserId !== chatId}
/>
<ChatExtra chatOrUserId={resolvedUserId || chatId} forceShowSelf={resolvedUserId !== chatId} />
</div>
);
}
@ -408,5 +402,6 @@ export default memo(withGlobal<OwnProps>(
'openAudioPlayer',
'openUserInfo',
'focusMessage',
'loadProfilePhotos',
]),
)(Profile));

View File

@ -0,0 +1,133 @@
.ProfileInfo {
aspect-ratio: 1 / 1;
position: relative;
@supports not (aspect-ratio: 1 / 1) {
&::before {
float: left;
padding-top: 100%;
content: "";
}
&::after {
display: block;
content: "";
clear: both;
}
}
.photo-wrapper {
width: 100%;
position: absolute;
left: 0;
top: 0;
bottom: 0;
> .Transition {
width: 100%;
height: 100%;
}
}
.photo-dashes {
position: absolute;
width: 100%;
height: .125rem;
padding: 0 .375rem;
z-index: 1;
display: flex;
top: .5rem;
left: 0;
}
.photo-dash {
flex: 1 1 auto;
background-color: var(--color-white);
opacity: .5;
border-radius: .125rem;
margin: 0 .125rem;
&.current {
opacity: 1;
}
}
.navigation {
position: absolute;
top: 0;
bottom: 0;
width: 25%;
border: none;
padding: 0;
margin: 0;
appearance: none;
background: transparent no-repeat;
background-size: 1.25rem;
opacity: .25;
transition: opacity .15s;
outline: none;
cursor: pointer;
z-index: 1;
&:hover, .is-touch-env & {
opacity: 1;
}
&.prev {
left: 0;
background-image: url("../../assets/media_navigation_previous.svg");
background-position: 1.25rem 50%;
}
&.next {
right: 0;
background-image: url("../../assets/media_navigation_next.svg");
background-position: calc(100% - 1.25rem) 50%;
}
}
.info {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
min-height: 100px;
padding: 0 1.5rem .5rem;
background: linear-gradient(0deg, rgba(0,0,0,0.5) 0%, rgba(0,0,0,0) 100%);
color: var(--color-white);
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.title {
display: flex;
align-items: center;
h3 {
font-weight: 500;
font-size: 1.25rem;
line-height: 1.375rem;
white-space: pre-wrap;
word-break: break-word;
margin-bottom: .25rem;
}
.VerifiedIcon {
margin-left: 0.25rem;
margin-top: -0.125rem;
}
.emoji {
width: 1.5rem;
height: 1.5rem;
background-size: 1.5rem;
}
}
.status {
font-size: 0.875rem;
opacity: .5;
}
}

View File

@ -0,0 +1,235 @@
import React, {
FC, useEffect, useCallback, memo, useState,
} from '../../lib/teact/teact';
import { withGlobal } from '../../lib/teact/teactn';
import { ApiUser, ApiChat } from '../../api/types';
import { GlobalActions, GlobalState } from '../../global/types';
import { MediaViewerOrigin } from '../../types';
import { IS_TOUCH_ENV } from '../../util/environment';
import { selectChat, selectUser } from '../../modules/selectors';
import {
getUserFullName, getUserStatus, isChatChannel, isUserOnline,
} from '../../modules/helpers';
import renderText from '../common/helpers/renderText';
import { pick } from '../../util/iteratees';
import { captureEvents, SwipeDirection } from '../../util/captureEvents';
import usePhotosPreload from './hooks/usePhotosPreload';
import useLang from '../../hooks/useLang';
import VerifiedIcon from '../common/VerifiedIcon';
import ProfilePhoto from './ProfilePhoto';
import Transition from '../ui/Transition';
import './ProfileInfo.scss';
type OwnProps = {
userId: number;
forceShowSelf?: boolean;
};
type StateProps = {
user?: ApiUser;
chat?: ApiChat;
isSavedMessages?: boolean;
animationLevel: 0 | 1 | 2;
} & Pick<GlobalState, 'lastSyncTime'>;
type DispatchProps = Pick<GlobalActions, 'loadFullUser' | 'openMediaViewer'>;
const PrivateChatInfo: FC<OwnProps & StateProps & DispatchProps> = ({
user,
chat,
isSavedMessages,
lastSyncTime,
animationLevel,
loadFullUser,
openMediaViewer,
}) => {
const { id: userId } = user || {};
const { id: chatId } = chat || {};
const fullName = user ? getUserFullName(user) : (chat ? chat.title : '');
const photos = (user ? user.photos : (chat ? chat.photos : undefined)) || [];
const slideAnimation = animationLevel >= 1 ? 'slide' : 'none';
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
const isFirst = isSavedMessages || photos.length <= 1 || currentPhotoIndex === 0;
const isLast = isSavedMessages || photos.length <= 1 || currentPhotoIndex === photos.length - 1;
// Deleting the last profile photo may result in an error
useEffect(() => {
if (currentPhotoIndex > photos.length) {
setCurrentPhotoIndex(Math.max(0, photos.length - 1));
}
}, [currentPhotoIndex, photos.length]);
const lang = useLang();
useEffect(() => {
if (lastSyncTime && userId) {
loadFullUser({ userId });
}
}, [userId, loadFullUser, lastSyncTime]);
usePhotosPreload(user || chat, photos, currentPhotoIndex);
const handleProfilePhotoClick = useCallback(() => {
openMediaViewer({
avatarOwnerId: userId || chatId,
profilePhotoIndex: currentPhotoIndex,
origin: MediaViewerOrigin.ProfileAvatar,
});
}, [openMediaViewer, userId, chatId, currentPhotoIndex]);
const selectPreviousMedia = useCallback(() => {
if (isFirst) {
return;
}
setCurrentPhotoIndex(currentPhotoIndex - 1);
}, [currentPhotoIndex, isFirst]);
const selectNextMedia = useCallback(() => {
if (isLast) {
return;
}
setCurrentPhotoIndex(currentPhotoIndex + 1);
}, [currentPhotoIndex, isLast]);
// Support for swipe gestures and closing on click
useEffect(() => {
const element = document.querySelector<HTMLDivElement>(
'.profile-slide-container > .active, .profile-slide-container > .to',
);
if (!element) {
return undefined;
}
return captureEvents(element, {
excludedClosestSelector: '.navigation',
onSwipe: IS_TOUCH_ENV ? (e, direction) => {
if (direction === SwipeDirection.Right) {
selectPreviousMedia();
} else if (direction === SwipeDirection.Left) {
selectNextMedia();
}
} : undefined,
});
}, [selectNextMedia, selectPreviousMedia]);
if (!user && !chat) {
return undefined;
}
function renderPhotoTabs() {
if (isSavedMessages || !photos || photos.length <= 1) {
return undefined;
}
return (
<div className="photo-dashes">
{photos.map((_, i) => (
<span className={`photo-dash ${i === currentPhotoIndex ? 'current' : ''}`} />
))}
</div>
);
}
function renderPhoto() {
const photo = !isSavedMessages && photos && photos.length > 0 ? photos[currentPhotoIndex] : undefined;
return (
<ProfilePhoto
key={currentPhotoIndex}
user={user}
chat={chat}
photo={photo}
isSavedMessages={isSavedMessages}
isFirstPhoto={isFirst}
onClick={handleProfilePhotoClick}
/>
);
}
function renderStatus() {
if (user) {
return (
<div className={`status ${isUserOnline(user) ? 'online' : ''}`}>
<span className="user-status">{getUserStatus(user, lang)}</span>
</div>
);
}
return (
<span className="status">{
isChatChannel(chat!)
? lang('Subscribers', chat!.membersCount, 'i')
: lang('Members', chat!.membersCount, 'i')
}
</span>
);
}
const isVerifiedIconShown = (user && user.isVerified) || (chat && chat.isVerified);
return (
<div className="ProfileInfo">
<div className="photo-wrapper">
{renderPhotoTabs()}
<Transition activeKey={currentPhotoIndex} name={slideAnimation} className="profile-slide-container">
{renderPhoto}
</Transition>
{!isFirst && (
<button
type="button"
className="navigation prev"
aria-label={lang('AccDescrPrevious')}
onClick={selectPreviousMedia}
/>
)}
{!isLast && (
<button
type="button"
className="navigation next"
aria-label={lang('Next')}
onClick={selectNextMedia}
/>
)}
</div>
<div className="info">
{isSavedMessages ? (
<div className="title">
<h3>{lang('SavedMessages')}</h3>
</div>
) : (
<div className="title">
<h3>{fullName && renderText(fullName)}</h3>
{isVerifiedIconShown && <VerifiedIcon />}
</div>
)}
{!isSavedMessages && renderStatus()}
</div>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global, { userId, forceShowSelf }): StateProps => {
const { lastSyncTime } = global;
const user = selectUser(global, userId);
const chat = selectChat(global, userId);
const isSavedMessages = !forceShowSelf && user && user.isSelf;
const {
animationLevel,
} = global.settings.byKey;
return {
lastSyncTime, user, chat, isSavedMessages, animationLevel,
};
},
(setGlobal, actions): DispatchProps => pick(actions, ['loadFullUser', 'openMediaViewer']),
)(PrivateChatInfo));

View File

@ -0,0 +1,44 @@
.ProfilePhoto {
width: 100%;
height: 100%;
cursor: pointer;
position: relative;
img {
width: 100%;
object-fit: cover;
}
.prev-avatar-media {
position: absolute;
left: 0;
top: 0;
z-index: -1;
}
.spinner-wrapper {
width: 100%;
height: 100%;
}
.spinner-wrapper,
&.deleted-account,
&.no-photo,
&.saved-messages {
display: flex;
align-items: center;
justify-content: center;
color: var(--color-white);
background: linear-gradient(var(--color-white) -125%, var(--color-user));
cursor: default;
}
&.no-photo {
font-size: 14rem;
}
&.deleted-account,
&.saved-messages {
font-size: 20rem;
}
}

View File

@ -0,0 +1,111 @@
import React, { FC, memo } from '../../lib/teact/teact';
import {
ApiUser, ApiChat, ApiMediaFormat, ApiPhoto,
} from '../../api/types';
import {
getChatAvatarHash, isDeletedUser, getUserColorKey, getChatTitle, isChatPrivate, getUserFullName,
} from '../../modules/helpers';
import renderText from '../common/helpers/renderText';
import buildClassName from '../../util/buildClassName';
import { getFirstLetters } from '../../util/textFormat';
import useMedia from '../../hooks/useMedia';
import useBlurSync from '../../hooks/useBlurSync';
import usePrevious from '../../hooks/usePrevious';
import Spinner from '../ui/Spinner';
import './ProfilePhoto.scss';
type OwnProps = {
chat?: ApiChat;
user?: ApiUser;
isFirstPhoto?: boolean;
isSavedMessages?: boolean;
photo?: ApiPhoto;
lastSyncTime?: number;
onClick: NoneToVoidFunction;
};
const ProfilePhoto: FC<OwnProps> = ({
chat,
user,
photo,
isFirstPhoto,
isSavedMessages,
lastSyncTime,
onClick,
}) => {
const isDeleted = user && isDeletedUser(user);
function getMediaHash(size: 'normal' | 'big' = 'big', forceAvatar?: boolean) {
if (photo && !forceAvatar) {
return `photo${photo.id}?size=c`;
}
let hash: string | undefined;
if (!isSavedMessages && !isDeleted) {
if (user) {
hash = getChatAvatarHash(user, size);
} else if (chat) {
hash = getChatAvatarHash(chat, size);
}
}
return hash;
}
const imageHash = getMediaHash();
const fullMediaData = useMedia(imageHash, false, ApiMediaFormat.BlobUrl, lastSyncTime);
const avatarThumbnailData = useMedia(
!fullMediaData && isFirstPhoto ? getMediaHash('normal', true) : undefined,
false,
ApiMediaFormat.BlobUrl,
lastSyncTime,
);
const thumbDataUri = useBlurSync(!fullMediaData && photo && photo.thumbnail && photo.thumbnail.dataUri);
const imageSrc = fullMediaData || avatarThumbnailData || thumbDataUri;
const prevImageSrc = usePrevious(imageSrc);
let content: string | undefined = '';
if (isSavedMessages) {
content = <i className="icon-avatar-saved-messages" />;
} else if (isDeleted) {
content = <i className="icon-avatar-deleted-account" />;
} else if (imageSrc) {
content = <img src={imageSrc} className="avatar-media" alt="" decoding="async" />;
} else if (!imageSrc && user) {
const userFullName = getUserFullName(user);
content = userFullName ? getFirstLetters(userFullName, 2) : undefined;
} else if (!imageSrc && chat) {
const title = getChatTitle(chat);
content = title && getFirstLetters(title, isChatPrivate(chat.id) ? 2 : 1);
} else {
content = (
<div className="spinner-wrapper">
<Spinner color="white" />
</div>
);
}
const fullClassName = buildClassName(
'ProfilePhoto',
`color-bg-${getUserColorKey(user || chat)}`,
isSavedMessages && 'saved-messages',
isDeleted && 'deleted-account',
(!isSavedMessages && !(imageSrc)) && 'no-photo',
);
return (
<div className={fullClassName} onClick={imageSrc ? onClick : undefined}>
{prevImageSrc && imageSrc && prevImageSrc !== imageSrc && (
<img src={prevImageSrc} className="prev-avatar-media" alt="" decoding="async" />
)}
{typeof content === 'string' ? renderText(content, ['hq_emoji']) : content}
</div>
);
};
export default memo(ProfilePhoto);

View File

@ -24,6 +24,7 @@
@media (max-width: 1275px) {
box-shadow: 0 .25rem .5rem .125rem var(--color-default-shadow);
border-left: none;
}
@media (max-width: 600px) {
@ -36,8 +37,7 @@
overflow: hidden;
}
.Management .section > .ChatInfo,
.profile-info > .ChatInfo {
.Management .section > .ChatInfo {
padding: 0 1.5rem;
margin: 1rem 0;
text-align: center;

View File

@ -264,7 +264,7 @@ const RightHeader: FC<OwnProps & StateProps & DispatchProps> = ({
default:
return (
<>
<h3>{lang('Info')}</h3>
<h3>Profile</h3>
<section className="tools">
{canManage && (
<Button

View File

@ -1,91 +0,0 @@
import React, { FC, useEffect, memo } from '../../lib/teact/teact';
import { withGlobal } from '../../lib/teact/teactn';
import { ApiUser } from '../../api/types';
import { GlobalActions, GlobalState } from '../../global/types';
import { selectUser } from '../../modules/selectors';
import { formatPhoneNumberWithCode } from '../../util/phoneNumber';
import renderText from '../common/helpers/renderText';
import { pick } from '../../util/iteratees';
import useLang from '../../hooks/useLang';
type OwnProps = {
userId: number;
forceShowSelf?: boolean;
};
type StateProps = {
user?: ApiUser;
} & Pick<GlobalState, 'lastSyncTime'>;
type DispatchProps = Pick<GlobalActions, 'loadFullUser'>;
const UserExtra: FC<OwnProps & StateProps & DispatchProps> = ({
lastSyncTime, user, forceShowSelf, loadFullUser,
}) => {
const {
id: userId,
fullInfo,
username,
phoneNumber,
isSelf,
} = user || {};
useEffect(() => {
if (lastSyncTime) {
loadFullUser({ userId });
}
}, [loadFullUser, userId, lastSyncTime]);
const lang = useLang();
if (!user || (isSelf && !forceShowSelf)) {
return undefined;
}
const bio = fullInfo && fullInfo.bio;
const formattedNumber = phoneNumber && formatPhoneNumberWithCode(phoneNumber);
return (
<div className="ChatExtra">
{bio && !!bio.length && (
<div className="item">
<i className="icon-info" />
<div>
<p className="title">{renderText(bio, ['br', 'links', 'emoji'])}</p>
<p className="subtitle">{lang('UserBio')}</p>
</div>
</div>
)}
{username && !!username.length && (
<div className="item">
<i className="icon-mention" />
<div>
<p className="title">{renderText(username)}</p>
<p className="subtitle">{lang('Username')}</p>
</div>
</div>
)}
{formattedNumber && !!formattedNumber.length && (
<div className="item">
<i className="icon-phone" />
<div>
<p className="title">{formattedNumber}</p>
<p className="subtitle">{lang('Phone')}</p>
</div>
</div>
)}
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global, { userId }): StateProps => {
const { lastSyncTime } = global;
const user = selectUser(global, userId);
return { lastSyncTime, user };
},
(setGlobal, actions): DispatchProps => pick(actions, ['loadFullUser']),
)(UserExtra));

View File

@ -0,0 +1,22 @@
import {
ApiChat, ApiMediaFormat, ApiPhoto, ApiUser,
} from '../../../api/types';
import { useEffect } from '../../../lib/teact/teact';
import * as mediaLoader from '../../../util/mediaLoader';
const PHOTOS_TO_PRELOAD = 4;
export default function usePhotosPreload(
profile: ApiUser | ApiChat | undefined,
photos: ApiPhoto[],
currentIndex: number,
) {
useEffect(() => {
photos.slice(currentIndex, currentIndex + PHOTOS_TO_PRELOAD).forEach((photo) => {
const mediaData = mediaLoader.getFromMemory(`photo${photo.id}?size=c`);
if (!mediaData) {
mediaLoader.fetch(`photo${photo.id}?size=c`, ApiMediaFormat.BlobUrl);
}
});
}, [currentIndex, photos]);
}

View File

@ -2,12 +2,13 @@ import { useCallback, useEffect } from '../../../lib/teact/teact';
export default function useTransitionFixes(
containerRef: { current: HTMLDivElement | null },
transitionElSelector = '.Transition.shared-media-transition',
) {
// Set `min-height` for shared media container to prevent jumping when switching tabs
useEffect(() => {
function setMinHeight() {
const container = containerRef.current!;
const transitionEl = container.querySelector<HTMLDivElement>('.Transition');
const transitionEl = container.querySelector<HTMLDivElement>(transitionElSelector);
const tabsEl = container.querySelector<HTMLDivElement>('.TabList');
if (transitionEl && tabsEl) {
transitionEl.style.minHeight = `${container.offsetHeight - tabsEl.offsetHeight}px`;
@ -21,7 +22,7 @@ export default function useTransitionFixes(
return () => {
window.removeEventListener('resize', setMinHeight, false);
};
}, [containerRef]);
}, [containerRef, transitionElSelector]);
// Workaround for scrollable content flickering during animation.
const applyTransitionFix = useCallback(() => {

View File

@ -177,24 +177,18 @@ const ManageChannel: FC<OwnProps & StateProps & DispatchProps> = ({
disabled={!canChangeInfo}
/>
{chat.isCreator && (
<ListItem icon="lock" ripple onClick={handleClickEditType}>
<div className="multiline-item">
<span className="title">{lang('ChannelType')}</span>
<span className="subtitle">{chat.username ? lang('TypePublic') : lang('TypePrivate')}</span>
</div>
<ListItem icon="lock" ripple multiline onClick={handleClickEditType}>
<span className="title">{lang('ChannelType')}</span>
<span className="subtitle">{chat.username ? lang('TypePublic') : lang('TypePrivate')}</span>
</ListItem>
)}
<ListItem icon="message" ripple onClick={handleClickDiscussion} disabled={!canChangeInfo}>
<div className="multiline-item">
<span className="title">{lang('Discussion')}</span>
<span className="subtitle">{hasLinkedChat ? lang('DiscussionUnlink') : lang('Add')}</span>
</div>
<ListItem icon="message" multiline ripple onClick={handleClickDiscussion} disabled={!canChangeInfo}>
<span className="title">{lang('Discussion')}</span>
<span className="subtitle">{hasLinkedChat ? lang('DiscussionUnlink') : lang('Add')}</span>
</ListItem>
<ListItem icon="admin" ripple onClick={handleClickAdministrators}>
<div className="multiline-item">
<span className="title">{lang('ChannelAdministrators')}</span>
<span className="subtitle">{adminsCount}</span>
</div>
<ListItem icon="admin" multiline ripple onClick={handleClickAdministrators}>
<span className="title">{lang('ChannelAdministrators')}</span>
<span className="subtitle">{adminsCount}</span>
</ListItem>
<div className="ListItem no-selection narrow">
<Checkbox
@ -205,11 +199,9 @@ const ManageChannel: FC<OwnProps & StateProps & DispatchProps> = ({
</div>
</div>
<div className="section">
<ListItem icon="group" ripple onClick={handleClickSubscribers}>
<div className="multiline-item">
<span className="title">{lang('ChannelSubscribers')}</span>
<span className="subtitle">{lang('Subscribers', chat.membersCount!, 'i')}</span>
</div>
<ListItem icon="group" multiline ripple onClick={handleClickSubscribers}>
<span className="title">{lang('ChannelSubscribers')}</span>
<span className="subtitle">{lang('Subscribers', chat.membersCount!, 'i')}</span>
</ListItem>
</div>
<div className="section">

View File

@ -79,11 +79,9 @@ const ManageChatAdministrators: FC<OwnProps & StateProps> = ({
<div className="Management">
<div className="custom-scroll">
<div className="section">
<ListItem icon="recent" ripple onClick={handleRecentActionsClick}>
<div className="multiline-item">
<span className="title">{lang('EventLog')}</span>
<span className="subtitle">{lang(isChannel ? 'EventLogInfoDetailChannel' : 'EventLogInfoDetail')}</span>
</div>
<ListItem icon="recent" multiline ripple onClick={handleRecentActionsClick}>
<span className="title">{lang('EventLog')}</span>
<span className="subtitle">{lang(isChannel ? 'EventLogInfoDetailChannel' : 'EventLogInfoDetail')}</span>
</ListItem>
</div>

View File

@ -228,40 +228,30 @@ const ManageGroup: FC<OwnProps & StateProps & DispatchProps> = ({
disabled={!canChangeInfo}
/>
{chat.isCreator && (
<ListItem icon="lock" ripple onClick={handleClickEditType}>
<div className="multiline-item">
<span className="title">{lang('GroupType')}</span>
<span className="subtitle">{chat.username ? lang('TypePublic') : lang('TypePrivate')}</span>
</div>
<ListItem icon="lock" multiline ripple onClick={handleClickEditType}>
<span className="title">{lang('GroupType')}</span>
<span className="subtitle">{chat.username ? lang('TypePublic') : lang('TypePrivate')}</span>
</ListItem>
)}
{hasLinkedChannel && (
<ListItem icon="message" ripple onClick={handleClickDiscussion}>
<div className="multiline-item">
<span className="title">{lang('LinkedChannel')}</span>
<span className="subtitle">{lang('DiscussionUnlink')}</span>
</div>
<ListItem icon="message" multiline ripple onClick={handleClickDiscussion}>
<span className="title">{lang('LinkedChannel')}</span>
<span className="subtitle">{lang('DiscussionUnlink')}</span>
</ListItem>
)}
<ListItem icon="permissions" ripple onClick={handleClickPermissions} disabled={!canBanUsers}>
<div className="multiline-item">
<span className="title">{lang('ChannelPermissions')}</span>
<span className="subtitle">{enabledPermissionsCount}/{TOTAL_PERMISSIONS_COUNT}</span>
</div>
<ListItem icon="permissions" multiline ripple onClick={handleClickPermissions} disabled={!canBanUsers}>
<span className="title">{lang('ChannelPermissions')}</span>
<span className="subtitle">{enabledPermissionsCount}/{TOTAL_PERMISSIONS_COUNT}</span>
</ListItem>
<ListItem icon="admin" ripple onClick={handleClickAdministrators}>
<div className="multiline-item">
<span className="title">{lang('ChannelAdministrators')}</span>
<span className="subtitle">{formatInteger(adminsCount)}</span>
</div>
<ListItem icon="admin" multiline ripple onClick={handleClickAdministrators}>
<span className="title">{lang('ChannelAdministrators')}</span>
<span className="subtitle">{formatInteger(adminsCount)}</span>
</ListItem>
</div>
<div className="section">
<ListItem icon="group" ripple onClick={handleClickMembers}>
<div className="multiline-item">
<span className="title">{lang('GroupMembers')}</span>
<span className="subtitle">{formatInteger(chat.membersCount!)}</span>
</div>
<ListItem icon="group" multiline ripple onClick={handleClickMembers}>
<span className="title">{lang('GroupMembers')}</span>
<span className="subtitle">{formatInteger(chat.membersCount!)}</span>
</ListItem>
{chat.fullInfo && (

View File

@ -240,11 +240,9 @@ const ManageGroupPermissions: FC<OwnProps & StateProps & DispatchProps> = ({
</div>
<div className="section">
<ListItem icon="delete-user" ripple narrow onClick={handleRemovedUsersClick}>
<div className="multiline-item">
<span className="title">{lang('ChannelBlockedUsers')}</span>
<span className="subtitle">{removedUsersCount}</span>
</div>
<ListItem icon="delete-user" multiline ripple narrow onClick={handleRemovedUsersClick}>
<span className="title">{lang('ChannelBlockedUsers')}</span>
<span className="subtitle">{removedUsersCount}</span>
</ListItem>
</div>

View File

@ -144,7 +144,6 @@ const ManageUser: FC<OwnProps & StateProps & DispatchProps> = ({
userId={user.id}
avatarSize="jumbo"
status="original name"
withMediaViewer
withFullInfo
/>
<InputText

View File

@ -4,7 +4,6 @@
.ListItem-button {
width: 100%;
background-color: var(--background-color);
ackground: none;
border: none !important;
box-shadow: none !important;
outline: none !important;
@ -25,6 +24,13 @@
}
}
&.multiline {
.ListItem-button > i {
position: relative;
top: .25rem;
}
}
&.disabled {
pointer-events: none;
@ -242,6 +248,7 @@
.multiline-item {
white-space: initial;
overflow: hidden;
.title, .subtitle {
display: block;
@ -250,6 +257,8 @@
.title {
line-height: 1.25rem;
overflow: hidden;
text-overflow: ellipsis;
}
.subtitle {
@ -266,5 +275,4 @@
}
}
}
}

View File

@ -33,6 +33,7 @@ type OwnProps = {
inactive?: boolean;
focus?: boolean;
destructive?: boolean;
multiline?: boolean;
contextActions?: MenuItemContextAction[];
onClick?: OnClickHandler;
};
@ -48,9 +49,10 @@ const ListItem: FC<OwnProps> = (props) => {
ripple,
narrow,
inactive,
contextActions,
focus,
destructive,
multiline,
contextActions,
onClick,
} = props;
@ -118,6 +120,7 @@ const ListItem: FC<OwnProps> = (props) => {
contextMenuPosition && 'has-menu-open',
focus && 'focus',
destructive && 'destructive',
multiline && 'multiline',
);
return (
@ -138,7 +141,8 @@ const ListItem: FC<OwnProps> = (props) => {
{icon && (
<i className={`icon-${icon}`} />
)}
{children}
{multiline && (<div className="multiline-item">{children}</div>)}
{!multiline && children}
{!disabled && !inactive && ripple && (
<RippleEffect />
)}

View File

@ -9,6 +9,10 @@
opacity: 0.5;
}
&.inactive {
pointer-events: none;
}
input {
height: 0;
width: 0;

View File

@ -12,6 +12,7 @@ type OwnProps = {
label: string;
checked?: boolean;
disabled?: boolean;
inactive?: boolean;
onChange?: (e: ChangeEvent<HTMLInputElement>) => void;
onCheck?: (isChecked: boolean) => void;
};
@ -23,22 +24,24 @@ const Switcher: FC<OwnProps> = ({
label,
checked = false,
disabled,
inactive,
onChange,
onCheck,
}) => {
const handleChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
const handleChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
if (onChange) {
onChange(event);
onChange(e);
}
if (onCheck) {
onCheck(event.currentTarget.checked);
onCheck(e.currentTarget.checked);
}
}, [onChange, onCheck]);
const className = buildClassName(
'Switcher',
disabled && 'disabled',
inactive && 'inactive',
);
return (

View File

@ -67,8 +67,6 @@
width: 100%;
border-radius: .1875rem .1875rem 0 0;
pointer-events: none;
padding-right: .5rem;
margin-left: -.25rem;
box-sizing: content-box;
transform-origin: left;

View File

@ -5,7 +5,7 @@
display: flex;
justify-content: space-between;
align-items: flex-end;
font-size: 0.875rem;
font-size: 1rem;
flex-wrap: nowrap;
box-shadow: 0 2px 2px var(--color-light-shadow);
background-color: var(--color-background);

View File

@ -53,6 +53,7 @@ export const GLOBAL_SEARCH_SLICE = 20;
export const CHANNEL_MEMBERS_LIMIT = 30;
export const PINNED_MESSAGES_LIMIT = 50;
export const BLOCKED_LIST_LIMIT = 100;
export const PROFILE_PHOTOS_LIMIT = 40;
export const TOP_CHAT_MESSAGES_PRELOAD_LIMIT = 25;
export const ALL_CHATS_PRELOAD_DISABLED = false;

View File

@ -276,6 +276,7 @@ export type GlobalState = {
threadId?: number;
messageId?: number;
avatarOwnerId?: number;
profilePhotoIndex?: number;
origin?: MediaViewerOrigin;
};
@ -392,6 +393,7 @@ export type ActionTypes = (
'joinChannel' | 'leaveChannel' | 'deleteChannel' | 'toggleChatPinned' | 'toggleChatArchived' | 'toggleChatUnread' |
'loadChatFolders' | 'loadRecommendedChatFolders' | 'editChatFolder' | 'addChatFolder' | 'deleteChatFolder' |
'updateChat' | 'toggleSignatures' | 'loadGroupsForDiscussion' | 'linkDiscussionGroup' | 'unlinkDiscussionGroup' |
'loadProfilePhotos' |
// messages
'loadViewportMessages' | 'selectMessage' | 'sendMessage' | 'cancelSendingMessage' | 'pinMessage' | 'deleteMessages' |
'markMessageListRead' | 'markMessagesRead' | 'loadMessage' | 'focusMessage' | 'focusLastMessage' | 'sendPollVote' |

View File

@ -949,6 +949,7 @@ updates.getState#edd4882a = updates.State;
updates.getDifference#25939651 flags:# pts:int pts_total_limit:flags.0?int date:int qts:int = updates.Difference;
updates.getChannelDifference#3173d78 flags:# force:flags.0?true channel:InputChannel filter:ChannelMessagesFilter pts:int limit:int = updates.ChannelDifference;
photos.uploadProfilePhoto#89f30f69 flags:# file:flags.0?InputFile video:flags.1?InputFile video_start_ts:flags.2?double = photos.Photo;
photos.getUserPhotos#91cd32a8 user_id:InputUser offset:int max_id:long limit:int = photos.Photos;
upload.saveFilePart#b304a621 file_id:long file_part:int bytes:bytes = Bool;
upload.getFile#b15a9afc flags:# precise:flags.0?true cdn_supported:flags.1?true location:InputFileLocation offset:int limit:int = upload.File;
upload.saveBigFilePart#de7b673d file_id:long file_part:int file_total_parts:int bytes:bytes = Bool;

View File

@ -949,6 +949,7 @@ updates.getState#edd4882a = updates.State;
updates.getDifference#25939651 flags:# pts:int pts_total_limit:flags.0?int date:int qts:int = updates.Difference;
updates.getChannelDifference#3173d78 flags:# force:flags.0?true channel:InputChannel filter:ChannelMessagesFilter pts:int limit:int = updates.ChannelDifference;
photos.uploadProfilePhoto#89f30f69 flags:# file:flags.0?InputFile video:flags.1?InputFile video_start_ts:flags.2?double = photos.Photo;
photos.getUserPhotos#91cd32a8 user_id:InputUser offset:int max_id:long limit:int = photos.Photos;
upload.saveFilePart#b304a621 file_id:long file_part:int bytes:bytes = Bool;
upload.getFile#b15a9afc flags:# precise:flags.0?true cdn_supported:flags.1?true location:InputFileLocation offset:int limit:int = upload.File;
upload.saveBigFilePart#de7b673d file_id:long file_part:int file_total_parts:int bytes:bytes = Bool;

View File

@ -7,10 +7,11 @@ import { ManagementProgress } from '../../../types';
import { debounce } from '../../../util/schedulers';
import { buildCollectionByKey } from '../../../util/iteratees';
import { isChatPrivate } from '../../helpers';
import { callApi } from '../../../api/gramjs';
import { selectUser } from '../../selectors';
import { selectChat, selectUser } from '../../selectors';
import {
addChats, addUsers, updateManagementProgress, updateUser, updateUsers,
addChats, addUsers, updateChat, updateManagementProgress, updateUser, updateUsers,
} from '../../reducers';
const runDebouncedForFetchFullUser = debounce((cb) => cb(), 500, false, true);
@ -170,3 +171,27 @@ async function deleteUser(userId: number) {
await callApi('deleteUser', { id, accessHash });
}
addReducer('loadProfilePhotos', (global, actions, payload) => {
const { profileId } = payload!;
const isPrivate = isChatPrivate(profileId);
const user = isPrivate ? selectUser(global, profileId) : undefined;
const chat = !isPrivate ? selectChat(global, profileId) : undefined;
(async () => {
const result = await callApi('fetchProfilePhotos', user, chat);
if (!result || !result.photos) {
return;
}
let newGlobal = getGlobal();
if (isPrivate) {
newGlobal = updateUser(newGlobal, profileId, { photos: result.photos });
} else {
newGlobal = addUsers(newGlobal, buildCollectionByKey(result.users!, 'id'));
newGlobal = updateChat(newGlobal, profileId, { photos: result.photos });
}
setGlobal(newGlobal);
})();
});

View File

@ -31,7 +31,7 @@ addReducer('apiUpdate', (global, actions, update: ApiUpdate) => {
actions.loadTopChats();
}
setGlobal(updateChat(global, update.id, update.chat));
setGlobal(updateChat(global, update.id, update.chat, update.newProfilePhoto));
break;
}
@ -330,5 +330,17 @@ addReducer('apiUpdate', (global, actions, update: ApiUpdate) => {
break;
}
case 'deleteProfilePhotos': {
const { chatId, ids } = update;
const chat = global.chats.byId[chatId];
if (chat && chat.photos) {
setGlobal(updateChat(global, chatId, {
photos: chat.photos.filter((photo) => !ids.includes(photo.id)),
}));
}
break;
}
}
});

View File

@ -85,7 +85,7 @@ addReducer('editLastMessage', (global) => {
addReducer('openMediaViewer', (global, actions, payload) => {
const {
chatId, threadId, messageId, avatarOwnerId, origin,
chatId, threadId, messageId, avatarOwnerId, profilePhotoIndex, origin,
} = payload!;
return {
@ -95,6 +95,7 @@ addReducer('openMediaViewer', (global, actions, payload) => {
threadId,
messageId,
avatarOwnerId,
profilePhotoIndex,
origin,
},
forwardMessages: {},

View File

@ -1,5 +1,5 @@
import { GlobalState } from '../../global/types';
import { ApiChat } from '../../api/types';
import { ApiChat, ApiPhoto } from '../../api/types';
import { ARCHIVED_FOLDER_ID } from '../../config';
import { omit } from '../../util/iteratees';
@ -47,13 +47,16 @@ export function replaceChats(global: GlobalState, newById: Record<number, ApiCha
};
}
export function updateChat(global: GlobalState, chatId: number, chatUpdate: Partial<ApiChat>): GlobalState {
export function updateChat(
global: GlobalState, chatId: number, chatUpdate: Partial<ApiChat>, photo?: ApiPhoto,
): GlobalState {
const { byId } = global.chats;
const chat = byId[chatId];
const shouldOmitMinInfo = chatUpdate.isMin && chat && !chat.isMin;
const updatedChat = {
...chat,
...(shouldOmitMinInfo ? omit(chatUpdate, ['isMin', 'accessHash']) : chatUpdate),
...(photo && { photos: [photo, ...(chat.photos || [])] }),
};
if (!updatedChat.id || !updatedChat.type) {

View File

@ -173,10 +173,10 @@ export function deleteChatMessages(
if (!byId) {
return global;
}
const newById = omit(byId, messageIds);
const deletedForwardedPosts = Object.values(pickTruthy(byId, messageIds)).filter(
({ forwardInfo }) => forwardInfo && forwardInfo.isLinkedChannelPost,
);
const newById = omit(byId, messageIds);
const threadIds = Object.keys(global.messages.byChatId[chatId].threadsById).map(Number);
threadIds.forEach((threadId) => {

View File

@ -94,3 +94,42 @@
text-align: center;
}
}
// Used by Avatar and ProfilePhoto components
div {
&.color-bg-1 {
--color-user: var(--color-user-1);
}
&.color-bg-2 {
--color-user: var(--color-user-2);
}
&.color-bg-4 {
--color-user: var(--color-user-4);
}
&.color-bg-5 {
--color-user: var(--color-user-5);
}
&.color-bg-6 {
--color-user: var(--color-user-6);
}
&.color-bg-7 {
--color-user: var(--color-user-7);
}
&.color-bg-8 {
--color-user: var(--color-user-8);
}
&.saved-messages {
--color-user: var(--color-primary);
}
&.deleted-account {
--color-user: var(--color-gray);
}
}

View File

@ -56,6 +56,8 @@ $color-user-8: #faa774;
:root {
--color-background: #{$color-white};
--color-background-selected: #f4f4f5;
--color-background-secondary: #f4f4f5;
--color-background-secondary-accent: #E4E4E5;
--color-background-own: #{$color-light-green};
--color-background-own-selected: #{darken($color-light-green, 10%)};
--color-background-own-rgb: #{toRGB($color-light-green)};
@ -144,7 +146,7 @@ $color-user-8: #faa774;
--border-radius-messages-small: 0.375rem;
--messages-container-width: 45.5rem;
--right-column-width: 26.5rem;
--header-height: 3.625rem;
--header-height: 3.5rem;
--symbol-menu-width: 26.25rem;
--symbol-menu-height: 23.25rem;
@ -156,7 +158,6 @@ $color-user-8: #faa774;
@media (max-width: 600px) {
--right-column-width: 100vw;
--header-height: 3.5rem;
--symbol-menu-width: 100vw;
--symbol-menu-height: 14.6875rem;
}

View File

@ -123,7 +123,7 @@ sup {
}
a {
color: theme-color("primary");
color: var(--color-links);
text-decoration: none;
background-color: transparent;
-webkit-text-decoration-skip: objects;

View File

@ -3,6 +3,8 @@
"--color-primary-opacity": ["#50A2E980", "#8378DB80"],
"--color-primary-shade": ["#4a95d6", "#7b71c6"],
"--color-background": ["#FFFFFF", "#212121"],
"--color-background-secondary": ["#f4f4f5", "#121212"],
"--color-background-secondary-accent": ["#E4E4E5", "#100f10"],
"--color-background-own": ["#EEFEDF", "#8378DB"],
"--color-background-selected": ["#F4F4F5", "#2C2C2C"],
"--color-background-own-selected": ["#d4fcae", "#7b71c6"],

View File

@ -157,7 +157,6 @@ async function fetchFromCacheOrRemote(url: string, mediaFormat: ApiMediaFormat,
const media = await webpToPng(url, blob);
if (media) {
prepared = prepareMedia(media);
mimeType = blob.type;
}
}