Introduce Forums (#2174)

This commit is contained in:
Alexander Zinchuk 2023-01-06 01:15:52 +01:00
parent 74f470751a
commit 1fef97b82e
221 changed files with 8144 additions and 1599 deletions

View File

@ -16,6 +16,7 @@ const config: PlaywrightTestConfig = {
video: 'retain-on-failure',
trace: 'on-first-retry',
},
reporter: [['html', { outputFolder: 'playwright-report' }]],
projects: [
{
name: 'chromium',

View File

@ -14,7 +14,7 @@ type Limit = 'upload_max_fileparts' | 'stickers_faved_limit' | 'saved_gifs_limit
type LimitKey = `${Limit}_${LimitType}`;
type LimitsConfig = Record<LimitKey, number>;
interface GramJsAppConfig extends LimitsConfig {
export interface GramJsAppConfig extends LimitsConfig {
emojies_sounds: Record<string, {
id: string;
access_hash: string;
@ -35,6 +35,8 @@ interface GramJsAppConfig extends LimitsConfig {
premium_invoice_slug: string;
premium_promo_order: string[];
default_emoji_statuses_stickerset_id: string;
// Forums
topics_pinned_limit: number;
}
function buildEmojiSounds(appConfig: GramJsAppConfig) {
@ -79,6 +81,7 @@ export function buildAppConfig(json: GramJs.TypeJSONValue): ApiAppConfig {
premiumPromoOrder: appConfig.premium_promo_order,
isPremiumPurchaseBlocked: appConfig.premium_purchase_blocked,
defaultEmojiStatusesStickerSetId: appConfig.default_emoji_statuses_stickerset_id,
topicsPinnedLimit: appConfig.topics_pinned_limit,
maxUserReactionsDefault: appConfig.reactions_user_max_default,
maxUserReactionsPremium: appConfig.reactions_user_max_premium,
limits: {

View File

@ -11,6 +11,7 @@ import type {
ApiExportedInvite,
ApiChatInviteImporter,
ApiChatSettings,
ApiTopic,
ApiSendAsPeerId,
ApiChatReactions,
} from '../../types';
@ -45,6 +46,7 @@ function buildApiChatFieldsFromPeerEntity(
const isJoinToSend = Boolean('joinToSend' in peerEntity && peerEntity.joinToSend);
const isJoinRequest = Boolean('joinRequest' in peerEntity && peerEntity.joinRequest);
const usernames = buildApiUsernames(peerEntity);
const isForum = Boolean('forum' in peerEntity && peerEntity.forum);
return {
isMin,
@ -70,6 +72,7 @@ function buildApiChatFieldsFromPeerEntity(
fakeType: isScam ? 'scam' : (isFake ? 'fake' : undefined),
isJoinToSend,
isJoinRequest,
isForum,
};
}
@ -257,7 +260,7 @@ export function getApiChatTitleFromMtpPeer(peer: GramJs.TypePeer, peerEntity: Gr
function getUserName(user: GramJs.User) {
return user.firstName
? `${user.firstName}${user.lastName ? ` ${user.lastName}` : ''}`
: (user.lastName || undefined);
: (user.lastName || '');
}
export function buildAvatarHash(photo: GramJs.TypeUserProfilePhoto | GramJs.TypeChatPhoto) {
@ -486,3 +489,50 @@ export function buildApiSendAsPeerId(sendAs: GramJs.SendAsPeer): ApiSendAsPeerId
isPremium: sendAs.premiumRequired,
};
}
export function buildApiTopic(forumTopic: GramJs.TypeForumTopic): ApiTopic | undefined {
if (forumTopic instanceof GramJs.ForumTopicDeleted) {
return undefined;
}
const {
id,
my,
closed,
pinned,
hidden,
short,
date,
title,
iconColor,
iconEmojiId,
topMessage,
unreadCount,
unreadMentionsCount,
unreadReactionsCount,
fromId,
notifySettings: {
silent, muteUntil,
},
} = forumTopic;
return {
id,
isClosed: closed,
isPinned: pinned,
isHidden: hidden,
isOwner: my,
isMin: short,
date,
title,
iconColor,
iconEmojiId: iconEmojiId?.toString(),
lastMessageId: topMessage,
unreadCount,
unreadMentionsCount,
unreadReactionsCount,
fromId: getApiChatIdFromMtpPeer(fromId),
// TODO[forums] `muteUntil` should not really be parsed here
isMuted: silent || (muteUntil !== undefined ? muteUntil > 0 : undefined),
};
}

View File

@ -152,7 +152,10 @@ type UniversalMessage = (
)>
);
export function buildApiMessageWithChatId(chatId: string, mtpMessage: UniversalMessage): ApiMessage {
export function buildApiMessageWithChatId(
chatId: string,
mtpMessage: UniversalMessage,
): ApiMessage {
const fromId = mtpMessage.fromId ? getApiChatIdFromMtpPeer(mtpMessage.fromId) : undefined;
const peerId = mtpMessage.peerId ? getApiChatIdFromMtpPeer(mtpMessage.peerId) : undefined;
const isChatWithSelf = !fromId && chatId === currentUserId;
@ -167,7 +170,9 @@ export function buildApiMessageWithChatId(chatId: string, mtpMessage: UniversalM
const isInvoiceMedia = mtpMessage.media instanceof GramJs.MessageMediaInvoice
&& Boolean(mtpMessage.media.extendedMedia);
const { replyToMsgId, replyToTopId, replyToPeerId } = mtpMessage.replyTo || {};
const {
replyToMsgId, replyToTopId, forumTopic, replyToPeerId,
} = mtpMessage.replyTo || {};
const isEdited = mtpMessage.editDate && !mtpMessage.editHide;
const {
inlineButtons, keyboardButtons, keyboardPlaceholder, isKeyboardSingleUse,
@ -195,6 +200,7 @@ export function buildApiMessageWithChatId(chatId: string, mtpMessage: UniversalM
reactions: mtpMessage.reactions && buildMessageReactions(mtpMessage.reactions),
...(emojiOnlyCount && { emojiOnlyCount }),
...(replyToMsgId && { replyToMessageId: replyToMsgId }),
...(forumTopic && { isTopicReply: true }),
...(replyToPeerId && { replyToChatId: getApiChatIdFromMtpPeer(replyToPeerId) }),
...(replyToTopId && { replyToTopMessageId: replyToTopId }),
...(forwardInfo && { forwardInfo }),
@ -1017,6 +1023,23 @@ function buildAction(
currency = action.currency;
amount = action.amount.toJSNumber();
months = action.months;
} else if (action instanceof GramJs.MessageActionTopicCreate) {
text = 'TopicWasCreatedAction';
type = 'topicCreate';
translationValues.push(action.title);
} else if (action instanceof GramJs.MessageActionTopicEdit) {
if (action.closed !== undefined) {
text = action.closed ? 'TopicWasClosedAction' : 'TopicWasReopenedAction';
translationValues.push('%action_origin%', '%action_topic%');
} else if (action.hidden !== undefined) {
text = action.hidden ? 'TopicHidden2' : 'TopicWasUnhiddenAction';
} else if (action.title) {
text = 'TopicRenamedTo';
translationValues.push('%action_origin%', action.title);
} else {
// TODO[forums] Support icon changed action
text = 'ChatList.UnsupportedMessage';
}
} else {
text = 'ChatList.UnsupportedMessage';
}
@ -1238,6 +1261,7 @@ export function buildLocalMessage(
const localId = getNextLocalMessageId();
const media = attachment && buildUploadingMedia(attachment);
const isChannel = chat.type === 'chatTypeChannel';
const isForum = chat.isForum;
const message = {
id: localId,
@ -1260,13 +1284,14 @@ export function buildLocalMessage(
senderId: sendAs?.id || currentUserId,
...(replyingTo && { replyToMessageId: replyingTo }),
...(replyingToTopId && { replyToTopMessageId: replyingToTopId }),
...((replyingTo || replyingToTopId) && isForum && { isTopicReply: true }),
...(groupedId && {
groupedId,
...(media && (media.photo || media.video) && { isInAlbum: true }),
}),
...(scheduledAt && { isScheduled: true }),
isForwardingAllowed: true,
};
} satisfies ApiMessage;
const emojiOnlyCount = getEmojiOnlyCountForMessage(message.content, message.groupedId);
@ -1276,15 +1301,25 @@ export function buildLocalMessage(
};
}
export function buildLocalForwardedMessage(
toChat: ApiChat,
message: ApiMessage,
serverTimeOffset: number,
scheduledAt?: number,
noAuthors?: boolean,
noCaptions?: boolean,
isCurrentUserPremium?: boolean,
): ApiMessage {
export function buildLocalForwardedMessage({
toChat,
toThreadId,
message,
serverTimeOffset,
scheduledAt,
noAuthors,
noCaptions,
isCurrentUserPremium,
}: {
toChat: ApiChat;
toThreadId?: number;
message: ApiMessage;
serverTimeOffset: number;
scheduledAt?: number;
noAuthors?: boolean;
noCaptions?: boolean;
isCurrentUserPremium?: boolean;
}): ApiMessage {
const localId = getNextLocalMessageId();
const {
content,
@ -1322,6 +1357,8 @@ export function buildLocalForwardedMessage(
sendingState: 'messageSendingStatePending',
groupedId,
isInAlbum,
isForwardingAllowed: true,
replyToTopMessageId: toThreadId,
...(emojiOnlyCount && { emojiOnlyCount }),
// Forward info doesn't get added when users forwards his own messages, also when forwarding audio
...(message.chatId !== currentUserId && !isAudio && !noAuthors && {
@ -1335,7 +1372,6 @@ export function buildLocalForwardedMessage(
}),
...(message.chatId === currentUserId && !noAuthors && { forwardInfo: message.forwardInfo }),
...(scheduledAt && { isScheduled: true }),
isForwardingAllowed: true,
};
}

View File

@ -139,6 +139,24 @@ export function buildApiNotifyException(
};
}
export function buildApiNotifyExceptionTopic(
notifySettings: GramJs.TypePeerNotifySettings, peer: GramJs.TypePeer, topicId: number, serverTimeOffset: number,
) {
const {
silent, muteUntil, showPreviews, otherSound,
} = notifySettings;
const hasSound = Boolean(otherSound && !(otherSound instanceof GramJs.NotificationSoundNone));
return {
chatId: getApiChatIdFromMtpPeer(peer),
topicId,
isMuted: silent || (typeof muteUntil === 'number' && getServerTime(serverTimeOffset) < muteUntil),
...(!hasSound && { isSilent: true }),
...(showPreviews !== undefined && { shouldShowPreviews: Boolean(showPreviews) }),
};
}
function buildApiCountry(country: GramJs.help.Country, code: GramJs.help.CountryCode) {
const {
hidden, iso2, defaultName, name,

View File

@ -82,6 +82,7 @@ export function buildMessagePublicForwards(
chat: {
id: peerId,
type: 'chatTypeChannel',
title: (channel as GramJs.Channel).title,
username: (channel as GramJs.Channel).username,
avatarHash: buildAvatarHash((channel as GramJs.Channel).photo),
},

View File

@ -108,9 +108,10 @@ export async function fetchInlineBotResults({
}
export async function sendInlineBotResult({
chat, resultId, queryId, replyingTo, sendAs, isSilent, scheduleDate,
chat, replyingToTopId, resultId, queryId, replyingTo, sendAs, isSilent, scheduleDate,
}: {
chat: ApiChat;
replyingToTopId?: number;
resultId: string;
queryId: string;
replyingTo?: number;
@ -127,6 +128,7 @@ export async function sendInlineBotResult({
peer: buildInputPeer(chat.id, chat.accessHash),
id: resultId,
scheduleDate,
...(replyingToTopId && { topMsgId: replyingToTopId }),
...(isSilent && { silent: true }),
...(replyingTo && { replyToMsgId: replyingTo }),
...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }),
@ -156,6 +158,7 @@ export async function requestWebView({
url,
startParam,
replyToMessageId,
threadId,
theme,
sendAs,
isFromBotMenu,
@ -166,6 +169,7 @@ export async function requestWebView({
url?: string;
startParam?: string;
replyToMessageId?: number;
threadId?: number;
theme?: ApiThemeParameters;
sendAs?: ApiUser | ApiChat;
isFromBotMenu?: boolean;
@ -180,6 +184,7 @@ export async function requestWebView({
themeParams: theme ? buildInputThemeParams(theme) : undefined,
fromBotMenu: isFromBotMenu || undefined,
platform: 'webz',
...(threadId && { topMsgId: threadId }),
...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }),
}));
@ -216,6 +221,7 @@ export function prolongWebView({
bot,
queryId,
replyToMessageId,
threadId,
sendAs,
}: {
isSilent?: boolean;
@ -223,6 +229,7 @@ export function prolongWebView({
bot: ApiUser;
queryId: string;
replyToMessageId?: number;
threadId?: number;
sendAs?: ApiUser | ApiChat;
}) {
return invokeRequest(new GramJs.messages.ProlongWebView({
@ -231,6 +238,7 @@ export function prolongWebView({
bot: buildInputPeer(bot.id, bot.accessHash),
queryId: BigInt(queryId),
replyToMsgId: replyToMessageId,
...(threadId && { topMsgId: threadId }),
...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }),
}));
}

View File

@ -1,4 +1,4 @@
import type BigInt from 'big-integer';
import BigInt from 'big-integer';
import { Api as GramJs } from '../../../lib/gramjs';
import type {
OnApiUpdate,
@ -12,11 +12,20 @@ import type {
ApiChatBannedRights,
ApiChatAdminRights,
ApiGroupCall,
ApiUserStatus, ApiPhoto, ApiChatReactions,
ApiUserStatus,
ApiPhoto,
ApiTopic,
ApiChatReactions,
} from '../../types';
import {
DEBUG, ARCHIVED_FOLDER_ID, MEMBERS_LOAD_SLICE, SERVICE_NOTIFICATIONS_USER_ID, ALL_FOLDER_ID, MAX_INT_32,
DEBUG,
ARCHIVED_FOLDER_ID,
MEMBERS_LOAD_SLICE,
SERVICE_NOTIFICATIONS_USER_ID,
ALL_FOLDER_ID,
MAX_INT_32,
TOPICS_SLICE,
} from '../../../config';
import { invokeRequest, uploadFile } from './client';
import {
@ -29,6 +38,7 @@ import {
buildApiChatBotCommands,
buildApiChatSettings,
buildApiChatReactions,
buildApiTopic,
} from '../apiBuilders/chats';
import { buildApiMessage, buildMessageDraft } from '../apiBuilders/messages';
import { buildApiUser, buildApiUsersAndStatuses } from '../apiBuilders/users';
@ -323,11 +333,13 @@ export function saveDraft({
chat,
text,
entities,
threadId,
replyToMsgId,
}: {
chat: ApiChat;
text: string;
entities?: ApiMessageEntity[];
threadId?: number;
replyToMsgId?: number;
}) {
return invokeRequest(new GramJs.messages.SaveDraft({
@ -337,13 +349,15 @@ export function saveDraft({
entities: entities.map(buildMtpMessageEntity),
}),
replyToMsgId,
topMsgId: threadId,
}));
}
export function clearDraft(chat: ApiChat) {
export function clearDraft(chat: ApiChat, threadId?: number) {
return invokeRequest(new GramJs.messages.SaveDraft({
peer: buildInputPeer(chat.id, chat.accessHash),
message: '',
...(threadId && { topMsgId: threadId }),
}));
}
@ -557,6 +571,30 @@ export async function updateChatMutedState({
});
}
export async function updateTopicMutedState({
chat, topicId, isMuted,
}: {
chat: ApiChat; topicId: number; isMuted: boolean; serverTimeOffset: number;
}) {
await invokeRequest(new GramJs.account.UpdateNotifySettings({
peer: new GramJs.InputNotifyForumTopic({
peer: buildInputPeer(chat.id, chat.accessHash),
topMsgId: topicId,
}),
settings: new GramJs.InputPeerNotifySettings({ muteUntil: isMuted ? MAX_INT_32 : 0 }),
}));
onUpdate({
'@type': 'updateTopicNotifyExceptions',
chatId: chat.id,
topicId,
isMuted,
});
// TODO[forums] Request forum topic thread update
}
export async function createChannel({
title, about = '', users,
}: {
@ -1234,7 +1272,7 @@ function updateLocalDb(result: (
GramJs.messages.Dialogs | GramJs.messages.DialogsSlice | GramJs.messages.PeerDialogs |
GramJs.messages.ChatFull | GramJs.contacts.Found |
GramJs.contacts.ResolvedPeer | GramJs.channels.ChannelParticipants |
GramJs.messages.Chats | GramJs.messages.ChatsSlice | GramJs.TypeUpdates
GramJs.messages.Chats | GramJs.messages.ChatsSlice | GramJs.TypeUpdates | GramJs.messages.ForumTopics
)) {
if ('users' in result) {
addEntitiesWithPhotosToLocalDb(result.users);
@ -1283,3 +1321,168 @@ export function toggleIsProtected({
enabled: isProtected,
}), true);
}
export function toggleForum({
chat, isEnabled,
}: { chat: ApiChat; isEnabled: boolean }) {
const { id, accessHash } = chat;
return invokeRequest(new GramJs.channels.ToggleForum({
channel: buildInputPeer(id, accessHash),
enabled: isEnabled,
}), true);
}
export async function fetchTopics({
chat, query, offsetTopicId, offsetId, offsetDate, limit = TOPICS_SLICE,
}: {
chat: ApiChat;
query?: string;
offsetTopicId?: number;
offsetId?: number;
offsetDate?: number;
limit?: number;
}): Promise<{
topics: ApiTopic[];
messages: ApiMessage[];
users: ApiUser[];
chats: ApiChat[];
count: number;
shouldOrderByCreateDate?: boolean;
draftsById: Record<number, ReturnType<typeof buildMessageDraft>>;
readInboxMessageIdByTopicId: Record<number, number>;
} | undefined> {
const { id, accessHash } = chat;
const result = await invokeRequest(new GramJs.channels.GetForumTopics({
channel: buildInputPeer(id, accessHash),
limit,
q: query,
offsetTopic: offsetTopicId,
offsetId,
offsetDate,
}));
if (!result) return undefined;
updateLocalDb(result);
const { count, orderByCreateDate } = result;
const topics = result.topics.map(buildApiTopic).filter(Boolean);
const messages = result.messages.map(buildApiMessage).filter(Boolean);
const users = result.users.map(buildApiUser).filter(Boolean);
const chats = result.chats.map((c) => buildApiChatFromPreview(c)).filter(Boolean);
const draftsById = result.topics.reduce((acc, topic) => {
if (topic instanceof GramJs.ForumTopic && topic.draft) {
acc[topic.id] = buildMessageDraft(topic.draft);
}
return acc;
}, {} as Record<number, ReturnType<typeof buildMessageDraft>>);
const readInboxMessageIdByTopicId = result.topics.reduce((acc, topic) => {
if (topic instanceof GramJs.ForumTopic && topic.readInboxMaxId) {
acc[topic.id] = topic.readInboxMaxId;
}
return acc;
}, {} as Record<number, number>);
return {
topics,
messages,
users,
chats,
// Include general topic
count: count + 1,
shouldOrderByCreateDate: orderByCreateDate,
draftsById,
readInboxMessageIdByTopicId,
};
}
export async function fetchTopicById({
chat, topicId,
}: {
chat: ApiChat;
topicId: number;
}): Promise<{
topic: ApiTopic;
messages: ApiMessage[];
users: ApiUser[];
chats: ApiChat[];
} | undefined> {
const { id, accessHash } = chat;
const result = await invokeRequest(new GramJs.channels.GetForumTopicsByID({
channel: buildInputPeer(id, accessHash),
topics: [topicId],
}));
if (!result?.topics.length || !(result.topics[0] instanceof GramJs.ForumTopic)) {
return undefined;
}
updateLocalDb(result);
const messages = result.messages.map(buildApiMessage).filter(Boolean);
const users = result.users.map(buildApiUser).filter(Boolean);
const chats = result.chats.map((c) => buildApiChatFromPreview(c)).filter(Boolean);
return {
topic: buildApiTopic(result.topics[0])!,
messages,
users,
chats,
};
}
export function deleteTopic({
chat, topicId,
}: {
chat: ApiChat;
topicId: number;
}) {
const { id, accessHash } = chat;
return invokeRequest(new GramJs.channels.DeleteTopicHistory({
channel: buildInputPeer(id, accessHash),
topMsgId: topicId,
}), true);
}
export function togglePinnedTopic({
chat, topicId, isPinned,
}: {
chat: ApiChat;
topicId: number;
isPinned: boolean;
}) {
const { id, accessHash } = chat;
return invokeRequest(new GramJs.channels.UpdatePinnedForumTopic({
channel: buildInputPeer(id, accessHash),
topicId,
pinned: isPinned,
}), true);
}
export function editTopic({
chat, topicId, title, iconEmojiId, isClosed, isHidden,
}: {
chat: ApiChat;
topicId: number;
title?: string;
iconEmojiId?: string;
isClosed?: boolean;
isHidden?: boolean;
}) {
const { id, accessHash } = chat;
return invokeRequest(new GramJs.channels.EditForumTopic({
channel: buildInputPeer(id, accessHash),
topicId,
title,
iconEmojiId: iconEmojiId ? BigInt(iconEmojiId) : undefined,
closed: isClosed,
hidden: isHidden,
}), true);
}

View File

@ -52,6 +52,7 @@ export async function init(_onUpdate: OnApiUpdate, initialArgs: ApiInitialArgs)
const {
userAgent, platform, sessionData, isTest, isMovSupported, isWebmSupported, maxBufferSize, webAuthToken, dcId,
mockScenario,
} = initialArgs;
const session = new sessions.CallbackSession(sessionData, onSessionUpdate);
@ -104,6 +105,7 @@ export async function init(_onUpdate: OnApiUpdate, initialArgs: ApiInitialArgs)
shouldThrowIfUnauthorized: Boolean(sessionData),
webAuthToken,
webAuthTokenFailed: onWebAuthTokenFailed,
mockScenario,
});
} catch (err: any) {
// eslint-disable-next-line no-console

View File

@ -12,14 +12,15 @@ export {
export {
fetchChats, fetchFullChat, searchChats, requestChatUpdate, fetchChatSettings,
saveDraft, clearDraft, fetchChat, updateChatMutedState,
saveDraft, clearDraft, fetchChat, updateChatMutedState, updateTopicMutedState,
createChannel, joinChannel, deleteChatUser, deleteChat, leaveChannel, deleteChannel, createGroupChat, editChatPhoto,
toggleChatPinned, toggleChatArchived, toggleDialogUnread, setChatEnabledReactions,
fetchChatFolders, editChatFolder, deleteChatFolder, sortChatFolders, fetchRecommendedChatFolders,
getChatByUsername, togglePreHistoryHidden, updateChatDefaultBannedRights, updateChatMemberBannedRights,
updateChatTitle, updateChatAbout, toggleSignatures, updateChatAdmin, fetchGroupsForDiscussion, setDiscussionGroup,
migrateChat, openChatByInvite, fetchMembers, importChatInvite, addChatMembers, deleteChatMember, toggleIsProtected,
getChatByPhoneNumber, toggleJoinToSend, toggleJoinRequest,
getChatByPhoneNumber, toggleJoinToSend, toggleJoinRequest, fetchTopics, deleteTopic, togglePinnedTopic,
editTopic, toggleForum, fetchTopicById,
} from './chats';
export {

View File

@ -93,7 +93,7 @@ export async function fetchMessages({
result = await invokeRequest(new RequestClass({
peer: buildInputPeer(chat.id, chat.accessHash),
...(threadId !== MAIN_THREAD_ID && {
msgId: threadId,
msgId: Number(threadId),
}),
...(offsetId && {
// Workaround for local message IDs overflowing some internal `Buffer` range check
@ -255,6 +255,7 @@ export function sendMessage(
sendAs,
serverTimeOffset,
);
onUpdate({
'@type': localMessage.isScheduled ? 'newScheduledMessage' : 'newMessage',
id: localMessage.id,
@ -280,7 +281,15 @@ export function sendMessage(
if (groupedId) {
return sendGroupedMedia({
chat, text, entities, replyingTo, attachment: attachment!, groupedId, isSilent, scheduledAt,
chat,
text,
entities,
replyingTo,
replyingToTopId,
attachment: attachment!,
groupedId,
isSilent,
scheduledAt,
}, randomId, localMessage, onProgress);
}
@ -328,6 +337,7 @@ export function sendMessage(
...(isSilent && { silent: isSilent }),
...(scheduledAt && { scheduleDate: scheduledAt }),
...(replyingTo && { replyToMsgId: replyingTo }),
...(replyingToTopId && { topMsgId: replyingToTopId }),
...(media && { media }),
...(noWebPage && { noWebpage: noWebPage }),
...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }),
@ -349,6 +359,7 @@ function sendGroupedMedia(
text,
entities,
replyingTo,
replyingToTopId,
attachment,
groupedId,
isSilent,
@ -359,6 +370,7 @@ function sendGroupedMedia(
text?: string;
entities?: ApiMessageEntity[];
replyingTo?: number;
replyingToTopId?: number;
attachment: ApiAttachment;
groupedId: string;
isSilent?: boolean;
@ -434,6 +446,7 @@ function sendGroupedMedia(
peer: buildInputPeer(chat.id, chat.accessHash),
multiMedia: Object.values(singleMediaByIndex), // Object keys are usually ordered
replyToMsgId: replyingTo,
...(replyingToTopId && { topMsgId: replyingToTopId }),
...(isSilent && { silent: isSilent }),
...(scheduledAt && { scheduleDate: scheduledAt }),
...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }),
@ -628,9 +641,10 @@ export async function pinMessage({
}), true);
}
export async function unpinAllMessages({ chat }: { chat: ApiChat }) {
export async function unpinAllMessages({ chat, threadId }: { chat: ApiChat; threadId?: number }) {
await invokeRequest(new GramJs.messages.UnpinAllMessages({
peer: buildInputPeer(chat.id, chat.accessHash),
...(threadId && { topMsgId: threadId }),
}), true);
}
@ -834,14 +848,18 @@ export async function requestThreadInfoUpdate({
}: {
chat: ApiChat; threadId: number;
}) {
if (threadId === MAIN_THREAD_ID) {
return undefined;
}
const [topMessageResult, repliesResult] = await Promise.all([
invokeRequest(new GramJs.messages.GetDiscussionMessage({
peer: buildInputPeer(chat.id, chat.accessHash),
msgId: threadId,
msgId: Number(threadId),
})),
invokeRequest(new GramJs.messages.GetReplies({
peer: buildInputPeer(chat.id, chat.accessHash),
msgId: threadId,
msgId: Number(threadId),
offsetId: 1,
addOffset: -1,
limit: 1,
@ -882,6 +900,14 @@ export async function requestThreadInfoUpdate({
});
});
if (chat.isForum) {
onUpdate({
'@type': 'updateTopic',
chatId: chat.id,
topicId: threadId,
});
}
return {
discussionChatId,
};
@ -928,9 +954,9 @@ export async function searchMessagesLocal({
const result = await invokeRequest(new GramJs.messages.Search({
peer: buildInputPeer(chat.id, chat.accessHash),
topMsgId: topMessageId,
filter,
q: query || '',
topMsgId: topMessageId,
minDate,
maxDate,
...pagination,
@ -1154,6 +1180,7 @@ export async function fetchExtendedMedia({
export async function forwardMessages({
fromChat,
toChat,
toThreadId,
messages,
serverTimeOffset,
isSilent,
@ -1166,6 +1193,7 @@ export async function forwardMessages({
}: {
fromChat: ApiChat;
toChat: ApiChat;
toThreadId?: number;
messages: ApiMessage[];
serverTimeOffset: number;
isSilent?: boolean;
@ -1180,9 +1208,16 @@ export async function forwardMessages({
const randomIds = messages.map(generateRandomBigInt);
messages.forEach((message, index) => {
const localMessage = buildLocalForwardedMessage(
toChat, message, serverTimeOffset, scheduledAt, noAuthors, noCaptions, isCurrentUserPremium,
);
const localMessage = buildLocalForwardedMessage({
toChat,
toThreadId,
message,
serverTimeOffset,
scheduledAt,
noAuthors,
noCaptions,
isCurrentUserPremium,
});
localDb.localMessages[String(randomIds[index])] = localMessage;
onUpdate({
@ -1202,6 +1237,7 @@ export async function forwardMessages({
silent: isSilent || undefined,
dropAuthor: noAuthors || undefined,
dropMediaCaptions: noCaptions || undefined,
...(toThreadId && { topMsgId: toThreadId }),
...(scheduledAt && { scheduleDate: scheduledAt }),
...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }),
}), true);
@ -1281,13 +1317,14 @@ function updateLocalDb(result: (
});
}
export async function fetchPinnedMessages({ chat }: { chat: ApiChat }) {
export async function fetchPinnedMessages({ chat, threadId }: { chat: ApiChat; threadId: number }) {
const result = await invokeRequest(new GramJs.messages.Search(
{
peer: buildInputPeer(chat.id, chat.accessHash),
filter: new GramJs.InputMessagesFilterPinned(),
q: '',
limit: PINNED_MESSAGES_LIMIT,
topMsgId: threadId,
},
));

View File

@ -39,7 +39,7 @@ import {
} from './gramjsBuilders';
import localDb from './localDb';
import { omitVirtualClassFields } from './apiBuilders/helpers';
import { DEBUG } from '../../config';
import { DEBUG, GENERAL_TOPIC_ID } from '../../config';
import {
addMessageToLocalDb,
addEntitiesWithPhotosToLocalDb,
@ -49,7 +49,12 @@ import {
log,
swapLocalInvoiceMedia,
} from './helpers';
import { buildApiNotifyException, buildPrivacyKey, buildPrivacyRules } from './apiBuilders/misc';
import {
buildApiNotifyException,
buildApiNotifyExceptionTopic,
buildPrivacyKey,
buildPrivacyRules,
} from './apiBuilders/misc';
import { buildApiPhoto, buildApiUsernames } from './apiBuilders/common';
import {
buildApiGroupCall,
@ -149,6 +154,13 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) {
let message: ApiMessage | undefined;
let shouldForceReply: boolean | undefined;
// eslint-disable-next-line no-underscore-dangle
const entities = update._entities;
if (entities) {
addEntitiesWithPhotosToLocalDb(entities);
dispatchUserAndChatUpdates(entities);
}
if (update instanceof GramJs.UpdateShortChatMessage) {
message = buildApiMessageFromShortChat(update);
} else if (update instanceof GramJs.UpdateShortMessage) {
@ -174,13 +186,6 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) {
&& (!update.message.replyMarkup.selective || message.isMentioned);
}
// eslint-disable-next-line no-underscore-dangle
const entities = update._entities;
if (entities) {
addEntitiesWithPhotosToLocalDb(entities);
dispatchUserAndChatUpdates(entities);
}
if (update instanceof GramJs.UpdateNewScheduledMessage) {
onUpdate({
'@type': sentMessageIds.has(message.id) ? 'updateScheduledMessage' : 'newScheduledMessage',
@ -282,6 +287,23 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) {
},
});
}
} else if (action instanceof GramJs.MessageActionTopicEdit) {
const { replyTo } = update.message;
const {
replyToMsgId, replyToTopId, forumTopic: isTopicReply,
} = replyTo || {};
const topicId = !isTopicReply ? GENERAL_TOPIC_ID : replyToTopId || replyToMsgId || GENERAL_TOPIC_ID;
onUpdate({
'@type': 'updateTopic',
chatId: getApiChatIdFromMtpPeer(update.message.peerId!),
topicId,
});
} else if (action instanceof GramJs.MessageActionTopicCreate) {
onUpdate({
'@type': 'updateTopics',
chatId: getApiChatIdFromMtpPeer(update.message.peerId!),
});
}
}
} else if (
@ -655,6 +677,16 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) {
'@type': 'updateNotifyExceptions',
...buildApiNotifyException(update.notifySettings, update.peer.peer, serverTimeOffset),
});
} else if (
update instanceof GramJs.UpdateNotifySettings
&& update.peer instanceof GramJs.NotifyForumTopic
) {
onUpdate({
'@type': 'updateTopicNotifyExceptions',
...buildApiNotifyExceptionTopic(
update.notifySettings, update.peer.peer, update.peer.topMsgId, serverTimeOffset,
),
});
} else if (
update instanceof GramJs.UpdateUserTyping
|| update instanceof GramJs.UpdateChatUserTyping
@ -684,6 +716,7 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) {
onUpdate({
'@type': 'updateChatTypingStatus',
id,
threadId: update.topMsgId,
typingStatus: buildChatTypingStatus(update, serverTimeOffset),
});
} else if (update instanceof GramJs.UpdateChannel) {
@ -907,6 +940,7 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) {
onUpdate({
'@type': 'draftMessage',
chatId: getApiChatIdFromMtpPeer(update.peer),
threadId: update.topMsgId,
...buildMessageDraft(update.draft),
});
} else if (update instanceof GramJs.UpdateContactsReset) {
@ -1042,6 +1076,19 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) {
dispatchUserAndChatUpdates(entities);
}
onUpdate({ '@type': 'updateConfig' });
} else if (update instanceof GramJs.UpdateChannelPinnedTopic) {
onUpdate({
'@type': 'updatePinnedTopic',
chatId: buildApiPeerId(update.channelId, 'channel'),
topicId: update.topicId,
isPinned: Boolean(update.pinned),
});
} else if (update instanceof GramJs.UpdateChannelPinnedTopics) {
onUpdate({
'@type': 'updatePinnedTopicsOrder',
chatId: buildApiPeerId(update.channelId, 'channel'),
order: update.order || [],
});
} else if (DEBUG) {
const params = typeof update === 'object' && 'className' in update ? update.className : update;
log('UNEXPECTED UPDATE', params);

View File

@ -15,7 +15,7 @@ export interface ApiChat {
id: string;
folderId?: number;
type: ApiChatType;
title?: string;
title: string;
hasUnreadMark?: boolean;
lastMessage?: ApiMessage;
lastReadOutboxMessageId?: number;
@ -39,6 +39,10 @@ export interface ApiChat {
draftDate?: number;
isProtected?: boolean;
fakeType?: ApiFakeType;
isForum?: boolean;
topics?: Record<number, ApiTopic>;
topicsCount?: number;
orderedPinnedTopicIds?: number[];
// Calls
isCallActive?: boolean;
@ -64,8 +68,6 @@ export interface ApiChat {
settings?: ApiChatSettings;
// Obtained from GetFullChat / GetFullChannel
fullInfo?: ApiChatFullInfo;
// Obtained with UpdateUserTyping or UpdateChatUserTyping updates
typingStatus?: ApiTypingStatus;
joinRequests?: ApiChatInviteImporter[];
isJoinToSend?: boolean;
@ -137,6 +139,7 @@ export interface ApiChatAdminRights {
addAdmins?: true;
anonymous?: true;
manageCall?: true;
manageTopics?: true;
}
export interface ApiChatBannedRights {
@ -153,6 +156,7 @@ export interface ApiChatBannedRights {
inviteUsers?: true;
pinMessages?: true;
untilDate?: number;
manageTopics?: true;
}
export interface ApiRestrictionReason {
@ -189,3 +193,25 @@ export interface ApiSendAsPeerId {
id: string;
isPremium?: boolean;
}
export interface ApiTopic {
id: number;
isClosed?: boolean;
isPinned?: boolean;
isHidden?: boolean;
isOwner?: boolean;
// eslint-disable-next-line max-len
// TODO[forums] https://github.com/telegramdesktop/tdesktop/blob/1aece79a471d99a8b63d826b1bce1f36a04d7293/Telegram/SourceFiles/data/data_forum_topic.cpp#L318
isMin?: boolean;
date: number;
title: string;
iconColor: number;
iconEmojiId?: string;
lastMessageId: number;
unreadCount: number;
unreadMentionsCount: number;
unreadReactionsCount: number;
fromId: string;
isMuted?: boolean;
}

View File

@ -259,7 +259,7 @@ export interface ApiAction {
text: string;
targetUserIds?: string[];
targetChatId?: string;
type: 'historyClear' | 'contactSignUp' | 'chatCreate' | 'other';
type: 'historyClear' | 'contactSignUp' | 'chatCreate' | 'topicCreate' | 'other';
photo?: ApiPhoto;
amount?: number;
currency?: string;
@ -390,6 +390,7 @@ export interface ApiMessage {
replyToChatId?: string;
replyToMessageId?: number;
replyToTopMessageId?: number;
isTopicReply?: true;
sendingState?: 'messageSendingStatePending' | 'messageSendingStateFailed';
forwardInfo?: ApiMessageForwardInfo;
isDeleting?: boolean;

View File

@ -12,6 +12,7 @@ export interface ApiInitialArgs {
maxBufferSize?: number;
webAuthToken?: string;
dcId?: number;
mockScenario?: string;
}
export interface ApiOnProgress {
@ -174,6 +175,7 @@ export interface ApiAppConfig {
premiumPromoOrder: string[];
defaultEmojiStatusesStickerSetId: string;
maxUniqueReactions: number;
topicsPinnedLimit: number;
maxUserReactionsDefault: number;
maxUserReactionsPremium: number;
limits: Record<ApiLimitType, readonly [number, number]>;

View File

@ -121,6 +121,7 @@ export type ApiUpdateChatInbox = {
export type ApiUpdateChatTypingStatus = {
'@type': 'updateChatTypingStatus';
id: string;
threadId?: number;
typingStatus: ApiTypingStatus | undefined;
};
@ -184,7 +185,7 @@ export type ApiUpdateNewScheduledMessage = {
'@type': 'newScheduledMessage';
chatId: string;
id: number;
message: Partial<ApiMessage>;
message: ApiMessage;
};
export type ApiUpdateNewMessage = {
@ -309,6 +310,7 @@ export type ApiUpdateResetMessages = {
export type ApiUpdateDraftMessage = {
'@type': 'draftMessage';
chatId: string;
threadId?: number;
formattedText?: ApiFormattedText;
date?: number;
replyingToId?: number;
@ -433,6 +435,11 @@ export type ApiUpdateNotifyExceptions = {
'@type': 'updateNotifyExceptions';
} & ApiNotifyException;
export type ApiUpdateTopicNotifyExceptions = {
'@type': 'updateTopicNotifyExceptions';
topicId: number;
} & ApiNotifyException;
export type ApiUpdateTwoFaStateWaitCode = {
'@type': 'updateTwoFaStateWaitCode';
length: number;
@ -563,6 +570,30 @@ export type ApiUpdateTranscribedAudio = {
isPending?: boolean;
};
export type ApiUpdatePinnedTopic = {
'@type': 'updatePinnedTopic';
topicId: number;
chatId: string;
isPinned: boolean;
};
export type ApiUpdatePinnedTopicsOrder = {
'@type': 'updatePinnedTopicsOrder';
chatId: string;
order: number[];
};
export type ApiUpdateTopic = {
'@type': 'updateTopic';
chatId: string;
topicId: number;
};
export type ApiUpdateTopics = {
'@type': 'updateTopics';
chatId: string;
};
export type ApiUpdate = (
ApiUpdateReady | ApiUpdateSession | ApiUpdateWebAuthTokenFailed |
ApiUpdateAuthorizationState | ApiUpdateAuthorizationError | ApiUpdateConnectionState | ApiUpdateCurrentUser |
@ -587,7 +618,8 @@ export type ApiUpdate = (
ApiUpdatePendingJoinRequests | ApiUpdatePaymentVerificationNeeded | ApiUpdatePaymentStateCompleted |
ApiUpdatePhoneCall | ApiUpdatePhoneCallSignalingData | ApiUpdatePhoneCallMediaState |
ApiUpdatePhoneCallConnectionState | ApiUpdateBotMenuButton | ApiUpdateTranscribedAudio | ApiUpdateUserEmojiStatus |
ApiUpdateMessageExtendedMedia | ApiUpdateConfig
ApiUpdateMessageExtendedMedia | ApiUpdateConfig | ApiUpdateTopicNotifyExceptions | ApiUpdatePinnedTopic |
ApiUpdatePinnedTopicsOrder | ApiUpdateTopic | ApiUpdateTopics
);
export type OnApiUpdate = (update: ApiUpdate) => void;

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="84" height="84"><defs><linearGradient id="a" x1="50%" x2="50%" y1="0%" y2="100%"><stop offset="0%" stop-color="#4BB7FF"/><stop offset="100%" stop-color="#015EC1"/></linearGradient><linearGradient id="b" x1="50%" x2="50%" y1="0%" y2="100%"><stop offset="0%" stop-color="#0888DF"/><stop offset="100%" stop-color="#0042AC"/></linearGradient></defs><g fill="none" fill-rule="evenodd"><path fill="url(#a)" stroke="url(#b)" stroke-width="2.947" d="M42 4.474c10.654 0 20.303 3.968 27.286 10.398 6.937 6.387 11.24 15.208 11.24 24.958 0 9.751-4.303 18.572-11.24 24.96C62.303 71.22 52.654 75.186 42 75.186a41.582 41.582 0 0 1-13.414-2.202c-.191.122-.386.244-.584.368-1.822 1.138-3.425 2.065-4.812 2.78-3.227 1.664-7.306 2.762-12.221 3.324l-.636.07c.515-.989.983-1.9 1.403-2.737l.35-.699c1.069-2.154 1.789-3.757 2.143-4.842.586-1.799.88-3.579 1.007-5.216.019-.24.034-.485.045-.734a37.536 37.536 0 0 1-.518-.466c-6.966-6.39-11.29-15.23-11.29-25.003 0-9.75 4.304-18.57 11.24-24.958C21.698 8.442 31.347 4.474 42 4.474Z"/><path fill="#71D0FF" d="M9.68 24.614a.501.501 0 0 0 .73-.006C20.518 13.728 31.314 8.501 42.8 8.93c11.46.427 22.125 6.44 31.995 18.037a.693.693 0 0 0 .941.106.705.705 0 0 0 .174-.944C67.782 13.338 56.745 6.691 42.8 6.188 28.822 5.686 17.759 11.55 9.607 23.78a.669.669 0 0 0 .074.834Z" opacity=".375"/></g></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="84" height="84"><defs><linearGradient id="a" x1="50%" x2="50%" y1="0%" y2="100%"><stop offset="0%" stop-color="#97E334"/><stop offset="100%" stop-color="#11B411"/></linearGradient><linearGradient id="b" x1="50%" x2="50%" y1="0%" y2="100%"><stop offset="0%" stop-color="#48AF18"/><stop offset="100%" stop-color="#05951A"/></linearGradient></defs><g fill="none" fill-rule="evenodd"><path fill="url(#a)" stroke="url(#b)" stroke-width="2.842" d="M42 4.421c10.668 0 20.33 3.974 27.322 10.412C76.27 21.23 80.579 30.065 80.579 39.83c0 9.766-4.31 18.6-11.257 24.998C62.329 71.266 52.668 75.24 42 75.24a41.635 41.635 0 0 1-13.407-2.197l-.563.355c-1.823 1.139-3.428 2.067-4.816 2.782-3.231 1.666-7.317 2.767-12.24 3.33l-.636.07c2.004-4.03 3.32-6.75 3.84-8.348.586-1.794.88-3.57 1.006-5.203.018-.232.033-.467.044-.706a37.29 37.29 0 0 1-.5-.451C7.75 58.472 3.42 49.619 3.42 39.83c0-9.765 4.31-18.6 11.257-24.997C21.671 8.395 31.332 4.42 42 4.42Z"/><path fill="#C2FF71" d="M9.68 24.614a.501.501 0 0 0 .73-.006C20.518 13.728 31.314 8.501 42.8 8.93c11.46.427 22.125 6.44 31.995 18.037a.693.693 0 0 0 .941.106.705.705 0 0 0 .174-.944C67.782 13.338 56.745 6.691 42.8 6.188 28.822 5.686 17.759 11.55 9.607 23.78a.669.669 0 0 0 .074.834Z" opacity=".375"/></g></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="84" height="84"><defs><linearGradient id="a" x1="50%" x2="50%" y1="0%" y2="100%"><stop offset="0%" stop-color="#A5A5A5"/><stop offset="100%" stop-color="#616161"/></linearGradient><linearGradient id="b" x1="50%" x2="50%" y1="0%" y2="99.396%"><stop offset="0%" stop-color="#737373"/><stop offset="100%" stop-color="#565656"/></linearGradient></defs><g fill="none" fill-rule="evenodd"><path fill="url(#a)" stroke="url(#b)" stroke-width="2.947" d="M42 4.474c10.654 0 20.303 3.968 27.286 10.398 6.937 6.387 11.24 15.208 11.24 24.958 0 9.751-4.303 18.572-11.24 24.96C62.303 71.22 52.654 75.186 42 75.186a41.582 41.582 0 0 1-13.414-2.202c-.191.122-.386.244-.584.368-1.822 1.138-3.425 2.065-4.812 2.78-3.227 1.664-7.306 2.762-12.221 3.324l-.636.07c.515-.989.983-1.9 1.403-2.737l.35-.699c1.069-2.154 1.789-3.757 2.143-4.842.586-1.799.88-3.579 1.007-5.216.019-.24.034-.485.045-.734a37.536 37.536 0 0 1-.518-.466c-6.966-6.39-11.29-15.23-11.29-25.003 0-9.75 4.304-18.57 11.24-24.958C21.698 8.442 31.347 4.474 42 4.474Z"/><path fill="#B8B8B8" d="M9.68 24.614a.501.501 0 0 0 .73-.006C20.518 13.728 31.314 8.501 42.8 8.93c11.46.427 22.125 6.44 31.995 18.037a.693.693 0 0 0 .941.106.705.705 0 0 0 .174-.944C67.782 13.338 56.745 6.691 42.8 6.188 28.822 5.686 17.759 11.55 9.607 23.78a.669.669 0 0 0 .074.834Z" opacity=".375"/></g></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="84" height="84"><defs><linearGradient id="a" x1="50%" x2="50%" y1="0%" y2="100%"><stop offset="0%" stop-color="#FF714C"/><stop offset="100%" stop-color="#C61505"/></linearGradient><linearGradient id="b" x1="50%" x2="50%" y1="0%" y2="98.606%"><stop offset="0%" stop-color="#E12F1F"/><stop offset="100%" stop-color="#B40101"/></linearGradient></defs><g fill="none" fill-rule="evenodd"><path fill="url(#a)" stroke="url(#b)" stroke-width="2.947" d="M42 4.474c10.654 0 20.303 3.968 27.286 10.398 6.937 6.387 11.24 15.208 11.24 24.958 0 9.751-4.303 18.572-11.24 24.96C62.303 71.22 52.654 75.186 42 75.186a41.582 41.582 0 0 1-13.414-2.202c-.191.122-.386.244-.584.368-1.822 1.138-3.425 2.065-4.812 2.78-3.227 1.664-7.306 2.762-12.221 3.324l-.636.07c.515-.989.983-1.9 1.403-2.737l.35-.699c1.069-2.154 1.789-3.757 2.143-4.842.586-1.799.88-3.579 1.007-5.216.019-.24.034-.485.045-.734a37.536 37.536 0 0 1-.518-.466c-6.966-6.39-11.29-15.23-11.29-25.003 0-9.75 4.304-18.57 11.24-24.958C21.698 8.442 31.347 4.474 42 4.474Z"/><path fill="#FFB47D" d="M9.68 24.614a.501.501 0 0 0 .73-.006C20.518 13.728 31.314 8.501 42.8 8.93c11.46.427 22.125 6.44 31.995 18.037a.693.693 0 0 0 .941.106.705.705 0 0 0 .174-.944C67.782 13.338 56.745 6.691 42.8 6.188 28.822 5.686 17.759 11.55 9.607 23.78a.669.669 0 0 0 .074.834Z" opacity=".375"/></g></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="84" height="84"><defs><linearGradient id="a" x1="50%" x2="50%" y1="4.314%" y2="99.602%"><stop offset="0%" stop-color="#FF7999"/><stop offset="98.597%" stop-color="#E4215A"/></linearGradient><linearGradient id="b" x1="50%" x2="50%" y1="0%" y2="96.402%"><stop offset="0%" stop-color="#F83B72"/><stop offset="100%" stop-color="#BA0940"/></linearGradient></defs><g fill="none" fill-rule="evenodd"><path fill="url(#a)" stroke="url(#b)" stroke-width="2.842" d="M42 4.421c10.668 0 20.33 3.974 27.322 10.412C76.27 21.23 80.579 30.065 80.579 39.83c0 9.766-4.31 18.6-11.257 24.998C62.329 71.266 52.668 75.24 42 75.24a41.635 41.635 0 0 1-13.407-2.197l-.563.355c-1.823 1.139-3.428 2.067-4.816 2.782-3.231 1.666-7.317 2.767-12.24 3.33l-.636.07c2.004-4.03 3.32-6.75 3.84-8.348.586-1.794.88-3.57 1.006-5.203.018-.232.033-.467.044-.706a37.29 37.29 0 0 1-.5-.451C7.75 58.472 3.42 49.619 3.42 39.83c0-9.765 4.31-18.6 11.257-24.997C21.671 8.395 31.332 4.42 42 4.42Z"/><path fill="#FFC7D6" d="M9.68 24.614a.501.501 0 0 0 .73-.006C20.518 13.728 31.314 8.501 42.8 8.93c11.46.427 22.125 6.44 31.995 18.037a.693.693 0 0 0 .941.106.705.705 0 0 0 .174-.944C67.782 13.338 56.745 6.691 42.8 6.188 28.822 5.686 17.759 11.55 9.607 23.78a.669.669 0 0 0 .074.834Z" opacity=".375"/></g></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="84" height="84"><defs><linearGradient id="a" x1="50%" x2="50%" y1="0%" y2="99.764%"><stop offset="0%" stop-color="#E57AFF"/><stop offset="99.855%" stop-color="#A438BB"/></linearGradient><linearGradient id="b" x1="50%" x2="50%" y1="0%" y2="99.396%"><stop offset="0%" stop-color="#B239D1"/><stop offset="100%" stop-color="#7C279A"/></linearGradient></defs><g fill="none" fill-rule="evenodd"><path fill="url(#a)" stroke="url(#b)" stroke-width="2.842" d="M42 4.421c10.668 0 20.33 3.974 27.322 10.412C76.27 21.23 80.579 30.065 80.579 39.83c0 9.766-4.31 18.6-11.257 24.998C62.329 71.266 52.668 75.24 42 75.24a41.635 41.635 0 0 1-13.407-2.197l-.563.355c-1.823 1.139-3.428 2.067-4.816 2.782-3.231 1.666-7.317 2.767-12.24 3.33l-.636.07c2.004-4.03 3.32-6.75 3.84-8.348.586-1.794.88-3.57 1.006-5.203.018-.232.033-.467.044-.706a37.29 37.29 0 0 1-.5-.451C7.75 58.472 3.42 49.619 3.42 39.83c0-9.765 4.31-18.6 11.257-24.997C21.671 8.395 31.332 4.42 42 4.42Z"/><path fill="#F5BDFF" d="M9.68 24.614a.501.501 0 0 0 .73-.006C20.518 13.728 31.314 8.501 42.8 8.93c11.46.427 22.125 6.44 31.995 18.037a.693.693 0 0 0 .941.106.705.705 0 0 0 .174-.944C67.782 13.338 56.745 6.691 42.8 6.188 28.822 5.686 17.759 11.55 9.607 23.78a.669.669 0 0 0 .074.834Z" opacity=".375"/></g></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="84" height="84"><defs><linearGradient id="a" x1="50%" x2="50%" y1="0%" y2="100%"><stop offset="0%" stop-color="#FFDB5C"/><stop offset="100%" stop-color="#EA5800"/></linearGradient><linearGradient id="b" x1="50%" x2="50%" y1="0%" y2="99.014%"><stop offset="0%" stop-color="#F2A807"/><stop offset="100%" stop-color="#D93A00"/></linearGradient></defs><g fill="none" fill-rule="evenodd"><path fill="url(#a)" stroke="url(#b)" stroke-width="2.842" d="M42 4.421c10.668 0 20.33 3.974 27.322 10.412C76.27 21.23 80.579 30.065 80.579 39.83c0 9.766-4.31 18.6-11.257 24.998C62.329 71.266 52.668 75.24 42 75.24a41.635 41.635 0 0 1-13.407-2.197l-.563.355c-1.823 1.139-3.428 2.067-4.816 2.782-3.231 1.666-7.317 2.767-12.24 3.33l-.636.07c2.004-4.03 3.32-6.75 3.84-8.348.586-1.794.88-3.57 1.006-5.203.018-.232.033-.467.044-.706a37.29 37.29 0 0 1-.5-.451C7.75 58.472 3.42 49.619 3.42 39.83c0-9.765 4.31-18.6 11.257-24.997C21.671 8.395 31.332 4.42 42 4.42Z"/><path fill="#F9FF71" d="M9.68 24.614a.501.501 0 0 0 .73-.006C20.518 13.728 31.314 8.501 42.8 8.93c11.46.427 22.125 6.44 31.995 18.037a.693.693 0 0 0 .941.106.705.705 0 0 0 .174-.944C67.782 13.338 56.745 6.691 42.8 6.188 28.822 5.686 17.759 11.55 9.607 23.78a.669.669 0 0 0 .074.834Z" opacity=".375"/></g></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -20,6 +20,7 @@ import './GroupCallTopPane.scss';
type OwnProps = {
chatId: string;
hasPinnedOffset: boolean;
className?: string;
};
type StateProps = {
@ -33,6 +34,7 @@ type StateProps = {
const GroupCallTopPane: FC<OwnProps & StateProps> = ({
chatId,
isActive,
className,
groupCall,
hasPinnedOffset,
usersById,
@ -97,6 +99,7 @@ const GroupCallTopPane: FC<OwnProps & StateProps> = ({
'GroupCallTopPane',
hasPinnedOffset && 'has-pinned-offset',
!isActive && 'is-hidden',
className,
)}
onClick={handleJoinGroupCall}
>

View File

@ -6,6 +6,10 @@ $animation-time: 0.15s;
.root {
display: inline-flex;
white-space: pre;
&[dir="rtl"] {
flex-direction: row-reverse;
}
}
.character-container {

View File

@ -6,6 +6,7 @@ import { ANIMATION_LEVEL_MAX } from '../../config';
import usePrevious from '../../hooks/usePrevious';
import useForceUpdate from '../../hooks/useForceUpdate';
import useTimeout from '../../hooks/useTimeout';
import useLang from '../../hooks/useLang';
import styles from './AnimatedCounter.module.scss';
@ -18,6 +19,8 @@ const ANIMATION_TIME = 150;
const AnimatedCounter: FC<OwnProps> = ({
text,
}) => {
const lang = useLang();
const prevText = usePrevious(text);
const forceUpdate = useForceUpdate();
@ -54,7 +57,7 @@ const AnimatedCounter: FC<OwnProps> = ({
}, shouldAnimate && isAnimatingRef.current ? ANIMATION_TIME : undefined);
return (
<span className={styles.root}>
<span className={styles.root} dir={lang.isRtl ? 'rtl' : undefined}>
{textElement}
</span>
);

View File

@ -1,11 +1,12 @@
.Avatar {
--color-user: var(--color-primary);
--radius: 50%;
flex: none;
align-items: center;
justify-content: center;
width: 3.375rem;
height: 3.375rem;
border-radius: 50%;
border-radius: var(--radius);
background: linear-gradient(var(--color-white) -125%, var(--color-user));
color: white;
font-weight: bold;
@ -15,7 +16,7 @@
position: relative;
&__media {
border-radius: 50%;
border-radius: var(--radius);
width: 100%;
height: 100%;
}
@ -26,11 +27,7 @@
}
&__icon {
font-size: 2.5rem;
&.icon-reply-filled {
transform: scale(0.7);
}
font-size: 1.25rem;
}
&.size-micro {
@ -81,7 +78,7 @@
font-size: 1.3125rem;
i {
font-size: 3.5rem;
font-size: 1.625rem;
}
.emoji {
@ -131,4 +128,8 @@
top: 0;
z-index: 0;
}
&.forum {
--radius: var(--border-radius-forum-avatar);
}
}

View File

@ -83,6 +83,7 @@ const Avatar: FC<OwnProps> = ({
const isIntersecting = useIsIntersecting(ref, observeIntersection);
const isDeleted = user && isDeletedUser(user);
const isReplies = user && isChatWithRepliesBot(user.id);
const isForum = chat?.isForum;
let imageHash: string | undefined;
let videoHash: string | undefined;
@ -184,6 +185,7 @@ const Avatar: FC<OwnProps> = ({
isSavedMessages && 'saved-messages',
isDeleted && 'deleted-account',
isReplies && 'replies-bot-account',
isForum && 'forum',
isOnline && 'online',
onClick && 'interactive',
(!isSavedMessages && !imgBlobUrl) && 'no-photo',

View File

@ -11,10 +11,17 @@ import type {
import { TME_LINK_PREFIX } from '../../config';
import {
selectChat, selectNotifyExceptions, selectNotifySettings, selectUser,
selectChat, selectCurrentMessageList, selectNotifyExceptions, selectNotifySettings, selectUser,
} from '../../global/selectors';
import {
getChatDescription, getChatLink, getHasAdminRight, isChatChannel, isUserId, isUserRightBanned, selectIsChatMuted,
getChatDescription,
getChatLink,
getTopicLink,
getHasAdminRight,
isChatChannel,
isUserId,
isUserRightBanned,
selectIsChatMuted,
} from '../../global/helpers';
import renderText from './helpers/renderText';
import { copyTextToClipboard } from '../../util/clipboard';
@ -38,6 +45,8 @@ type StateProps =
canInviteUsers?: boolean;
isMuted?: boolean;
phoneCodeList: ApiCountryCode[];
isForum?: boolean;
topicId?: number;
}
& Pick<GlobalState, 'lastSyncTime'>;
@ -51,11 +60,14 @@ const ChatExtra: FC<OwnProps & StateProps> = ({
canInviteUsers,
isMuted,
phoneCodeList,
isForum,
topicId,
}) => {
const {
loadFullUser,
showNotification,
updateChatMutedState,
updateTopicMutedState,
} = getActions();
const {
@ -84,19 +96,35 @@ const ChatExtra: FC<OwnProps & StateProps> = ({
return result?.length ? result : undefined;
}, [chatUsernames, user]);
const link = useMemo(() => (chat ? getChatLink(chat) : undefined), [chat]);
const link = useMemo(() => {
if (!chat) {
return undefined;
}
return isForum
? getTopicLink(chat.id, activeChatUsernames?.[0].username, topicId)
: getChatLink(chat);
}, [chat, isForum, activeChatUsernames, topicId]);
const handleNotificationChange = useCallback(() => {
setAreNotificationsEnabled((current) => {
const newAreNotificationsEnabled = !current;
runDebounced(() => {
updateChatMutedState({ chatId, isMuted: !newAreNotificationsEnabled });
if (topicId) {
updateTopicMutedState({
chatId: chatId!,
topicId,
isMuted: !newAreNotificationsEnabled,
});
} else {
updateChatMutedState({ chatId, isMuted: !newAreNotificationsEnabled });
}
});
return newAreNotificationsEnabled;
});
}, [chatId, updateChatMutedState]);
}, [chatId, topicId, updateChatMutedState, updateTopicMutedState]);
if (!chat || chat.isRestricted || (isSelf && !forceShowSelf)) {
return undefined;
@ -112,6 +140,7 @@ const ChatExtra: FC<OwnProps & StateProps> = ({
function renderUsernames(usernameList: ApiUsername[], isChat?: boolean) {
const [mainUsername, ...otherUsernames] = usernameList;
const usernameLinks = otherUsernames.length
? (lang('UsernameAlso', '%USERNAMES%') as string)
.split('%')
@ -139,6 +168,10 @@ const ChatExtra: FC<OwnProps & StateProps> = ({
})
: undefined;
const publicLink = isForum
? getTopicLink('', mainUsername.username, topicId)
: `@${mainUsername.username}`;
return (
<ListItem
icon="mention"
@ -146,9 +179,9 @@ const ChatExtra: FC<OwnProps & StateProps> = ({
narrow
ripple
// eslint-disable-next-line react/jsx-no-bind
onClick={() => copy(`@${mainUsername.username}`, lang(isChat ? 'Link' : 'Username'))}
onClick={() => copy(publicLink, lang(isChat ? 'Link' : 'Username'))}
>
<span className="title" dir="auto">{renderText(mainUsername.username)}</span>
<span className="title" dir="auto">{publicLink}</span>
<span className="subtitle">
{usernameLinks && <span className="other-usernames">{usernameLinks}</span>}
{lang(isChat ? 'Link' : 'Username')}
@ -215,7 +248,10 @@ export default memo(withGlobal<OwnProps>(
const chat = chatOrUserId ? selectChat(global, chatOrUserId) : undefined;
const user = isUserId(chatOrUserId) ? selectUser(global, chatOrUserId) : undefined;
const isForum = chat?.isForum;
const isMuted = chat && selectIsChatMuted(chat, selectNotifySettings(global), selectNotifyExceptions(global));
const { threadId } = selectCurrentMessageList(global) || {};
const topicId = isForum ? threadId : undefined;
const canInviteUsers = chat && !user && (
(!isChatChannel(chat) && !isUserRightBanned(chat, 'inviteUsers'))
@ -223,7 +259,14 @@ export default memo(withGlobal<OwnProps>(
);
return {
lastSyncTime, phoneCodeList, chat, user, canInviteUsers, isMuted,
lastSyncTime,
phoneCodeList,
chat,
user,
canInviteUsers,
isMuted,
isForum,
topicId,
};
},
)(ChatExtra));

View File

@ -0,0 +1,158 @@
$radius: 0.5rem;
.root {
--first-column-background-color: var(--color-item-active);
display: flex;
min-width: 0;
overflow: hidden;
margin-inline-end: 0.25rem;
flex-direction: column;
align-items: flex-start;
z-index: 3;
transition: 0.25s ease-out background-color;
pointer-events: none;
&:hover {
--first-column-background-color: var(--color-borders);
}
}
.title-row {
display: flex;
max-width: 100%;
}
.loading {
color: var(--color-text-secondary);
}
.other-column, .main-column {
display: flex;
align-items: center;
font-size: 0.9375rem;
color: var(--color-text-secondary);
}
.unread {
font-weight: 500;
color: var(--color-text);
&.main-column, &.last-message {
padding: 0 0.25rem;
}
&.main-column, &.last-message, .after-wrapper {
transition: background-color 0.15s ease-in-out;
background: var(--first-column-background-color);
}
}
.other-column {
margin-left: 0.25rem;
margin-right: 0.25rem;
display: inline;
}
.main-column {
border-start-start-radius: $radius;
border-start-end-radius: $radius;
max-width: 100%;
position: relative;
pointer-events: initial;
border-end-end-radius: $radius;
.after-wrapper {
width: $radius;
height: $radius;
bottom: 0;
position: absolute;
inset-inline-end: -$radius;
}
.after {
border-end-start-radius: $radius;
background: var(--background-color);
width: 100%;
height: 100%;
}
}
.title {
margin-left: 0.25rem;
font-size: 0.9375rem;
overflow: hidden;
text-overflow: ellipsis;
}
.other-column-title {
font-size: 0.9375rem;
margin-left: 0.25rem;
}
.other-columns {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.ellipsis {
margin-left: auto;
}
.last-message {
border-end-start-radius: $radius;
border-end-end-radius: $radius;
max-width: 100%;
pointer-events: initial;
position: relative;
.after-wrapper {
width: $radius;
height: $radius;
top: 0;
position: absolute;
inset-inline-end: -$radius;
}
.after {
border-start-start-radius: $radius;
background: var(--background-color);
width: 100%;
height: 100%;
}
}
.reverse-corner {
.main-column {
border-end-end-radius: 0;
}
.last-message {
border-start-end-radius: $radius;
}
}
.overwritten-width {
.last-message, .main-column {
min-width: var(--overwritten-width);
}
.last-message {
border-start-end-radius: 0;
}
.main-column {
border-end-end-radius: 0;
}
}

View File

@ -0,0 +1,147 @@
import React, {
memo,
useLayoutEffect,
useMemo,
useRef,
useState,
} from '../../lib/teact/teact';
import { getActions } from '../../global';
import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import type { FC } from '../../lib/teact/teact';
import type { ApiChat } from '../../api/types';
import { REM } from './helpers/mediaDimensions';
import buildClassName from '../../util/buildClassName';
import { getOrderedTopics } from '../../global/helpers';
import renderText from './helpers/renderText';
import useLang from '../../hooks/useLang';
import TopicIcon from './TopicIcon';
import styles from './ChatForumLastMessage.module.scss';
type OwnProps = {
chat: ApiChat;
renderLastMessage: () => React.ReactNode;
observeIntersection?: ObserveFn;
};
const NO_CORNER_THRESHOLD = Number(REM);
const ChatForumLastMessage: FC<OwnProps> = ({
chat,
renderLastMessage,
observeIntersection,
}) => {
const { openChat } = getActions();
// eslint-disable-next-line no-null/no-null
const lastMessageRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const mainColumnRef = useRef<HTMLDivElement>(null);
const lang = useLang();
const lastMessage = renderLastMessage();
const [lastActiveTopic, ...otherTopics] = useMemo(() => {
return chat.topics ? getOrderedTopics(Object.values(chat.topics), undefined, true) : [];
}, [chat.topics]);
const [isReversedCorner, setIsReversedCorner] = useState(false);
const [overwrittenWidth, setOverwrittenWidth] = useState<number | undefined>(undefined);
function handleOpenTopic(e: React.MouseEvent<HTMLDivElement>) {
if (lastActiveTopic.unreadCount === 0) return;
e.stopPropagation();
openChat({ id: chat.id, threadId: lastActiveTopic.id, shouldReplaceHistory: true });
}
useLayoutEffect(() => {
const lastMessageElement = lastMessageRef.current;
const mainColumnElement = mainColumnRef.current;
if (!lastMessageElement || !mainColumnElement) return;
const lastMessageWidth = lastMessageElement.offsetWidth;
const mainColumnWidth = mainColumnElement.offsetWidth;
if (Math.abs(lastMessageWidth - mainColumnWidth) < NO_CORNER_THRESHOLD) {
setOverwrittenWidth(Math.max(lastMessageWidth, mainColumnWidth));
} else {
setOverwrittenWidth(undefined);
}
setIsReversedCorner(lastMessageWidth > mainColumnWidth);
}, [lastActiveTopic, lastMessage]);
return (
<div
className={buildClassName(
styles.root,
isReversedCorner && styles.reverseCorner,
overwrittenWidth && styles.overwrittenWidth,
)}
dir={lang.isRtl ? 'rtl' : undefined}
style={overwrittenWidth ? `--overwritten-width: ${overwrittenWidth}px` : undefined}
>
{lastActiveTopic && (
<span className={styles.titleRow}>
<div
className={buildClassName(
styles.mainColumn,
lastActiveTopic.unreadCount && styles.unread,
)}
ref={mainColumnRef}
onMouseDown={handleOpenTopic}
>
<TopicIcon
topic={lastActiveTopic}
observeIntersection={observeIntersection}
/>
<div className={styles.title}>{renderText(lastActiveTopic.title)}</div>
{!overwrittenWidth && isReversedCorner && (
<div className={styles.afterWrapper}>
<div className={styles.after} />
</div>
)}
</div>
<div className={styles.otherColumns}>
{otherTopics.map((topic) => (
<div
className={buildClassName(
styles.otherColumn, topic.unreadCount && styles.unread,
)}
key={topic.id}
>
<TopicIcon
topic={topic}
observeIntersection={observeIntersection}
/>
<span className={styles.otherColumnTitle}>{renderText(topic.title)}</span>
</div>
))}
</div>
<div className={styles.ellipsis} />
</span>
)}
{!lastActiveTopic && <div className={buildClassName(styles.titleRow, styles.loading)}>{lang('Loading')}</div>}
<span
className={buildClassName(styles.lastMessage, lastActiveTopic?.unreadCount && styles.unread)}
ref={lastMessageRef}
onMouseDown={handleOpenTopic}
>
{lastMessage}
{!overwrittenWidth && !isReversedCorner && (
<div className={styles.afterWrapper}>
<div className={styles.after} />
</div>
)}
</span>
</div>
);
};
export default memo(ChatForumLastMessage);

View File

@ -1,7 +1,8 @@
import type { FC } from '../../lib/teact/teact';
import React, { useCallback } from '../../lib/teact/teact';
import React, { memo, useCallback } from '../../lib/teact/teact';
import { getActions } from '../../global';
import type { FC } from '../../lib/teact/teact';
import buildClassName from '../../util/buildClassName';
import Link from '../ui/Link';
@ -32,4 +33,4 @@ const ChatLink: FC<OwnProps> = ({
);
};
export default ChatLink;
export default memo(ChatLink);

View File

@ -42,6 +42,11 @@
display: flex;
flex-direction: column;
> .Transition {
height: 100%;
overflow: hidden;
}
.picker-list {
height: 100%;
overflow-x: hidden;
@ -85,4 +90,38 @@
}
}
}
.topic-icon {
--custom-emoji-size: 2.75rem;
margin-inline-end: 0.25rem !important;
width: 2.75rem;
height: 2.75rem;
font-size: 2.75rem !important;
}
.topic-icon-letter {
font-size: 1.5rem;
}
.topic-item {
.ListItem-button {
display: flex;
align-items: center;
font-size: 1rem;
line-height: 1.6875rem;
font-weight: 500;
}
.fullName {
overflow: hidden;
text-overflow: ellipsis;
}
.emoji-small {
width: 1rem;
height: 1rem;
}
}
}

View File

@ -1,12 +1,19 @@
import type { FC } from '../../lib/teact/teact';
import React, { memo, useRef, useCallback } from '../../lib/teact/teact';
import React, {
memo, useRef, useCallback, useState, useMemo,
} from '../../lib/teact/teact';
import { getActions } from '../../global';
import type { ApiChat, ApiTopic } from '../../api/types';
import { REM } from './helpers/mediaDimensions';
import { CHAT_HEIGHT_PX } from '../../config';
import renderText from './helpers/renderText';
import { getCanPostInChat, isUserId } from '../../global/helpers';
import useInfiniteScroll from '../../hooks/useInfiniteScroll';
import useLang from '../../hooks/useLang';
import useKeyboardListNavigation from '../../hooks/useKeyboardListNavigation';
import useInputFocusOnOpen from '../../hooks/useInputFocusOnOpen';
import { isUserId } from '../../global/helpers';
import Loading from '../ui/Loading';
import Modal from '../ui/Modal';
@ -16,26 +23,34 @@ import InfiniteScroll from '../ui/InfiniteScroll';
import ListItem from '../ui/ListItem';
import GroupChatInfo from './GroupChatInfo';
import PrivateChatInfo from './PrivateChatInfo';
import Transition from '../ui/Transition';
import TopicIcon from './TopicIcon';
import './ChatOrUserPicker.scss';
export type OwnProps = {
currentUserId?: string;
chatOrUserIds: string[];
chatsById?: Record<string, ApiChat>;
isOpen: boolean;
searchPlaceholder: string;
search: string;
loadMore?: NoneToVoidFunction;
onSearchChange: (search: string) => void;
onSelectChatOrUser: (chatOrUserId: string) => void;
onSelectChatOrUser: (chatOrUserId: string, threadId?: number) => void;
onClose: NoneToVoidFunction;
onCloseAnimationEnd?: NoneToVoidFunction;
};
const CHAT_LIST_SLIDE = 0;
const TOPIC_LIST_SLIDE = 1;
const TOPIC_ICON_SIZE = 2.75 * REM;
const ChatOrUserPicker: FC<OwnProps> = ({
isOpen,
currentUserId,
chatOrUserIds,
chatsById,
search,
searchPlaceholder,
loadMore,
@ -44,89 +59,218 @@ const ChatOrUserPicker: FC<OwnProps> = ({
onClose,
onCloseAnimationEnd,
}) => {
const { loadTopics } = getActions();
const lang = useLang();
const [viewportIds, getMore] = useInfiniteScroll(loadMore, chatOrUserIds, Boolean(search));
// eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const topicContainerRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const searchRef = useRef<HTMLInputElement>(null);
// eslint-disable-next-line no-null/no-null
const topicSearchRef = useRef<HTMLInputElement>(null);
const [viewportIds, getMore] = useInfiniteScroll(loadMore, chatOrUserIds, Boolean(search));
const [forumId, setForumId] = useState<string | undefined>(undefined);
const [topicSearch, setTopicSearch] = useState<string>('');
const activeKey = forumId ? TOPIC_LIST_SLIDE : CHAT_LIST_SLIDE;
const viewportOffset = chatOrUserIds!.indexOf(viewportIds![0]);
const resetSearch = useCallback(() => {
onSearchChange('');
}, [onSearchChange]);
useInputFocusOnOpen(searchRef, isOpen, resetSearch);
useInputFocusOnOpen(searchRef, isOpen && activeKey === CHAT_LIST_SLIDE, resetSearch);
useInputFocusOnOpen(topicSearchRef, isOpen && activeKey === TOPIC_LIST_SLIDE);
const [topicIds, topics] = useMemo(() => {
const topicsResult = forumId ? chatsById?.[forumId].topics : undefined;
if (!topicsResult) {
return [undefined, undefined];
}
const searchTitle = topicSearch.toLowerCase();
const result = topicsResult
? Object.values(topicsResult).reduce((acc, topic) => {
if (
getCanPostInChat(chatsById![forumId!], topic.id)
&& (!searchTitle || topic.title.toLowerCase().includes(searchTitle))
) {
acc[topic.id] = topic;
}
return acc;
}, {} as Record<number, ApiTopic>)
: topicsResult;
return [Object.keys(result).map(Number), result];
}, [chatsById, forumId, topicSearch]);
const handleHeaderBackClick = useCallback(() => {
setForumId(undefined);
setTopicSearch('');
}, []);
// eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(null);
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
onSearchChange(e.currentTarget.value);
}, [onSearchChange]);
const handleTopicSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setTopicSearch(e.currentTarget.value);
}, []);
const handleKeyDown = useKeyboardListNavigation(containerRef, isOpen, (index) => {
if (viewportIds && viewportIds.length > 0) {
onSelectChatOrUser(viewportIds[index === -1 ? 0 : index]);
const chatId = viewportIds[index === -1 ? 0 : index];
const chat = chatsById?.[chatId];
if (chat?.isForum) {
if (!chat.topics) loadTopics({ chatId });
setForumId(chatId);
} else {
onSelectChatOrUser(chatId);
}
}
}, '.ListItem-button', true);
const modalHeader = (
<div className="modal-header" dir={lang.isRtl ? 'rtl' : undefined}>
<Button
round
color="translucent"
size="smaller"
ariaLabel={lang('Close')}
onClick={onClose}
>
<i className="icon-close" />
</Button>
<InputText
ref={searchRef}
value={search}
onChange={handleSearchChange}
onKeyDown={handleKeyDown}
placeholder={searchPlaceholder}
/>
</div>
);
const handleTopicKeyDown = useKeyboardListNavigation(topicContainerRef, isOpen, (index) => {
if (topicIds?.length) {
onSelectChatOrUser(forumId!, topicIds[index === -1 ? 0 : index]);
}
}, '.ListItem-button', true);
const viewportOffset = chatOrUserIds!.indexOf(viewportIds![0]);
const handleClick = useCallback((e: React.MouseEvent, chatId: string) => {
const chat = chatsById?.[chatId];
if (chat?.isForum) {
if (!chat.topics) loadTopics({ chatId });
setForumId(chatId);
resetSearch();
} else {
onSelectChatOrUser(chatId);
}
}, [chatsById, loadTopics, onSelectChatOrUser, resetSearch]);
const handleTopicClick = useCallback((e: React.MouseEvent, topicId: number) => {
onSelectChatOrUser(forumId!, topicId);
}, [forumId, onSelectChatOrUser]);
function renderTopicList() {
return (
<>
<div className="modal-header" dir={lang.isRtl ? 'rtl' : undefined}>
<Button round color="translucent" size="smaller" ariaLabel={lang('Back')} onClick={handleHeaderBackClick}>
<i className="icon-arrow-left" />
</Button>
<InputText
ref={topicSearchRef}
value={topicSearch}
onChange={handleTopicSearchChange}
onKeyDown={handleTopicKeyDown}
placeholder={searchPlaceholder}
/>
</div>
<InfiniteScroll
ref={topicContainerRef}
className="picker-list custom-scroll"
items={topicIds}
withAbsolutePositioning
maxHeight={!topicIds ? 0 : topicIds.length * CHAT_HEIGHT_PX}
onKeyDown={handleTopicKeyDown}
>
{topicIds
? topicIds.map((topicId, i) => (
<ListItem
key={`${forumId}_${topicId}`}
className="chat-item-clickable force-rounded-corners small-icon topic-item"
style={`top: ${i * CHAT_HEIGHT_PX}px;`}
onClick={handleTopicClick}
clickArg={topicId}
>
<TopicIcon
size={TOPIC_ICON_SIZE}
topic={topics[topicId]}
className="topic-icon"
letterClassName="topic-icon-letter"
/>
<div dir="auto" className="fullName">{renderText(topics[topicId].title)}</div>
</ListItem>
))
: <Loading />}
</InfiniteScroll>
</>
);
}
function renderChatList() {
return (
<>
<div className="modal-header" dir={lang.isRtl ? 'rtl' : undefined}>
<Button
round
color="translucent"
size="smaller"
ariaLabel={lang('Close')}
onClick={onClose}
>
<i className="icon-close" />
</Button>
<InputText
ref={searchRef}
value={search}
onChange={handleSearchChange}
onKeyDown={handleKeyDown}
placeholder={searchPlaceholder}
/>
</div>
{viewportIds?.length ? (
<InfiniteScroll
ref={containerRef}
className="picker-list custom-scroll"
items={viewportIds}
onLoadMore={getMore}
withAbsolutePositioning
maxHeight={chatOrUserIds!.length * CHAT_HEIGHT_PX}
onKeyDown={handleKeyDown}
>
{viewportIds.map((id, i) => (
<ListItem
key={id}
className="chat-item-clickable force-rounded-corners small-icon"
style={`top: ${(viewportOffset + i) * CHAT_HEIGHT_PX}px;`}
onClick={handleClick}
clickArg={id}
>
{isUserId(id) ? (
<PrivateChatInfo
status={id === currentUserId ? lang('SavedMessagesInfo') : undefined}
userId={id}
/>
) : (
<GroupChatInfo chatId={id} />
)}
</ListItem>
))}
</InfiniteScroll>
) : viewportIds && !viewportIds.length ? (
<p className="no-results">{lang('lng_blocked_list_not_found')}</p>
) : (
<Loading />
)}
</>
);
}
return (
<Modal
isOpen={isOpen}
className="ChatOrUserPicker"
header={modalHeader}
onClose={onClose}
onCloseAnimationEnd={onCloseAnimationEnd}
>
{viewportIds?.length ? (
<InfiniteScroll
ref={containerRef}
className="picker-list custom-scroll"
items={viewportIds}
onLoadMore={getMore}
withAbsolutePositioning
maxHeight={chatOrUserIds!.length * CHAT_HEIGHT_PX}
onKeyDown={handleKeyDown}
>
{viewportIds.map((id, i) => (
<ListItem
key={id}
className="chat-item-clickable force-rounded-corners small-icon"
style={`top: ${(viewportOffset + i) * CHAT_HEIGHT_PX}px;`}
// eslint-disable-next-line react/jsx-no-bind
onClick={() => onSelectChatOrUser(id)}
>
{isUserId(id) ? (
<PrivateChatInfo status={id === currentUserId ? lang('SavedMessagesInfo') : undefined} userId={id} />
) : (
<GroupChatInfo chatId={id} />
)}
</ListItem>
))}
</InfiniteScroll>
) : viewportIds && !viewportIds.length ? (
<p className="no-results">{lang('lng_blocked_list_not_found')}</p>
) : (
<Loading />
)}
<Transition activeKey={activeKey} name="slide-fade">
{() => {
return activeKey === TOPIC_LIST_SLIDE ? renderTopicList() : renderChatList();
}}
</Transition>
</Modal>
);
};

View File

@ -4,6 +4,7 @@
width: var(--custom-emoji-size);
height: var(--custom-emoji-size);
position: relative;
flex: 0 0 var(--custom-emoji-size);
&.with-grid-fix .media, &.with-grid-fix .thumb {
width: calc(100% + 1px) !important;
@ -13,6 +14,10 @@
&:global(.custom-color) {
--emoji-status-color: var(--color-primary);
}
canvas {
display: block;
}
}
.thumb {

View File

@ -1,7 +1,9 @@
import type { FC } from '../../lib/teact/teact';
import React, { useRef } from '../../lib/teact/teact';
import type { ApiUser, ApiMessage, ApiChat } from '../../api/types';
import type {
ApiUser, ApiMessage, ApiChat,
} from '../../api/types';
import {
getMessageMediaHash,

View File

@ -5,27 +5,39 @@ import React, {
} from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global';
import type { ApiChat, ApiTypingStatus } from '../../api/types';
import type {
ApiChat, ApiTopic, ApiThreadInfo, ApiTypingStatus,
} from '../../api/types';
import type { GlobalState } from '../../global/types';
import type { AnimationLevel } from '../../types';
import type { LangFn } from '../../hooks/useLang';
import { MediaViewerOrigin } from '../../types';
import { REM } from './helpers/mediaDimensions';
import {
getChatTypeString,
getMainUsername,
isChatSuperGroup,
} from '../../global/helpers';
import { selectChat, selectChatMessages, selectChatOnlineCount } from '../../global/selectors';
import type { LangFn } from '../../hooks/useLang';
import {
selectChat, selectChatMessages, selectChatOnlineCount, selectThreadInfo,
} from '../../global/selectors';
import buildClassName from '../../util/buildClassName';
import renderText from './helpers/renderText';
import useLang from '../../hooks/useLang';
import Avatar from './Avatar';
import TypingStatus from './TypingStatus';
import DotAnimation from './DotAnimation';
import FullNameTitle from './FullNameTitle';
import TopicIcon from './TopicIcon';
const TOPIC_ICON_SIZE = 2.5 * REM;
type OwnProps = {
chatId: string;
threadId?: number;
className?: string;
typingStatus?: ApiTypingStatus;
avatarSize?: 'small' | 'medium' | 'large' | 'jumbo';
status?: string;
@ -37,11 +49,15 @@ type OwnProps = {
withChatType?: boolean;
withVideoAvatar?: boolean;
noRtl?: boolean;
noAvatar?: boolean;
onClick?: VoidFunction;
};
type StateProps =
{
chat?: ApiChat;
threadInfo?: ApiThreadInfo;
topic?: ApiTopic;
onlineCount?: number;
areMessagesLoaded: boolean;
animationLevel: AnimationLevel;
@ -50,7 +66,9 @@ type StateProps =
const GroupChatInfo: FC<OwnProps & StateProps> = ({
typingStatus,
className,
avatarSize = 'medium',
noAvatar,
status,
withDots,
withMediaViewer,
@ -59,12 +77,15 @@ const GroupChatInfo: FC<OwnProps & StateProps> = ({
withUpdatingStatus,
withChatType,
withVideoAvatar,
threadInfo,
noRtl,
chat,
onlineCount,
areMessagesLoaded,
animationLevel,
lastSyncTime,
topic,
onClick,
}) => {
const {
loadFullChat,
@ -73,6 +94,7 @@ const GroupChatInfo: FC<OwnProps & StateProps> = ({
} = getActions();
const isSuperGroup = chat && isChatSuperGroup(chat);
const isTopic = Boolean(chat?.isForum && threadInfo && topic);
const { id: chatId, isMin, isRestricted } = chat || {};
useEffect(() => {
@ -123,6 +145,14 @@ const GroupChatInfo: FC<OwnProps & StateProps> = ({
return <TypingStatus typingStatus={typingStatus} />;
}
if (isTopic) {
return (
<span className="status" dir="auto">
{threadInfo?.messagesCount ? lang('messages', threadInfo.messagesCount, 'i') : renderText(chat.title)}
</span>
);
}
if (withChatType) {
return (
<span className="status" dir="auto">{lang(getChatTypeString(chat))}</span>
@ -142,17 +172,30 @@ const GroupChatInfo: FC<OwnProps & StateProps> = ({
}
return (
<div className="ChatInfo" dir={!noRtl && lang.isRtl ? 'rtl' : undefined}>
<Avatar
key={chat.id}
size={avatarSize}
chat={chat}
onClick={withMediaViewer ? handleAvatarViewerOpen : undefined}
withVideo={withVideoAvatar}
animationLevel={animationLevel}
/>
<div
className={
buildClassName('ChatInfo', className)
}
dir={!noRtl && lang.isRtl ? 'rtl' : undefined}
onClick={onClick}
>
{!noAvatar && !isTopic && (
<Avatar
key={chat.id}
size={avatarSize}
chat={chat}
onClick={withMediaViewer ? handleAvatarViewerOpen : undefined}
withVideo={withVideoAvatar}
animationLevel={animationLevel}
/>
)}
{isTopic && (
<TopicIcon topic={topic!} className="topic-header-icon" size={TOPIC_ICON_SIZE} />
)}
<div className="info">
<FullNameTitle peer={chat} />
{topic
? <h3 dir="auto" className="fullName">{renderText(topic.title)}</h3>
: <FullNameTitle peer={chat} />}
{renderStatusOrTyping()}
</div>
</div>
@ -177,16 +220,20 @@ function getGroupStatus(lang: LangFn, chat: ApiChat) {
}
export default memo(withGlobal<OwnProps>(
(global, { chatId }): StateProps => {
(global, { chatId, threadId }): StateProps => {
const { lastSyncTime } = global;
const chat = selectChat(global, chatId);
const threadInfo = threadId ? selectThreadInfo(global, chatId, threadId) : undefined;
const onlineCount = chat ? selectChatOnlineCount(global, chat) : undefined;
const areMessagesLoaded = Boolean(selectChatMessages(global, chatId));
const topic = threadId ? chat?.topics?.[threadId] : undefined;
return {
lastSyncTime,
chat,
threadInfo,
onlineCount,
topic,
areMessagesLoaded,
animationLevel: global.settings.byKey.animationLevel,
};

View File

@ -69,7 +69,7 @@
transition: opacity 0.15s ease;
.Avatar__icon, i {
font-size: 2rem;
font-size: 1rem;
}
}
@ -120,6 +120,16 @@
transition: opacity 0.15s ease;
}
&.forum-avatar {
border-start-start-radius: 0.625rem;
border-end-start-radius: 0.625rem;
.item-remove {
border-radius: 0.625rem;
}
}
&[dir="rtl"] {
padding-left: 1rem;
padding-right: 0;

View File

@ -74,6 +74,7 @@ const PickerSelectedItem: FC<OwnProps & StateProps> = ({
const fullClassName = buildClassName(
'PickerSelectedItem',
className,
chat?.isForum && 'forum-avatar',
isMinimized && 'minimized',
canClose && 'closeable',
);

View File

@ -0,0 +1,179 @@
.self {
margin: 0 -0.5rem 0.75rem;
overflow: hidden;
&:global(.ghost) {
margin: 0;
}
.info {
padding-bottom: 0.75rem;
}
.status {
line-height: 1rem;
}
}
.photoWrapper {
width: 100%;
position: absolute;
left: 0;
top: 0;
bottom: 0;
> :global(.Transition) {
width: 100%;
height: 100%;
}
}
.photoDashes {
position: absolute;
width: 100%;
height: 0.125rem;
padding: 0 0.375rem;
z-index: 1;
display: flex;
top: 0.5rem;
left: 0;
}
.photoDash {
flex: 1 1 auto;
background-color: var(--color-white);
opacity: 0.25;
border-radius: 0.125rem;
margin: 0 0.125rem;
transition: opacity 300ms ease;
&_current {
opacity: 0.75;
}
}
.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: 0.25;
transition: opacity 0.15s;
outline: none;
cursor: pointer;
z-index: 1;
&:global(:hover),
:global(.is-touch-env) & {
opacity: 1;
}
&_prev {
left: 0;
background-image: url("../../assets/media_navigation_previous.svg");
background-position: 1.25rem 50%;
&[dir="rtl"] {
left: auto;
right: 0;
transform: scaleX(-1);
}
}
&_next {
right: 0;
background-image: url("../../assets/media_navigation_next.svg");
background-position: calc(100% - 1.25rem) 50%;
&[dir="rtl"] {
left: 0;
right: auto;
transform: scaleX(-1);
}
}
}
.info {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
min-height: 100px;
padding: 0 1.5rem 0.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;
pointer-events: none;
&:dir(rtl) {
.status {
text-align: right;
unicode-bidi: plaintext;
}
}
&[dir="rtl"] {
.status {
text-align: right;
unicode-bidi: plaintext;
}
}
}
.status {
font-size: 0.875rem;
opacity: 0.5;
}
.topicContainer {
--custom-emoji-size: 7.5rem;
padding: 1rem 1rem 0.75rem;
}
.topicTitle {
font-size: 1.25rem;
line-height: 1.5rem;
text-align: center;
margin: 0.5rem 0 0;
}
.topicIcon {
margin: auto;
width: 7.5rem !important;
height: 7.5rem !important;
display: flex !important;
&:global(.general-forum-icon) {
font-size: 7.5rem;
color: var(--color-text-secondary);
}
}
.topicIconTitle {
font-size: 3rem !important;
font-weight: 400;
:global(.emoji-small) {
width: 3rem;
height: 3rem;
}
}
.topicMessagesCounter {
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--color-text-secondary);
margin: 0;
text-align: center;
}

View File

@ -1,3 +1,4 @@
// This class is used in `ghostAnimation`, so we need to keep it global
.ProfileInfo {
aspect-ratio: 1 / 1;
position: relative;
@ -16,176 +17,37 @@
}
}
.photo-wrapper {
width: 100%;
position: absolute;
left: 0;
top: 0;
bottom: 0;
> .Transition {
width: 100%;
height: 100%;
}
.fullName {
font-weight: 500;
font-size: 1.25rem;
line-height: 1.375rem;
white-space: pre-wrap;
word-break: break-word;
margin-bottom: 0;
}
.photo-dashes {
position: absolute;
width: 100%;
height: 0.125rem;
padding: 0 0.375rem;
z-index: 1;
display: flex;
top: 0.5rem;
left: 0;
.VerifiedIcon,
.PremiumIcon {
z-index: 2;
--color-fill: var(--color-white);
--color-checkmark: var(--color-primary);
opacity: 0.8;
}
.photo-dash {
flex: 1 1 auto;
background-color: var(--color-white);
opacity: 0.25;
border-radius: 0.125rem;
margin: 0 0.125rem;
transition: opacity 300ms ease;
&.current {
opacity: 0.75;
}
.emoji:not(.custom-emoji) {
width: 1.5rem;
height: 1.5rem;
background-size: 1.5rem;
}
.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: 0.25;
transition: opacity 0.15s;
outline: none;
.custom-emoji {
pointer-events: auto;
cursor: pointer;
z-index: 1;
&:hover,
.is-touch-env & {
opacity: 1;
}
--custom-emoji-size: 1.5rem;
&.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 0.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;
pointer-events: none;
&:dir(rtl) {
.status {
text-align: right;
unicode-bidi: plaintext;
}
}
&[dir="rtl"] {
.status {
text-align: right;
unicode-bidi: plaintext;
}
}
}
.title {
.fullName {
font-weight: 500;
font-size: 1.25rem;
line-height: 1.375rem;
white-space: pre-wrap;
word-break: break-word;
margin-bottom: 0;
}
.VerifiedIcon, .PremiumIcon {
z-index: 2;
--color-fill: var(--color-white);
--color-checkmark: var(--color-primary);
opacity: 0.8;
}
.emoji:not(.custom-emoji) {
width: 1.5rem;
height: 1.5rem;
background-size: 1.5rem;
}
.custom-emoji {
pointer-events: auto;
cursor: pointer;
--custom-emoji-size: 1.5rem;
&.custom-color {
--emoji-status-color: var(--color-white);
}
}
}
.status {
font-size: 0.875rem;
opacity: 0.5;
}
&[dir="rtl"] {
.navigation.prev {
left: auto;
right: 0;
transform: scaleX(-1);
}
.navigation.next {
left: 0;
right: auto;
transform: scaleX(-1);
}
}
&.self {
margin: 0 -0.5rem 0.75rem;
overflow: hidden;
&.ghost {
margin: 0;
}
.info {
padding-bottom: 0.75rem;
}
.status {
line-height: 1rem;
.custom-color {
--emoji-status-color: var(--color-white);
}
}
}

View File

@ -4,19 +4,23 @@ import React, {
} from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global';
import type { ApiUser, ApiChat, ApiUserStatus } from '../../api/types';
import type {
ApiUser, ApiChat, ApiUserStatus, ApiTopic,
} from '../../api/types';
import type { GlobalState } from '../../global/types';
import type { AnimationLevel } from '../../types';
import { MediaViewerOrigin } from '../../types';
import { GENERAL_TOPIC_ID } from '../../config';
import { IS_TOUCH_ENV } from '../../util/environment';
import { MEMO_EMPTY_ARRAY } from '../../util/memo';
import { selectChat, selectUser, selectUserStatus } from '../../global/selectors';
import {
getUserStatus, isChatChannel, isUserOnline,
} from '../../global/helpers';
selectChat, selectCurrentMessageList, selectThreadInfo, selectUser, selectUserStatus,
} from '../../global/selectors';
import { getUserStatus, isChatChannel, isUserOnline } from '../../global/helpers';
import { captureEvents, SwipeDirection } from '../../util/captureEvents';
import buildClassName from '../../util/buildClassName';
import renderText from './helpers/renderText';
import usePhotosPreload from './hooks/usePhotosPreload';
import useLang from '../../hooks/useLang';
@ -25,8 +29,10 @@ import usePrevious from '../../hooks/usePrevious';
import FullNameTitle from './FullNameTitle';
import ProfilePhoto from './ProfilePhoto';
import Transition from '../ui/Transition';
import TopicIcon from './TopicIcon';
import './ProfileInfo.scss';
import styles from './ProfileInfo.module.scss';
type OwnProps = {
userId: string;
@ -44,10 +50,13 @@ type StateProps =
serverTimeOffset: number;
mediaId?: number;
avatarOwnerId?: string;
topic?: ApiTopic;
messagesCount?: number;
}
& Pick<GlobalState, 'connectionState'>;
const EMOJI_STATUS_SIZE = 24;
const EMOJI_TOPIC_SIZE = 120;
const ProfileInfo: FC<OwnProps & StateProps> = ({
forceShowSelf,
@ -61,6 +70,8 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
serverTimeOffset,
mediaId,
avatarOwnerId,
topic,
messagesCount,
}) => {
const {
loadFullUser,
@ -139,7 +150,7 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
// Swipe gestures
useEffect(() => {
const element = document.querySelector<HTMLDivElement>('.photo-wrapper');
const element = document.querySelector<HTMLDivElement>(`.${styles.photoWrapper}`);
if (!element) {
return undefined;
}
@ -164,15 +175,35 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
return undefined;
}
function renderTopic() {
return (
<div className={styles.topicContainer}>
<TopicIcon
topic={topic!}
size={EMOJI_TOPIC_SIZE}
className={styles.topicIcon}
letterClassName={styles.topicIconTitle}
noLoopLimit
/>
<h3 className={styles.topicTitle} dir={lang.isRtl ? 'rtl' : undefined}>{renderText(topic!.title)}</h3>
<p className={styles.topicMessagesCounter}>
{messagesCount && messagesCount > 1
? lang('Chat.Title.Topic', messagesCount + (topic!.id === GENERAL_TOPIC_ID ? 1 : -1), 'i')
: lang('lng_forum_no_messages')}
</p>
</div>
);
}
function renderPhotoTabs() {
if (isSavedMessages || !photos || photos.length <= 1) {
return undefined;
}
return (
<div className="photo-dashes">
<div className={styles.photoDashes}>
{photos.map((_, i) => (
<span className={`photo-dash ${i === currentPhotoIndex ? 'current' : ''}`} />
<span className={buildClassName(styles.photoDash, i === currentPhotoIndex && styles.photoDash_current)} />
))}
</div>
);
@ -198,14 +229,14 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
function renderStatus() {
if (user) {
return (
<div className={buildClassName('status', isUserOnline(user, userStatus) && 'online')}>
<div className={buildClassName(styles.status, 'status', isUserOnline(user, userStatus) && 'online')}>
<span className="user-status" dir="auto">{getUserStatus(lang, user, userStatus, serverTimeOffset)}</span>
</div>
);
}
return (
<span className="status" dir="auto">{
<span className={buildClassName(styles.status, 'status')} dir="auto">{
isChatChannel(chat!)
? lang('Subscribers', chat!.membersCount ?? 0, 'i')
: lang('Members', chat!.membersCount ?? 0, 'i')
@ -214,18 +245,26 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
);
}
if (topic) {
return renderTopic();
}
return (
<div className={buildClassName('ProfileInfo', forceShowSelf && 'self')} dir={lang.isRtl ? 'rtl' : undefined}>
<div className="photo-wrapper">
<div
className={buildClassName('ProfileInfo', forceShowSelf && styles.self)}
dir={lang.isRtl ? 'rtl' : undefined}
>
<div className={styles.photoWrapper}>
{renderPhotoTabs()}
<Transition activeKey={currentPhotoIndex} name={slideAnimation} className="profile-slide-container">
<Transition activeKey={currentPhotoIndex} name={slideAnimation}>
{renderPhoto}
</Transition>
{!isFirst && (
<button
type="button"
className="navigation prev"
dir={lang.isRtl ? 'rtl' : undefined}
className={buildClassName(styles.navigation, styles.navigation_prev)}
aria-label={lang('AccDescrPrevious')}
onClick={selectPreviousMedia}
/>
@ -233,14 +272,15 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
{!isLast && (
<button
type="button"
className="navigation next"
dir={lang.isRtl ? 'rtl' : undefined}
className={buildClassName(styles.navigation, styles.navigation_next)}
aria-label={lang('Next')}
onClick={selectNextMedia}
/>
)}
</div>
<div className="info" dir={lang.isRtl ? 'rtl' : 'auto'}>
<div className={styles.info} dir={lang.isRtl ? 'rtl' : 'auto'}>
{(user || chat) && (
<FullNameTitle
peer={(user || chat)!}
@ -266,6 +306,10 @@ export default memo(withGlobal<OwnProps>(
const isSavedMessages = !forceShowSelf && user && user.isSelf;
const { animationLevel } = global.settings.byKey;
const { mediaId, avatarOwnerId } = global.mediaViewer;
const isForum = chat?.isForum;
const { threadId: currentTopicId } = selectCurrentMessageList(global) || {};
const threadInfo = currentTopicId ? selectThreadInfo(global, userId, currentTopicId) : undefined;
const topic = isForum && currentTopicId ? chat?.topics?.[currentTopicId] : undefined;
return {
connectionState,
@ -277,6 +321,10 @@ export default memo(withGlobal<OwnProps>(
serverTimeOffset,
mediaId,
avatarOwnerId,
...(topic && {
topic,
messagesCount: threadInfo?.messagesCount,
}),
};
},
)(ProfileInfo));

View File

@ -35,7 +35,7 @@
&.replies-bot-account,
&.deleted-account,
&.saved-messages {
font-size: 20rem;
font-size: 10rem;
}
.thumb {

View File

@ -26,7 +26,7 @@ export type OwnProps = {
searchPlaceholder: string;
filter?: ApiChatType[];
loadMore?: NoneToVoidFunction;
onSelectRecipient: (peerId: string) => void;
onSelectRecipient: (peerId: string, threadId?: number) => void;
onClose: NoneToVoidFunction;
onCloseAnimationEnd?: NoneToVoidFunction;
};
@ -94,6 +94,7 @@ const RecipientPicker: FC<OwnProps & StateProps> = ({
<ChatOrUserPicker
isOpen={isOpen}
chatOrUserIds={renderingIds}
chatsById={chatsById}
searchPlaceholder={searchPlaceholder}
search={search}
onSearchChange={setSearch}

View File

@ -0,0 +1,33 @@
.root {
--custom-emoji-size: 1.125rem;
position: relative;
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem;
line-height: 1.125rem;
background-color: var(--background-color);
border-radius: var(--border-radius-messages);
color: var(--topic-button-accent-color);
cursor: pointer;
&::before {
content: "";
display: block;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100%;
height: 100%;
background-color: var(--topic-button-accent-color);
opacity: 0.15;
transition: opacity 0.15s ease-in-out;
border-radius: var(--border-radius-messages);
}
&:hover::before {
opacity: 0.10;
}
}

View File

@ -0,0 +1,48 @@
import React, { memo } from '../../lib/teact/teact';
import type { ApiTopic } from '../../api/types';
import type { FC } from '../../lib/teact/teact';
import { getTopicColorCssVariable } from '../../util/forumColors';
import { REM } from './helpers/mediaDimensions';
import buildClassName from '../../util/buildClassName';
import renderText from './helpers/renderText';
import useLang from '../../hooks/useLang';
import TopicIcon from './TopicIcon';
import styles from './TopicChip.module.scss';
import blankSrc from '../../assets/blank.png';
type OwnProps = {
topic?: ApiTopic;
className?: string;
onClick?: NoneToVoidFunction;
};
const TOPIC_ICON_SIZE = 1.125 * REM;
const TopicChip: FC<OwnProps> = ({
topic,
className,
onClick,
}) => {
const lang = useLang();
return (
<div
className={buildClassName(styles.root, className)}
style={`--topic-button-accent-color: var(${getTopicColorCssVariable(topic?.iconColor)})`}
onClick={onClick}
>
{topic
? <TopicIcon topic={topic} size={TOPIC_ICON_SIZE} />
: <img src={blankSrc} alt="" />}
{topic?.title ? renderText(topic.title) : lang('Loading')}
{topic?.isClosed && <i className="icon-lock" />}
<i className="icon-next" />
</div>
);
};
export default memo(TopicChip);

View File

@ -0,0 +1,30 @@
.root {
display: inline-flex;
align-items: center;
justify-content: center;
position: relative;
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
}
.icon {
position: absolute;
width: 100%;
height: 100%;
}
.title {
z-index: 1;
color: #ffffff;
font-weight: 500;
font-size: 0.75rem;
position: relative;
bottom: 0.0625rem;
:global(.emoji) {
width: 0.75rem;
height: 0.75rem;
}
}

View File

@ -0,0 +1,43 @@
import React, { memo } from '../../lib/teact/teact';
import type { FC } from '../../lib/teact/teact';
import { GENERAL_TOPIC_ID } from '../../config';
import { getFirstLetters } from '../../util/textFormat';
import buildClassName from '../../util/buildClassName';
import renderText from './helpers/renderText';
import { getTopicDefaultIcon } from '../../util/forumColors';
import styles from './TopicDefaultIcon.module.scss';
type OwnProps = {
className?: string;
letterClassName?: string;
topicId: number;
iconColor?: number;
title: string;
};
const TopicDefaultIcon: FC<OwnProps> = ({
className,
letterClassName,
topicId,
iconColor,
title,
}) => {
const iconSrc = getTopicDefaultIcon(iconColor);
if (topicId === GENERAL_TOPIC_ID) {
return <i className={buildClassName(styles.root, className, 'icon-hashtag', 'general-forum-icon')} />;
}
return (
<div className={buildClassName(styles.root, className)}>
<img className={styles.icon} src={iconSrc} alt="" />
<div className={buildClassName(styles.title, letterClassName, 'topic-icon-letter')}>
{renderText(getFirstLetters(title, 1))}
</div>
</div>
);
};
export default memo(TopicDefaultIcon);

View File

@ -0,0 +1,52 @@
import React, { memo } from '../../lib/teact/teact';
import type { FC } from '../../lib/teact/teact';
import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import type { ApiTopic } from '../../api/types';
import CustomEmoji from './CustomEmoji';
import TopicDefaultIcon from './TopicDefaultIcon';
type OwnProps = {
topic: ApiTopic;
className?: string;
letterClassName?: string;
size?: number;
noLoopLimit?: true;
observeIntersection?: ObserveFn;
};
const LOOP_LIMIT = 2;
const TopicIcon: FC<OwnProps> = ({
topic,
className,
letterClassName,
size,
noLoopLimit,
observeIntersection,
}) => {
if (topic.iconEmojiId) {
return (
<CustomEmoji
documentId={topic.iconEmojiId}
className={className}
size={size}
observeIntersectionForPlaying={observeIntersection}
loopLimit={!noLoopLimit ? LOOP_LIMIT : undefined}
/>
);
}
return (
<TopicDefaultIcon
iconColor={topic.iconColor}
title={topic.title}
topicId={topic.id}
className={className}
letterClassName={letterClassName}
/>
);
};
export default memo(TopicIcon);

View File

@ -64,7 +64,7 @@
.left {
flex: 1;
background: var(--color-background);
min-width: 12rem;
min-width: 16rem;
width: 33vw;
max-width: 26.5rem;
height: 100%;

View File

@ -1,7 +1,7 @@
import React from '../../../lib/teact/teact';
import type {
ApiChat, ApiMessage, ApiUser, ApiGroupCall,
ApiChat, ApiMessage, ApiUser, ApiGroupCall, ApiTopic,
} from '../../../api/types';
import type { TextPart } from '../../../types';
import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
@ -38,6 +38,7 @@ export function renderActionMessageText(
targetUsers?: ApiUser[],
targetMessage?: ApiMessage,
targetChatId?: string,
topic?: ApiTopic,
options: RenderOptions = {},
observeIntersectionForLoading?: ObserveFn,
observeIntersectionForPlaying?: ObserveFn,
@ -54,6 +55,7 @@ export function renderActionMessageText(
const translationKey = text === 'Chat.Service.Group.UpdatedPinnedMessage1' && !targetMessage
? 'Message.PinnedGenericMessage'
: text;
let unprocessed = lang(translationKey, translationValues?.length ? translationValues : undefined);
if (translationKey.includes('ScoredInGame')) { // Translation hack for games
unprocessed = unprocessed.replace('un1', '%action_origin%').replace('un2', '%message%');
@ -92,6 +94,16 @@ export function renderActionMessageText(
unprocessed = processed.pop() as string;
content.push(...processed);
if (unprocessed.includes('%action_topic%')) {
processed = processPlaceholder(
unprocessed,
'%action_topic%',
topic ? topic.title : 'a topic',
);
unprocessed = processed.pop() as string;
content.push(...processed);
}
if (unprocessed.includes('%gift_payment_amount%')) {
processed = processPlaceholder(
unprocessed,

View File

@ -5,4 +5,27 @@
.chat-list {
height: calc(100% - var(--header-height));
}
.DropdownMenuFiller {
width: 2.5rem;
height: 2.5rem;
}
.Button.rtl {
transition: var(--slide-transition) transform;
position: absolute;
z-index: 2;
&.right-aligned {
transform: translateX(calc(clamp(
var(--left-column-min-width),
var(--left-column-width),
var(--left-column-max-width)
) - 4.375rem));
}
&.disable-transition {
transition: none;
}
}
}

View File

@ -1,22 +1,33 @@
import type { FC } from '../../lib/teact/teact';
import React, { memo } from '../../lib/teact/teact';
import type { FC } from '../../lib/teact/teact';
import buildClassName from '../../util/buildClassName';
import useLang from '../../hooks/useLang';
import useHistoryBack from '../../hooks/useHistoryBack';
import useLeftHeaderButtonRtlForumTransition from './main/hooks/useLeftHeaderButtonRtlForumTransition';
import useShowTransition from '../../hooks/useShowTransition';
import useForumPanelRender from '../../hooks/useForumPanelRender';
import Button from '../ui/Button';
import ChatList from './main/ChatList';
import type { LeftColumnContent } from '../../types';
import ForumPanel from './main/ForumPanel';
import './ArchivedChats.scss';
export type OwnProps = {
isActive: boolean;
onReset: () => void;
onContentChange: (content: LeftColumnContent) => void;
onTopicSearch: NoneToVoidFunction;
isForumPanelOpen?: boolean;
};
const ArchivedChats: FC<OwnProps> = ({ isActive, onReset }) => {
const ArchivedChats: FC<OwnProps> = ({
isActive,
isForumPanelOpen,
onReset,
onTopicSearch,
}) => {
const lang = useLang();
useHistoryBack({
@ -24,21 +35,47 @@ const ArchivedChats: FC<OwnProps> = ({ isActive, onReset }) => {
onBack: onReset,
});
const {
shouldDisableDropdownMenuTransitionRef,
handleDropdownMenuTransitionEnd,
} = useLeftHeaderButtonRtlForumTransition(isForumPanelOpen);
const {
shouldRender: shouldRenderTitle,
transitionClassNames: titleClassNames,
} = useShowTransition(!isForumPanelOpen);
const { shouldRenderForumPanel, handleForumPanelAnimationEnd } = useForumPanelRender(isForumPanelOpen);
return (
<div className="ArchivedChats">
<div className="left-header">
{lang.isRtl && <div className="DropdownMenuFiller" />}
<Button
round
size="smaller"
color="translucent"
onClick={onReset}
ariaLabel="Return to chat list"
className={buildClassName(
lang.isRtl && 'rtl',
isForumPanelOpen && lang.isRtl && 'right-aligned',
shouldDisableDropdownMenuTransitionRef.current && lang.isRtl && 'disable-transition',
)}
onTransitionEnd={handleDropdownMenuTransitionEnd}
>
<i className="icon-arrow-left" />
</Button>
<h3>{lang('ArchivedChats')}</h3>
{shouldRenderTitle && <h3 className={titleClassNames}>{lang('ArchivedChats')}</h3>}
</div>
<ChatList folderType="archived" isActive={isActive} />
{shouldRenderForumPanel && (
<ForumPanel
isOpen={isForumPanelOpen}
onTopicSearch={onTopicSearch}
onCloseAnimationEnd={handleForumPanelAnimationEnd}
/>
)}
</div>
);
};

View File

@ -8,7 +8,7 @@ import { LeftColumnContent, SettingsScreens } from '../../types';
import { IS_MAC_OS, IS_PWA, LAYERS_ANIMATION_NAME } from '../../util/environment';
import captureEscKeyListener from '../../util/captureEscKeyListener';
import { selectCurrentChat } from '../../global/selectors';
import { selectCurrentChat, selectIsForumPanelOpen } from '../../global/selectors';
import useFoldersReducer from '../../hooks/reducers/useFoldersReducer';
import { useResize } from '../../hooks/useResize';
import { useHotkeys } from '../../hooks/useHotkeys';
@ -33,6 +33,9 @@ type StateProps = {
nextSettingsScreen?: SettingsScreens;
isChatOpen: boolean;
isUpdateAvailable?: boolean;
isForumPanelOpen?: boolean;
forumPanelChatId?: string;
isClosingSearch?: boolean;
};
enum ContentType {
@ -60,9 +63,13 @@ const LeftColumn: FC<StateProps> = ({
nextSettingsScreen,
isChatOpen,
isUpdateAvailable,
isForumPanelOpen,
forumPanelChatId,
isClosingSearch,
}) => {
const {
setGlobalSearchQuery,
setGlobalSearchClosing,
setGlobalSearchChatId,
resetChatCreation,
setGlobalSearchDate,
@ -106,11 +113,13 @@ const LeftColumn: FC<StateProps> = ({
function fullReset() {
setContent(LeftColumnContent.ChatList);
setContactsFilter('');
setGlobalSearchQuery({ query: '' });
setGlobalSearchDate({ date: undefined });
setGlobalSearchChatId({ id: undefined });
setGlobalSearchClosing(true);
resetChatCreation();
setTimeout(() => {
setGlobalSearchQuery({ query: '' });
setGlobalSearchDate({ date: undefined });
setGlobalSearchChatId({ id: undefined });
setGlobalSearchClosing(false);
setLastResetTime(Date.now());
}, RESET_TRANSITION_DELAY_MS);
}
@ -299,8 +308,8 @@ const LeftColumn: FC<StateProps> = ({
fullReset();
}, [
content, isFirstChatFolderActive, settingsScreen, setGlobalSearchQuery, setGlobalSearchDate, setGlobalSearchChatId,
resetChatCreation, hasPasscode,
content, isFirstChatFolderActive, setGlobalSearchClosing, resetChatCreation, setGlobalSearchQuery,
setGlobalSearchDate, setGlobalSearchChatId, settingsScreen, hasPasscode,
]);
const handleSearchQuery = useCallback((query: string) => {
@ -316,6 +325,12 @@ const LeftColumn: FC<StateProps> = ({
}
}, [content, searchQuery, setGlobalSearchQuery]);
const handleTopicSearch = useCallback(() => {
setContent(LeftColumnContent.GlobalSearch);
setGlobalSearchQuery({ query: '' });
setGlobalSearchChatId({ id: forumPanelChatId });
}, [forumPanelChatId, setGlobalSearchChatId, setGlobalSearchQuery]);
useEffect(
() => (content !== LeftColumnContent.ChatList || (isFirstChatFolderActive && !isChatOpen)
? captureEscKeyListener(() => handleReset())
@ -367,7 +382,7 @@ const LeftColumn: FC<StateProps> = ({
const {
initResize, resetResize, handleMouseUp,
} = useResize(resizeRef, setLeftColumnWidth, resetLeftColumnWidth, leftColumnWidth);
} = useResize(resizeRef, setLeftColumnWidth, resetLeftColumnWidth, leftColumnWidth, '--left-column-width');
const handleSettingsScreenSelect = useCallback((screen: SettingsScreens) => {
setContent(LeftColumnContent.Settings);
@ -393,7 +408,8 @@ const LeftColumn: FC<StateProps> = ({
<ArchivedChats
isActive={isActive}
onReset={handleReset}
onContentChange={setContent}
onTopicSearch={handleTopicSearch}
isForumPanelOpen={isForumPanelOpen}
/>
);
case ContentType.Settings:
@ -433,6 +449,7 @@ const LeftColumn: FC<StateProps> = ({
return (
<LeftMain
content={content}
isClosingSearch={isClosingSearch}
searchQuery={searchQuery}
searchDate={searchDate}
contactsFilter={contactsFilter}
@ -443,6 +460,8 @@ const LeftColumn: FC<StateProps> = ({
onReset={handleReset}
shouldSkipTransition={shouldSkipHistoryAnimations}
isUpdateAvailable={isUpdateAvailable}
isForumPanelOpen={isForumPanelOpen}
onTopicSearch={handleTopicSearch}
/>
);
}
@ -480,7 +499,10 @@ export default memo(withGlobal(
isUpdateAvailable,
} = global;
const isChatOpen = Boolean(selectCurrentChat(global)?.id);
const currentChat = selectCurrentChat(global);
const isChatOpen = Boolean(currentChat?.id);
const isForumPanelOpen = selectIsForumPanelOpen(global);
const forumPanelChatId = global.forumPanelChatId;
return {
searchQuery: query,
@ -493,6 +515,9 @@ export default memo(withGlobal(
nextSettingsScreen,
isChatOpen,
isUpdateAvailable,
isForumPanelOpen,
forumPanelChatId,
isClosingSearch: global.globalSearch.isClosing,
};
},
)(LeftColumn));

View File

@ -49,11 +49,21 @@
}
&.mention,
&.unread:not(.muted) {
&.unread:not(.muted),
&.unopened:not(.muted) {
background: var(--color-green);
color: var(--color-white);
}
&.unopened {
width: 0.5rem;
height: 0.5rem;
min-width: auto;
min-height: auto;
padding: 0;
align-self: center;
}
&.pinned {
color: var(--color-pinned);
background: transparent;

View File

@ -1,7 +1,7 @@
import type { FC } from '../../../lib/teact/teact';
import React, { memo } from '../../../lib/teact/teact';
import React, { memo, useMemo } from '../../../lib/teact/teact';
import type { ApiChat } from '../../../api/types';
import type { ApiChat, ApiTopic } from '../../../api/types';
import type { FC } from '../../../lib/teact/teact';
import { formatIntegerCompact } from '../../../util/textFormat';
import buildClassName from '../../../util/buildClassName';
@ -13,38 +13,76 @@ import './Badge.scss';
type OwnProps = {
chat: ApiChat;
topic?: ApiTopic;
wasTopicOpened?: boolean;
isPinned?: boolean;
isMuted?: boolean;
shouldShowOnlyMostImportant?: boolean;
};
const Badge: FC<OwnProps> = ({ chat, isPinned, isMuted }) => {
const Badge: FC<OwnProps> = ({
topic, chat, isPinned, isMuted, shouldShowOnlyMostImportant, wasTopicOpened,
}) => {
const {
unreadMentionsCount = 0, unreadReactionsCount = 0,
} = !chat.isForum ? chat : {}; // TODO[forums] Unread mentions and reactions temporarily disabled for forums
const isTopicUnopened = !isPinned && topic && !wasTopicOpened;
const isForum = chat.isForum && !topic;
const topicsWithUnread = useMemo(() => (
isForum && chat?.topics ? Object.values(chat.topics).filter(({ unreadCount }) => unreadCount) : undefined
), [chat, isForum]);
const unreadCount = useMemo(() => (
isForum
// If we have unmuted topics, display the count of those. Otherwise, display the count of all topics.
? ((isMuted && topicsWithUnread?.filter((acc) => acc.isMuted === false).length)
|| topicsWithUnread?.length)
: (topic || chat).unreadCount
), [chat, topic, topicsWithUnread, isForum, isMuted]);
const shouldBeMuted = useMemo(() => {
const hasUnmutedUnreadTopics = chat.topics
&& Object.values(chat.topics).some((acc) => acc.isMuted && acc.unreadCount);
return isMuted || (chat.topics && !hasUnmutedUnreadTopics);
}, [chat, isMuted]);
const hasUnreadMark = topic ? false : chat.hasUnreadMark;
const isShown = Boolean(
chat.unreadCount || chat.unreadMentionsCount || chat.hasUnreadMark || isPinned || chat.unreadReactionsCount,
unreadCount || unreadMentionsCount || hasUnreadMark || isPinned || unreadReactionsCount
|| isTopicUnopened,
);
const isUnread = Boolean(chat.unreadCount || chat.hasUnreadMark);
const isUnread = Boolean(unreadCount || hasUnreadMark);
const className = buildClassName(
'Badge',
isMuted && 'muted',
shouldBeMuted && 'muted',
!isUnread && isPinned && 'pinned',
isUnread && 'unread',
);
function renderContent() {
const unreadReactionsElement = chat.unreadReactionsCount && (
<div className={buildClassName('Badge reaction', isMuted && 'muted')}>
const unreadReactionsElement = unreadReactionsCount && (
<div className={buildClassName('Badge reaction', shouldBeMuted && 'muted')}>
<i className="icon-heart" />
</div>
);
const unreadMentionsElement = chat.unreadMentionsCount && (
const unreadMentionsElement = unreadMentionsCount && (
<div className="Badge mention">
<i className="icon-mention" />
</div>
);
const unreadCountElement = (chat.hasUnreadMark || chat.unreadCount) ? (
const unopenedTopicElement = isTopicUnopened && (
<div className={buildClassName('Badge unopened', shouldBeMuted && 'muted')} />
);
const unreadCountElement = (hasUnreadMark || unreadCount) ? (
<div className={className}>
{!chat.hasUnreadMark && <AnimatedCounter text={formatIntegerCompact(chat.unreadCount!)} />}
{!hasUnreadMark && <AnimatedCounter text={formatIntegerCompact(unreadCount!)} />}
</div>
) : undefined;
@ -54,12 +92,21 @@ const Badge: FC<OwnProps> = ({ chat, isPinned, isMuted }) => {
</div>
);
const elements = [unreadReactionsElement, unreadMentionsElement, unreadCountElement, pinnedElement].filter(Boolean);
const elements = [
unopenedTopicElement, unreadReactionsElement, unreadMentionsElement, unreadCountElement, pinnedElement,
].filter(Boolean);
if (elements.length === 0) return undefined;
if (elements.length === 1) return elements[0];
if (shouldShowOnlyMostImportant) {
const importanceOrderedElements = [
unreadMentionsElement, unreadCountElement, unreadReactionsElement, pinnedElement,
].filter(Boolean);
return importanceOrderedElements[0];
}
return (
<div className="Badge-wrapper">
{elements}

View File

@ -28,6 +28,14 @@
.Avatar.online::after {
border-color: var(--color-chat-hover);
}
.status-badge-wrapper {
--outline-color: var(--color-chat-hover);
}
.ChatCallStatus {
border-color: var(--color-chat-hover);
}
}
&:last-of-type {
@ -41,12 +49,29 @@
.Avatar.online::after {
border-color: var(--color-chat-hover);
}
.ChatCallStatus {
border-color: var(--color-chat-hover);
}
}
&.active-forum {
.status-badge-wrapper {
--outline-color: var(--color-chat-hover);
}
}
}
@media (min-width: 600px) {
&.selected,
&.selected:hover {
&.active-forum.forum,
&.active-forum.forum:hover {
.status-badge-wrapper {
--outline-color: var(--color-chat-hover);
}
}
&.selected:not(.forum),
&.selected:not(.forum):hover {
--background-color: var(--color-chat-active) !important;
.custom-emoji.custom-color {
@ -63,6 +88,10 @@
background: var(--color-white);
}
.ChatCallStatus {
border-color: var(--color-chat-active) !important;
}
.ListItem-button {
--background-color: var(--color-chat-active) !important;
--color-text: var(--color-white);
@ -77,25 +106,98 @@
color: var(--color-white) !important;
}
.general-forum-icon {
color: var(--color-white) !important;
}
.Badge:not(.pinned) {
background: var(--color-white);
color: var(--color-chat-active);
}
.Badge:not(.pinned).muted {
color: var(--color-white);
background: #FFFFFF33;
}
.status-badge-wrapper-visible .Badge:not(.pinned).muted {
background: var(--color-chat-active-greyed);
--outline-color: transparent;
}
.status-badge-wrapper-visible .Badge:not(.pinned):not(.muted) {
--outline-color: transparent;
}
}
}
&.smaller .ListItem-button {
height: 4.5rem;
}
&.active-forum::before {
content: '';
position: absolute;
top: 50%;
left: -0.5rem;
width: 0.375rem;
height: 75%;
transform: translateY(-50%);
background: var(--color-primary);
z-index: 1;
border-start-end-radius: var(--border-radius-default);
border-end-end-radius: var(--border-radius-default);
}
@media (max-width: 600px) {
.ListItem-button {
border-radius: 0 !important;
}
}
.ripple-container {
z-index: 2;
}
.status {
height: 3.375rem;
position: relative;
flex-shrink: 0;
z-index: 1;
background: var(--background-color);
}
.status-badge-wrapper {
position: absolute;
bottom: 0;
right: 0.5rem;
z-index: 2;
transition-duration: 0.25s;
transition-timing-function: cubic-bezier(0.16,1.25,0.64,1);
transition-property: opacity, transform;
opacity: 0;
transform: scale(0);
--outline-color: var(--color-background);
.Badge {
box-shadow: 0 0 0 2px var(--outline-color);
}
}
.status-badge-wrapper-visible {
opacity: 1;
transform: scale(1);
}
.info {
transition-duration: 0.25s;
transition-property: ease-in-out;
transition-property: opacity, transform;
.subtitle {
margin-top: -0.125rem;
}
@ -112,6 +214,11 @@
}
}
.general-forum-icon {
font-size: 1.25rem;
color: var(--color-text-secondary);
}
.LastMessageMeta {
body.is-ios & {
font-size: 0.875rem;
@ -218,5 +325,19 @@
unicode-bidi: plaintext;
}
}
&.active-forum::before {
left: auto;
right: 0.0625rem;
}
}
&.smaller .info {
transform: translateX(-25%);
opacity: 0;
}
&[dir="rtl"].smaller .info {
transform: translateX(25%);
}
}

View File

@ -1,52 +1,58 @@
import type { FC } from '../../../lib/teact/teact';
import React, {
memo, useCallback, useLayoutEffect, useMemo, useRef,
memo, useCallback, useEffect, useRef,
} from '../../../lib/teact/teact';
import { getActions, getGlobal, withGlobal } from '../../../global';
import { getActions, withGlobal } from '../../../global';
import type { LangFn } from '../../../hooks/useLang';
import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import type {
ApiChat, ApiUser, ApiMessage, ApiMessageOutgoingStatus, ApiFormattedText, ApiUserStatus,
ApiChat,
ApiUser,
ApiMessage,
ApiMessageOutgoingStatus,
ApiFormattedText,
ApiUserStatus,
ApiTopic,
ApiTypingStatus,
} from '../../../api/types';
import type { AnimationLevel } from '../../../types';
import { MAIN_THREAD_ID } from '../../../api/types';
import type { ChatAnimationTypes } from './hooks';
import { ANIMATION_END_DELAY } from '../../../config';
import { MAIN_THREAD_ID } from '../../../api/types';
import { IS_SINGLE_COLUMN_LAYOUT } from '../../../util/environment';
import {
isUserId,
isActionMessage,
getPrivateChatUserId,
getMessageAction,
getMessageSenderName,
isChatChannel,
getMessageMediaHash,
getMessageMediaThumbDataUri,
getMessageVideo,
getMessageSticker,
selectIsChatMuted,
getMessageRoundVideo,
} from '../../../global/helpers';
import {
selectChat, selectUser, selectChatMessage, selectOutgoingStatus, selectDraft, selectCurrentMessageList,
selectNotifySettings, selectNotifyExceptions, selectUserStatus, selectIsDefaultEmojiStatusPack,
selectChat,
selectUser,
selectChatMessage,
selectOutgoingStatus,
selectDraft,
selectCurrentMessageList,
selectNotifySettings,
selectNotifyExceptions,
selectUserStatus,
selectIsDefaultEmojiStatusPack,
selectTopicFromMessage,
selectThreadParam,
selectIsForumPanelOpen,
} from '../../../global/selectors';
import { renderActionMessageText } from '../../common/helpers/renderActionMessageText';
import renderText from '../../common/helpers/renderText';
import { renderTextWithEntities } from '../../common/helpers/renderTextWithEntities';
import { fastRaf } from '../../../util/schedulers';
import buildClassName from '../../../util/buildClassName';
import { fastRaf } from '../../../util/schedulers';
import buildStyle from '../../../util/buildStyle';
import useEnsureMessage from '../../../hooks/useEnsureMessage';
import useChatContextActions from '../../../hooks/useChatContextActions';
import useFlag from '../../../hooks/useFlag';
import useMedia from '../../../hooks/useMedia';
import { ChatAnimationTypes } from './hooks';
import useLang from '../../../hooks/useLang';
import useChatListEntry from './hooks/useChatListEntry';
import { useIsIntersecting } from '../../../hooks/useIntersectionObserver';
import usePrevious from '../../../hooks/usePrevious';
import Avatar from '../../common/Avatar';
import TypingStatus from '../../common/TypingStatus';
import LastMessageMeta from '../../common/LastMessageMeta';
import DeleteChatModal from '../../common/DeleteChatModal';
import ListItem from '../../ui/ListItem';
@ -55,17 +61,19 @@ import ChatFolderModal from '../ChatFolderModal.async';
import ChatCallStatus from './ChatCallStatus';
import ReportModal from '../../common/ReportModal';
import FullNameTitle from '../../common/FullNameTitle';
import MessageSummary from '../../common/MessageSummary';
import './Chat.scss';
const TRANSFORM_TO_TOPIC_LIST_ANIMATION_DELAY = 300;
type OwnProps = {
style?: string;
chatId: string;
folderId?: number;
orderDiff: number;
animationType: ChatAnimationTypes;
isPinned?: boolean;
offsetTopInSmallerMode: number;
offsetTop: number;
observeIntersection?: ObserveFn;
onDragEnter?: (chatId: string) => void;
};
@ -79,20 +87,21 @@ type StateProps = {
actionTargetUserIds?: string[];
actionTargetMessage?: ApiMessage;
actionTargetChatId?: string;
lastMessageSender?: ApiUser;
lastMessageSender?: ApiUser | ApiChat;
lastMessageOutgoingStatus?: ApiMessageOutgoingStatus;
draft?: ApiFormattedText;
animationLevel?: AnimationLevel;
isSelected?: boolean;
isForumPanelActive?: boolean;
canScrollDown?: boolean;
canChangeFolder?: boolean;
lastSyncTime?: number;
lastMessageTopic?: ApiTopic;
typingStatus?: ApiTypingStatus;
forumPanelChatId?: string;
};
const ANIMATION_DURATION = 200;
const Chat: FC<OwnProps & StateProps> = ({
style,
chatId,
folderId,
orderDiff,
@ -109,22 +118,28 @@ const Chat: FC<OwnProps & StateProps> = ({
lastMessageOutgoingStatus,
actionTargetMessage,
actionTargetChatId,
offsetTopInSmallerMode,
offsetTop,
draft,
animationLevel,
isSelected,
isForumPanelActive,
canScrollDown,
canChangeFolder,
lastSyncTime,
lastMessageTopic,
typingStatus,
forumPanelChatId,
onDragEnter,
}) => {
const {
openChat,
openForumPanel,
closeForumPanel,
focusLastMessage,
loadTopics,
} = getActions();
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null);
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useFlag();
const [isChatFolderModalOpen, openChatFolderModal, closeChatFolderModal] = useFlag();
const [isReportModalOpen, openReportModal, closeReportModal] = useFlag();
@ -132,74 +147,40 @@ const Chat: FC<OwnProps & StateProps> = ({
const [shouldRenderChatFolderModal, markRenderChatFolderModal, unmarkRenderChatFolderModal] = useFlag();
const [shouldRenderReportModal, markRenderReportModal, unmarkRenderReportModal] = useFlag();
const { lastMessage, typingStatus } = chat || {};
const isAction = lastMessage && isActionMessage(lastMessage);
const { lastMessage, isForum } = chat || {};
useEnsureMessage(chatId, isAction ? lastMessage.replyToMessageId : undefined, actionTargetMessage);
const { renderSubtitle, ref } = useChatListEntry({
chat,
chatId,
lastMessage,
typingStatus,
draft,
actionTargetMessage,
actionTargetUserIds,
actionTargetChatId,
lastMessageTopic,
lastMessageSender,
observeIntersection,
const mediaThumbnail = lastMessage && !getMessageSticker(lastMessage)
? getMessageMediaThumbDataUri(lastMessage)
: undefined;
const mediaBlobUrl = useMedia(lastMessage ? getMessageMediaHash(lastMessage, 'micro') : undefined);
const isRoundVideo = Boolean(lastMessage && getMessageRoundVideo(lastMessage));
const actionTargetUsers = useMemo(() => {
if (!actionTargetUserIds) {
return undefined;
}
// No need for expensive global updates on users, so we avoid them
const usersById = getGlobal().users.byId;
return actionTargetUserIds.map((userId) => usersById[userId]).filter(Boolean);
}, [actionTargetUserIds]);
// Sets animation excess values when `orderDiff` changes and then resets excess values to animate.
useLayoutEffect(() => {
const element = ref.current;
if (animationLevel === 0 || !element) {
return;
}
// TODO Refactor animation: create `useListAnimation` that owns `orderDiff` and `animationType`
if (animationType === ChatAnimationTypes.Opacity) {
element.style.opacity = '0';
fastRaf(() => {
element.classList.add('animate-opacity');
element.style.opacity = '1';
});
} else if (animationType === ChatAnimationTypes.Move) {
element.style.transform = `translate3d(0, ${-orderDiff * 100}%, 0)`;
fastRaf(() => {
element.classList.add('animate-transform');
element.style.transform = '';
});
} else {
return;
}
setTimeout(() => {
fastRaf(() => {
element.classList.remove('animate-opacity', 'animate-transform');
element.style.opacity = '';
element.style.transform = '';
});
}, ANIMATION_DURATION + ANIMATION_END_DELAY);
}, [animationLevel, orderDiff, animationType]);
animationType,
animationLevel,
orderDiff,
});
const handleClick = useCallback(() => {
if (chat?.isForum) {
openForumPanel({ chatId });
return;
}
if (forumPanelChatId) closeForumPanel();
openChat({ id: chatId, shouldReplaceHistory: true }, { forceOnHeavyAnimation: true });
if (isSelected && canScrollDown) {
focusLastMessage();
}
}, [
isSelected,
canScrollDown,
openChat,
chatId,
chat?.isForum, forumPanelChatId, closeForumPanel, openChat, chatId, isSelected, canScrollDown, openForumPanel,
focusLastMessage,
]);
@ -235,79 +216,66 @@ const Chat: FC<OwnProps & StateProps> = ({
canChangeFolder,
});
const lang = useLang();
const isIntersecting = useIsIntersecting(ref, observeIntersection);
// Load the forum topics to display unread count badge
useEffect(() => {
if (isIntersecting && lastSyncTime && isForum && chat && chat.topics === undefined) {
loadTopics({ chatId });
}
}, [chat, chatId, isForum, isIntersecting, lastSyncTime, loadTopics]);
const isOnForumPanel = chatId === forumPanelChatId;
const prevIsForumPanelActive = usePrevious(isForumPanelActive);
const isAnimatingRef = useRef(false);
if (prevIsForumPanelActive !== isForumPanelActive) {
isAnimatingRef.current = true;
}
// Animate changing to smaller chat size when navigating to/from forum topic list
useEffect(() => {
const current = ref.current;
if (current && isAnimatingRef.current && isForumPanelActive !== prevIsForumPanelActive) {
current.classList.add('animate-transform');
current.style.transform = '';
setTimeout(() => {
// Wait one more frame for better animation performance
fastRaf(() => {
isAnimatingRef.current = false;
current.classList.remove('animate-transform');
});
}, TRANSFORM_TO_TOPIC_LIST_ANIMATION_DELAY + ANIMATION_END_DELAY);
}
}, [ref, isForumPanelActive, prevIsForumPanelActive]);
if (!chat) {
return undefined;
}
function renderLastMessageOrTyping() {
if (typingStatus && lastMessage && typingStatus.timestamp > lastMessage.date * 1000) {
return <TypingStatus typingStatus={typingStatus} />;
}
if (draft?.text.length) {
return (
<p className="last-message" dir={lang.isRtl ? 'auto' : 'ltr'}>
<span className="draft">{lang('Draft')}</span>
{renderTextWithEntities(draft.text, draft.entities, undefined, undefined, undefined, undefined, true)}
</p>
);
}
if (!lastMessage) {
return undefined;
}
if (isAction) {
const isChat = chat && (isChatChannel(chat) || lastMessage.senderId === lastMessage.chatId);
return (
<p className="last-message shared-canvas-container" dir={lang.isRtl ? 'auto' : 'ltr'}>
{renderActionMessageText(
lang,
lastMessage,
!isChat ? lastMessageSender : undefined,
isChat ? chat : undefined,
actionTargetUsers,
actionTargetMessage,
actionTargetChatId,
{ isEmbedded: true },
)}
</p>
);
}
const senderName = getMessageSenderName(lang, chatId, lastMessageSender);
return (
<p className="last-message shared-canvas-container" dir={lang.isRtl ? 'auto' : 'ltr'}>
{senderName && (
<>
<span className="sender-name">{renderText(senderName)}</span>
<span className="colon">:</span>
</>
)}
{renderSummary(lang, lastMessage, observeIntersection, mediaBlobUrl || mediaThumbnail, isRoundVideo)}
</p>
);
}
const className = buildClassName(
'Chat chat-item-clickable',
isUserId(chatId) ? 'private' : 'group',
isForum && 'forum',
isSelected && 'selected',
isForumPanelActive && 'smaller',
isOnForumPanel && 'active-forum',
);
const chatTop = isForumPanelActive ? (offsetTop - offsetTopInSmallerMode) : offsetTop;
const offsetAnimate = isForumPanelActive ? offsetTopInSmallerMode : -offsetTopInSmallerMode;
return (
<ListItem
ref={ref}
className={className}
style={style}
ripple={!IS_SINGLE_COLUMN_LAYOUT}
style={buildStyle(`top: ${chatTop}px`, isAnimatingRef.current && `transform: translateY(${offsetAnimate}px)`)}
ripple={!isForum && !IS_SINGLE_COLUMN_LAYOUT}
contextActions={contextActions}
onClick={handleClick}
onDragEnter={handleDragEnter}
shouldUsePortalForMenu={isForumPanelActive}
>
<div className="status">
<Avatar
@ -320,6 +288,13 @@ const Chat: FC<OwnProps & StateProps> = ({
withVideo
observeIntersection={observeIntersection}
/>
<div className={buildClassName(
'status-badge-wrapper',
isForumPanelActive && 'status-badge-wrapper-visible',
)}
>
<Badge chat={chat} isMuted={isMuted} shouldShowOnlyMostImportant />
</div>
{chat.isCallActive && chat.isCallNotEmpty && (
<ChatCallStatus isSelected={isSelected} isActive={animationLevel !== 0} />
)}
@ -343,7 +318,7 @@ const Chat: FC<OwnProps & StateProps> = ({
)}
</div>
<div className="subtitle">
{renderLastMessageOrTyping()}
{renderSubtitle()}
<Badge chat={chat} isPinned={isPinned} isMuted={isMuted} />
</div>
</div>
@ -376,31 +351,6 @@ const Chat: FC<OwnProps & StateProps> = ({
);
};
function renderSummary(
lang: LangFn, message: ApiMessage, observeIntersection?: ObserveFn, blobUrl?: string, isRoundVideo?: boolean,
) {
const messageSummary = (
<MessageSummary
lang={lang}
message={message}
noEmoji={Boolean(blobUrl)}
observeIntersectionForLoading={observeIntersection}
/>
);
if (!blobUrl) {
return messageSummary;
}
return (
<span className="media-preview">
<img src={blobUrl} alt="" className={buildClassName('media-preview--image', isRoundVideo && 'round')} />
{getMessageVideo(message) && <i className="icon-play" />}
{messageSummary}
</span>
);
}
export default memo(withGlobal<OwnProps>(
(global, { chatId }): StateProps => {
const chat = selectChat(global, chatId);
@ -409,7 +359,8 @@ export default memo(withGlobal<OwnProps>(
}
const { senderId, replyToMessageId, isOutgoing } = chat.lastMessage || {};
const lastMessageSender = senderId ? selectUser(global, senderId) : undefined;
const lastMessageSender = senderId
? (selectUser(global, senderId) || selectChat(global, senderId)) : undefined;
const lastMessageAction = chat.lastMessage ? getMessageAction(chat.lastMessage) : undefined;
const actionTargetMessage = lastMessageAction && replyToMessageId
? selectChatMessage(global, chat.id, replyToMessageId)
@ -421,12 +372,16 @@ export default memo(withGlobal<OwnProps>(
threadId: currentThreadId,
type: messageListType,
} = selectCurrentMessageList(global) || {};
const isForumPanelActive = selectIsForumPanelOpen(global);
const isSelected = chatId === currentChatId && currentThreadId === MAIN_THREAD_ID;
const user = privateChatUserId ? selectUser(global, privateChatUserId) : undefined;
const userStatus = privateChatUserId ? selectUserStatus(global, privateChatUserId) : undefined;
const statusEmoji = user?.emojiStatus && global.customEmojis.byId[user.emojiStatus.documentId];
const isEmojiStatusColored = statusEmoji && selectIsDefaultEmojiStatusPack(global, statusEmoji.stickerSetInfo);
const lastMessageTopic = chat.lastMessage && selectTopicFromMessage(global, chat.lastMessage);
const typingStatus = selectThreadParam(global, chatId, MAIN_THREAD_ID, 'typingStatus');
return {
chat,
@ -437,6 +392,7 @@ export default memo(withGlobal<OwnProps>(
actionTargetMessage,
draft: selectDraft(global, chatId, MAIN_THREAD_ID),
animationLevel: global.settings.byKey.animationLevel,
isForumPanelActive,
isSelected,
canScrollDown: isSelected && messageListType === 'thread',
canChangeFolder: (global.chatFolders.orderedIds?.length || 0) > 1,
@ -447,6 +403,9 @@ export default memo(withGlobal<OwnProps>(
user,
userStatus,
isEmojiStatusColored,
lastMessageTopic,
typingStatus,
forumPanelChatId: global.forumPanelChatId,
};
},
)(Chat));

View File

@ -26,6 +26,7 @@ import ChatList from './ChatList';
type OwnProps = {
onScreenSelect: (screen: SettingsScreens) => void;
foldersDispatch: FolderEditDispatch;
shouldHideFolderTabs?: boolean;
};
type StateProps = {
@ -51,6 +52,7 @@ const ChatFolders: FC<OwnProps & StateProps> = ({
lastSyncTime,
shouldSkipHistoryAnimations,
maxFolders,
shouldHideFolderTabs,
}) => {
const {
loadChatFolders,
@ -221,7 +223,12 @@ const ChatFolders: FC<OwnProps & StateProps> = ({
const shouldRenderFolders = folderTabs && folderTabs.length > 1;
return (
<div className="ChatFolders">
<div
className={buildClassName(
'ChatFolders',
shouldRenderFolders && shouldHideFolderTabs && 'ChatFolders--tabs-hidden',
)}
>
{shouldRenderFolders ? (
<TabList tabs={folderTabs} activeTab={activeChatFolder} onSwitchTab={handleSwitchTab} areFolders />
) : shouldRenderPlaceholder ? (

View File

@ -1,7 +1,7 @@
import React, {
memo, useMemo, useEffect, useRef, useCallback,
memo, useEffect, useRef, useCallback, useMemo,
} from '../../../lib/teact/teact';
import { getActions } from '../../../global';
import { getActions, getGlobal } from '../../../global';
import type { FC } from '../../../lib/teact/teact';
import type { SettingsScreens } from '../../../types';
@ -9,21 +9,20 @@ import type { FolderEditDispatch } from '../../../hooks/reducers/useFoldersReduc
import {
ALL_FOLDER_ID,
ARCHIVED_FOLDER_ID,
ARCHIVED_FOLDER_ID, CHAT_HEIGHT_FORUM_PX,
CHAT_HEIGHT_PX,
CHAT_LIST_SLICE,
} from '../../../config';
import { IS_MAC_OS, IS_PWA } from '../../../util/environment';
import { mapValues } from '../../../util/iteratees';
import { getPinnedChatsCount, getOrderKey } from '../../../util/folderManager';
import { selectChat } from '../../../global/selectors';
import usePrevious from '../../../hooks/usePrevious';
import useInfiniteScroll from '../../../hooks/useInfiniteScroll';
import { useFolderManagerForOrderedIds } from '../../../hooks/useFolderManager';
import { useChatAnimationType } from './hooks';
import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver';
import { useHotkeys } from '../../../hooks/useHotkeys';
import useDebouncedCallback from '../../../hooks/useDebouncedCallback';
import useChatOrderDiff from './hooks/useChatOrderDiff';
import InfiniteScroll from '../../ui/InfiniteScroll';
import Loading from '../../ui/Loading';
@ -60,28 +59,7 @@ const ChatList: FC<OwnProps> = ({
const orderedIds = useFolderManagerForOrderedIds(resolvedFolderId);
const orderById = useMemo(() => {
if (!orderedIds) {
return undefined;
}
return orderedIds.reduce((acc, id, i) => {
acc[id] = i;
return acc;
}, {} as Record<string, number>);
}, [orderedIds]);
const prevOrderById = usePrevious(orderById);
const orderDiffById = useMemo(() => {
if (!orderById || !prevOrderById) {
return {};
}
return mapValues(orderById, (order, id) => {
return prevOrderById[id] !== undefined ? order - prevOrderById[id] : -Infinity;
});
}, [orderById, prevOrderById]);
const { orderDiffById, getAnimationType } = useChatOrderDiff(orderedIds);
const [viewportIds, getMore] = useInfiniteScroll(undefined, orderedIds, undefined, CHAT_LIST_SLICE);
@ -122,8 +100,6 @@ const ChatList: FC<OwnProps> = ({
};
}, [isActive, openChat, openNextChat, orderedIds]);
const getAnimationType = useChatAnimationType(orderDiffById);
const { observe } = useIntersectionObserver({
rootRef: containerRef,
throttleMs: INTERSECTION_THROTTLE,
@ -145,12 +121,31 @@ const ChatList: FC<OwnProps> = ({
shouldIgnoreDragRef.current = true;
}, []);
const viewportOffsetPx = useMemo(() => {
if (!viewportIds?.length) return 0;
const global = getGlobal();
const viewportOffset = orderedIds!.indexOf(viewportIds![0]);
return orderedIds!.reduce((acc, id, i) => {
if (i >= viewportOffset) {
return acc;
}
return acc + (selectChat(global, id)!.isForum ? CHAT_HEIGHT_FORUM_PX : CHAT_HEIGHT_PX);
}, 0);
}, [orderedIds, viewportIds]);
function renderChats() {
const viewportOffset = orderedIds!.indexOf(viewportIds![0]);
const global = getGlobal();
const pinnedCount = getPinnedChatsCount(resolvedFolderId) || 0;
let currentChatListHeight = viewportOffsetPx;
return viewportIds!.map((id, i) => {
const isPinned = viewportOffset + i < pinnedCount;
const chatTop = currentChatListHeight;
const chatTopSmaller = (viewportOffset + i) * CHAT_HEIGHT_PX;
currentChatListHeight += (selectChat(global, id)!.isForum ? CHAT_HEIGHT_FORUM_PX : CHAT_HEIGHT_PX);
return (
<Chat
@ -161,7 +156,8 @@ const ChatList: FC<OwnProps> = ({
folderId={folderId}
animationType={getAnimationType(id)}
orderDiff={orderDiffById[id]}
style={`top: ${(viewportOffset + i) * CHAT_HEIGHT_PX}px;`}
offsetTop={chatTop}
offsetTopInSmallerMode={chatTop - chatTopSmaller}
observeIntersection={observe}
onDragEnter={handleDragEnter}
/>

View File

@ -1,4 +1,4 @@
.EmptyFolder {
.root {
width: 100%;
height: 80%;
display: flex;
@ -11,13 +11,16 @@
}
.sticker {
height: 8rem;
height: 6rem;
margin-bottom: 1.875rem;
}
.title {
font-size: 1.25rem;
margin-bottom: 0.125rem;
word-break: break-word;
text-align: center;
max-width: 100%;
}
.description {
@ -30,12 +33,21 @@
}
}
.Button.pill {
:global(.Button.pill) {
max-width: 100%;
margin-top: 0.625rem;
font-weight: 500;
padding-inline-start: 0.75rem;
unicode-bidi: plaintext;
justify-content: start;
.button-text {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
i {
margin-inline-end: 0.625rem;
font-size: 1.5rem;

View File

@ -13,7 +13,7 @@ import useLang from '../../../hooks/useLang';
import Button from '../../ui/Button';
import AnimatedIconFromSticker from '../../common/AnimatedIconFromSticker';
import './EmptyFolder.scss';
import styles from './EmptyFolder.module.scss';
type OwnProps = {
folderId?: number;
@ -27,7 +27,7 @@ type StateProps = {
animatedEmoji?: ApiSticker;
};
const ICON_SIZE = 128;
const ICON_SIZE = 96;
const EmptyFolder: FC<OwnProps & StateProps> = ({
chatFolder, animatedEmoji, foldersDispatch, onScreenSelect,
@ -40,12 +40,12 @@ const EmptyFolder: FC<OwnProps & StateProps> = ({
}, [chatFolder, foldersDispatch, onScreenSelect]);
return (
<div className="EmptyFolder">
<div className="sticker">
<div className={styles.root}>
<div className={styles.sticker}>
{animatedEmoji && <AnimatedIconFromSticker sticker={animatedEmoji} size={ICON_SIZE} />}
</div>
<h3 className="title" dir="auto">{lang('FilterNoChatsToDisplay')}</h3>
<p className="description" dir="auto">
<h3 className={styles.title} dir="auto">{lang('FilterNoChatsToDisplay')}</h3>
<p className={styles.description} dir="auto">
{lang(chatFolder ? 'ChatList.EmptyChatListFilterText' : 'Chat.EmptyChat')}
</p>
{chatFolder && foldersDispatch && onScreenSelect && (
@ -58,7 +58,9 @@ const EmptyFolder: FC<OwnProps & StateProps> = ({
isRtl={lang.isRtl}
>
<i className="icon-settings" />
{lang('ChatList.EmptyChatListEditFilter')}
<div className={styles.buttonText}>
{lang('ChatList.EmptyChatListEditFilter')}
</div>
</Button>
)}
</div>

View File

@ -0,0 +1,58 @@
import type { FC } from '../../../lib/teact/teact';
import React, { memo, useCallback } from '../../../lib/teact/teact';
import { withGlobal } from '../../../global';
import type { ApiSticker } from '../../../api/types';
import { IS_SINGLE_COLUMN_LAYOUT } from '../../../util/environment';
import { selectAnimatedEmoji } from '../../../global/selectors';
import useLang from '../../../hooks/useLang';
import Button from '../../ui/Button';
import AnimatedIconFromSticker from '../../common/AnimatedIconFromSticker';
import styles from './EmptyFolder.module.scss';
type StateProps = {
animatedEmoji?: ApiSticker;
};
const ICON_SIZE = 96;
// TODO[forums] Open create topic screen if has permission
const EmptyTopic: FC<StateProps> = ({
animatedEmoji,
}) => {
const lang = useLang();
const handleCreateTopic = useCallback(() => {
}, []);
return (
<div className={styles.root}>
<div className={styles.sticker}>
{animatedEmoji && <AnimatedIconFromSticker sticker={animatedEmoji} size={ICON_SIZE} />}
</div>
<h3 className={styles.title} dir="auto">{lang('ChatList.EmptyTopicsTitle')}</h3>
<Button
ripple={!IS_SINGLE_COLUMN_LAYOUT}
fluid
pill
onClick={handleCreateTopic}
size="smaller"
isRtl={lang.isRtl}
>
<i className="icon-add" />
<div className={styles.buttonText}>
{lang('ChatList.EmptyTopicsCreate')}
</div>
</Button>
</div>
);
};
export default memo(withGlobal((global): StateProps => {
return {
animatedEmoji: selectAnimatedEmoji(global, '👀'),
};
})(EmptyTopic));

View File

@ -0,0 +1,96 @@
.root {
position: absolute;
top: 0;
right: 0;
left: 4.75rem;
z-index: var(--z-forum-panel);
height: 100%;
background-color: var(--color-background);
border-left: 1px solid var(--color-borders);
display: flex;
flex-direction: column;
&.rtl {
left: 0;
right: 4.75rem;
transform: translateX(-100%);
border-left: none;
border-right: 1px solid var(--color-borders);
}
transition: transform var(--slide-transition);
transform: translate3d(100%, 0, 0);
:global(.chat-list) {
position: relative;
overflow-x: hidden;
padding-top: 0 !important;
}
:global(.HeaderActions) {
margin-left: auto;
display: flex;
align-items: center;
justify-content: flex-end;
}
}
.group-call {
position: static !important;
}
.border-bottom {
width: 100%;
height: 0;
transition: 0.1s ease-out border-color;
border-bottom: 0.0625rem solid transparent;
}
.scrolled .border-bottom {
border-color: var(--color-borders);
}
.scroll-top-handler {
position: absolute;
top: 0;
z-index: 100;
width: 100%;
height: 1px;
}
.info {
margin-left: 0.4375rem;
min-width: 0;
width: 100%;
cursor: pointer;
:global(.info) {
display: flex;
flex-direction: column;
justify-content: center;
flex-grow: 1;
overflow: hidden;
}
:global(.fullName) {
line-height: 1.375rem;
white-space: pre;
overflow: hidden;
text-overflow: ellipsis;
unicode-bidi: plaintext;
font-size: 1rem !important;
font-weight: 500 !important;
margin: 0 !important;
}
:global(.status) {
font-size: 0.875rem;
line-height: 1.125rem;
margin: 0;
color: var(--color-text-secondary);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
display: inline-block;
}
}

View File

@ -0,0 +1,256 @@
import React, {
memo, useCallback, useEffect, useMemo, useRef, useState,
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { FC } from '../../../lib/teact/teact';
import type { ApiChat } from '../../../api/types';
import { MAIN_THREAD_ID } from '../../../api/types';
import {
TOPICS_SLICE, TOPIC_HEIGHT_PX, TOPIC_LIST_SENSITIVE_AREA,
} from '../../../config';
import { selectChat, selectCurrentMessageList } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import { fastRaf } from '../../../util/schedulers';
import { getOrderedTopics } from '../../../global/helpers';
import captureEscKeyListener from '../../../util/captureEscKeyListener';
import { waitForTransitionEnd } from '../../../util/cssAnimationEndListeners';
import useInfiniteScroll from '../../../hooks/useInfiniteScroll';
import { useIntersectionObserver, useOnIntersect } from '../../../hooks/useIntersectionObserver';
import useChatOrderDiff from './hooks/useChatOrderDiff';
import useLang from '../../../hooks/useLang';
import usePrevious from '../../../hooks/usePrevious';
import useHistoryBack from '../../../hooks/useHistoryBack';
import { dispatchHeavyAnimationEvent } from '../../../hooks/useHeavyAnimationCheck';
import GroupChatInfo from '../../common/GroupChatInfo';
import Button from '../../ui/Button';
import Topic from './Topic';
import InfiniteScroll from '../../ui/InfiniteScroll';
import Loading from '../../ui/Loading';
import HeaderActions from '../../middle/HeaderActions';
import GroupCallTopPane from '../../calls/group/GroupCallTopPane';
import EmptyTopic from './EmptyTopic';
import styles from './ForumPanel.module.scss';
type OwnProps = {
isOpen?: boolean;
isHidden?: boolean;
onTopicSearch?: NoneToVoidFunction;
onCloseAnimationEnd?: VoidFunction;
};
type StateProps = {
chat?: ApiChat;
currentTopicId?: number;
lastSyncTime?: number;
};
const INTERSECTION_THROTTLE = 200;
const ForumPanel: FC<OwnProps & StateProps> = ({
chat,
currentTopicId,
isOpen,
isHidden,
lastSyncTime,
onTopicSearch,
onCloseAnimationEnd,
}) => {
const {
closeForumPanel, openChatWithInfo, loadTopics,
} = getActions();
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const scrollTopHandlerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (lastSyncTime && chat && !chat.topics) {
loadTopics({ chatId: chat.id });
}
}, [chat, lastSyncTime, loadTopics]);
const [isScrolled, setIsScrolled] = useState(false);
const lang = useLang();
const handleClose = useCallback(() => {
closeForumPanel();
}, [closeForumPanel]);
const handleToggleChatInfo = useCallback(() => {
if (!chat) return;
openChatWithInfo({ id: chat.id, shouldReplaceHistory: true });
}, [chat, openChatWithInfo]);
const { observe } = useIntersectionObserver({
rootRef: containerRef,
throttleMs: INTERSECTION_THROTTLE,
});
useOnIntersect(scrollTopHandlerRef, observe, ({ isIntersecting }) => {
setIsScrolled(!isIntersecting);
});
const orderedIds = useMemo(() => {
return chat?.topics
? getOrderedTopics(Object.values(chat.topics), chat.orderedPinnedTopicIds).map(({ id }) => id)
: [];
}, [chat]);
const { orderDiffById, getAnimationType } = useChatOrderDiff(orderedIds);
const [viewportIds, getMore] = useInfiniteScroll(() => {
if (!chat || !lastSyncTime) return;
loadTopics({ chatId: chat.id });
}, orderedIds, !chat?.topicsCount || orderedIds.length >= chat.topicsCount, TOPICS_SLICE);
const shouldRenderRef = useRef(false);
const isVisible = isOpen && !isHidden;
const prevIsVisible = usePrevious(isVisible);
if (prevIsVisible !== isVisible) {
shouldRenderRef.current = false;
}
useHistoryBack({
isActive: isVisible,
onBack: handleClose,
});
useEffect(() => (isVisible ? captureEscKeyListener(handleClose) : undefined), [handleClose, isVisible]);
useEffect(() => {
if (prevIsVisible !== isVisible) {
const dispatchHeavyAnimationStop = dispatchHeavyAnimationEvent();
waitForTransitionEnd(ref.current!, () => {
dispatchHeavyAnimationStop();
});
// For performance reasons, we delay animation of the topic list panel to the next animation frame
fastRaf(() => {
if (isVisible) {
shouldRenderRef.current = true;
ref.current!.style.transform = 'none';
} else {
shouldRenderRef.current = false;
ref.current!.style.transform = '';
}
});
}
}, [isVisible, prevIsVisible]);
function renderTopics() {
const viewportOffset = orderedIds!.indexOf(viewportIds![0]);
return viewportIds?.map((id, i) => (
<Topic
key={id}
chatId={chat!.id}
topic={chat!.topics![id]}
style={`top: ${(viewportOffset + i) * TOPIC_HEIGHT_PX}px;`}
isSelected={currentTopicId === id}
observeIntersection={observe}
animationType={getAnimationType(id)}
orderDiff={orderDiffById[id]}
/>
));
}
const isLoading = chat?.topics === undefined;
return (
<div
className={buildClassName(
styles.root,
isScrolled && styles.scrolled,
lang.isRtl && styles.rtl,
)}
ref={ref}
onTransitionEnd={!isOpen ? onCloseAnimationEnd : undefined}
>
<div className="left-header">
<Button
round
size="smaller"
color="translucent"
onClick={handleClose}
ariaLabel={lang('Close')}
>
<i className="icon-close" />
</Button>
{chat && (
<GroupChatInfo
noAvatar
className={styles.info}
chatId={chat.id}
onClick={handleToggleChatInfo}
/>
)}
{chat
&& (
<HeaderActions
chatId={chat.id}
threadId={MAIN_THREAD_ID}
messageListType="thread"
canExpandActions={false}
withForumActions
onTopicSearch={onTopicSearch}
/>
)}
</div>
{chat && <GroupCallTopPane chatId={chat.id} hasPinnedOffset={false} className={styles.groupCall} />}
<div className={styles.borderBottom} />
<InfiniteScroll
className="chat-list custom-scroll"
ref={containerRef}
items={viewportIds}
preloadBackwards={TOPICS_SLICE}
withAbsolutePositioning
maxHeight={(orderedIds?.length || 0) * TOPIC_HEIGHT_PX}
onLoadMore={getMore}
sensitiveArea={TOPIC_LIST_SENSITIVE_AREA}
beforeChildren={<div ref={scrollTopHandlerRef} className={styles.scrollTopHandler} />}
>
{viewportIds?.length ? (
renderTopics()
) : !isLoading ? (
<EmptyTopic />
) : (
<Loading key="loading" />
)}
</InfiniteScroll>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global, ownProps, detachWhenChanged): StateProps => {
const chatId = global.forumPanelChatId;
detachWhenChanged(chatId);
const chat = chatId ? selectChat(global, chatId) : undefined;
const {
chatId: currentChatId,
threadId: currentThreadId,
} = selectCurrentMessageList(global) || {};
return {
chat,
lastSyncTime: global.lastSyncTime,
currentTopicId: chatId === currentChatId ? currentThreadId : undefined,
};
},
)(ForumPanel));

View File

@ -17,6 +17,14 @@
flex-direction: column;
overflow: hidden;
transition: 0.25s ease-out transform;
&--tabs-hidden {
transform: translateY(-3.125rem);
height: calc(100% + 3.125rem);
}
.tabs-placeholder {
height: 2.625rem;
/* stylelint-disable-next-line plugin/no-low-performance-animation-properties */

View File

@ -11,6 +11,7 @@ import { IS_TOUCH_ENV } from '../../../util/environment';
import buildClassName from '../../../util/buildClassName';
import useShowTransition from '../../../hooks/useShowTransition';
import useLang from '../../../hooks/useLang';
import useForumPanelRender from '../../../hooks/useForumPanelRender';
import Transition from '../../ui/Transition';
import LeftMainHeader from './LeftMainHeader';
@ -19,6 +20,7 @@ import LeftSearch from '../search/LeftSearch.async';
import ContactList from './ContactList.async';
import NewChatButton from '../NewChatButton';
import Button from '../../ui/Button';
import ForumPanel from './ForumPanel';
import './LeftMain.scss';
@ -30,9 +32,12 @@ type OwnProps = {
shouldSkipTransition?: boolean;
foldersDispatch: FolderEditDispatch;
isUpdateAvailable?: boolean;
isForumPanelOpen?: boolean;
isClosingSearch?: boolean;
onSearchQuery: (query: string) => void;
onContentChange: (content: LeftColumnContent) => void;
onScreenSelect: (screen: SettingsScreens) => void;
onTopicSearch: NoneToVoidFunction;
onReset: () => void;
};
@ -45,17 +50,23 @@ const LeftMain: FC<OwnProps> = ({
content,
searchQuery,
searchDate,
isClosingSearch,
contactsFilter,
shouldSkipTransition,
foldersDispatch,
isUpdateAvailable,
isForumPanelOpen,
onSearchQuery,
onContentChange,
onScreenSelect,
onReset,
onTopicSearch,
}) => {
const [isNewChatButtonShown, setIsNewChatButtonShown] = useState(IS_TOUCH_ENV);
const { shouldRenderForumPanel, handleForumPanelAnimationEnd } = useForumPanelRender(isForumPanelOpen);
const isForumPanelVisible = isForumPanelOpen && content === LeftColumnContent.ChatList;
const {
shouldRender: shouldRenderUpdateButton,
transitionClassNames: updateButtonClassNames,
@ -137,6 +148,7 @@ const LeftMain: FC<OwnProps> = ({
onMouseLeave={!IS_TOUCH_ENV ? handleMouseLeave : undefined}
>
<LeftMainHeader
shouldHideSearch={isForumPanelVisible}
content={content}
contactsFilter={contactsFilter}
onSearchQuery={onSearchQuery}
@ -145,6 +157,7 @@ const LeftMain: FC<OwnProps> = ({
onSelectArchived={handleSelectArchived}
onReset={onReset}
shouldSkipTransition={shouldSkipTransition}
isClosingSearch={isClosingSearch}
/>
<Transition
name={shouldSkipTransition ? 'none' : 'zoom-fade'}
@ -156,7 +169,13 @@ const LeftMain: FC<OwnProps> = ({
{(isActive) => {
switch (content) {
case LeftColumnContent.ChatList:
return <ChatFolders onScreenSelect={onScreenSelect} foldersDispatch={foldersDispatch} />;
return (
<ChatFolders
shouldHideFolderTabs={isForumPanelVisible}
onScreenSelect={onScreenSelect}
foldersDispatch={foldersDispatch}
/>
);
case LeftColumnContent.GlobalSearch:
return (
<LeftSearch
@ -183,6 +202,14 @@ const LeftMain: FC<OwnProps> = ({
{lang('lng_update_telegram')}
</Button>
)}
{shouldRenderForumPanel && (
<ForumPanel
isOpen={isForumPanelOpen}
isHidden={!isForumPanelVisible}
onTopicSearch={onTopicSearch}
onCloseAnimationEnd={handleForumPanelAnimationEnd}
/>
)}
<NewChatButton
isShown={isNewChatButtonShown}
onNewPrivateChat={handleSelectContacts}

View File

@ -3,6 +3,29 @@
#LeftMainHeader {
position: relative;
.DropdownMenuFiller {
width: 2.5rem;
height: 2.5rem;
}
.DropdownMenu.rtl {
transition: var(--slide-transition) transform;
position: absolute;
z-index: 2;
&.right-aligned {
transform: translateX(calc(clamp(
var(--left-column-min-width),
var(--left-column-width),
var(--left-column-max-width)
) - 4.375rem));
}
&.disable-transition {
transition: none;
}
}
.animated-menu-icon {
position: absolute;
@ -54,35 +77,10 @@
}
}
.archived-badge {
min-width: 1.5rem;
height: 1.5rem;
margin-left: auto;
background: var(--color-gray);
border-radius: 0.75rem;
padding: 0 0.4375rem;
color: white;
font-size: 0.875rem;
line-height: 1.5rem;
font-weight: 500;
text-align: center;
flex-shrink: 0;
}
.MenuItem.compact .archived-badge {
background: none;
padding: 0;
color: var(--color-text-secondary);
}
.MenuItem.compact .Switcher {
transform: scale(0.75);
}
[dir="rtl"] .archived-badge {
margin-left: 0;
margin-right: auto;
}
.Menu .bubble {
min-width: 17rem;
@ -102,4 +100,15 @@
transition: none !important;
}
}
.SearchInput {
transition-duration: 0.25s;
transition-timing-function: ease-out;
transition-property: opacity;
&--hidden {
opacity: 0;
pointer-events: none;
}
}
}

View File

@ -32,6 +32,7 @@ import useConnectionStatus from '../../../hooks/useConnectionStatus';
import { useHotkeys } from '../../../hooks/useHotkeys';
import { getPromptInstall } from '../../../util/installPrompt';
import captureEscKeyListener from '../../../util/captureEscKeyListener';
import useLeftHeaderButtonRtlForumTransition from './hooks/useLeftHeaderButtonRtlForumTransition';
import DropdownMenu from '../../ui/DropdownMenu';
import MenuItem from '../../ui/MenuItem';
@ -45,8 +46,10 @@ import ConnectionStatusOverlay from '../ConnectionStatusOverlay';
import './LeftMainHeader.scss';
type OwnProps = {
shouldHideSearch?: boolean;
content: LeftColumnContent;
contactsFilter: string;
isClosingSearch?: boolean;
shouldSkipTransition?: boolean;
onSearchQuery: (query: string) => void;
onSelectSettings: () => void;
@ -77,9 +80,11 @@ const LEGACY_VERSION_URL = 'https://web.telegram.org/?legacy=1';
const WEBK_VERSION_URL = 'https://web.telegram.org/k/';
const LeftMainHeader: FC<OwnProps & StateProps> = ({
shouldHideSearch,
content,
contactsFilter,
onSearchQuery,
isClosingSearch,
onSelectSettings,
onSelectContacts,
onSelectArchived,
@ -250,12 +255,26 @@ const LeftMainHeader: FC<OwnProps & StateProps> = ({
const versionString = IS_BETA ? `${APP_VERSION} Beta (${APP_REVISION})` : (DEBUG ? APP_REVISION : APP_VERSION);
// Disable dropdown menu RTL animation for resize
const {
shouldDisableDropdownMenuTransitionRef,
handleDropdownMenuTransitionEnd,
} = useLeftHeaderButtonRtlForumTransition(shouldHideSearch);
return (
<div className="LeftMainHeader">
<div id="LeftMainHeader" className="left-header">
{lang.isRtl && <div className="DropdownMenuFiller" />}
<DropdownMenu
trigger={MainButton}
footer={`${APP_NAME} ${versionString}`}
className={buildClassName(
lang.isRtl && 'rtl',
shouldHideSearch && lang.isRtl && 'right-aligned',
shouldDisableDropdownMenuTransitionRef.current && lang.isRtl && 'disable-transition',
)}
positionX={shouldHideSearch && lang.isRtl ? 'right' : 'left'}
onTransitionEnd={lang.isRtl ? handleDropdownMenuTransitionEnd : undefined}
>
<MenuItem
icon="saved-messages"
@ -269,7 +288,7 @@ const LeftMainHeader: FC<OwnProps & StateProps> = ({
>
<span className="menu-item-name">{lang('ArchivedChats')}</span>
{archivedUnreadChatsCount > 0 && (
<div className="archived-badge">{archivedUnreadChatsCount}</div>
<div className="right-badge">{archivedUnreadChatsCount}</div>
)}
</MenuItem>
<MenuItem
@ -357,8 +376,11 @@ const LeftMainHeader: FC<OwnProps & StateProps> = ({
<SearchInput
inputId="telegram-search-input"
parentContainerClassName="LeftSearch"
className={globalSearchChatId || searchDate ? 'with-picker-item' : ''}
value={contactsFilter || searchQuery}
className={buildClassName(
(globalSearchChatId || searchDate) ? 'with-picker-item' : undefined,
shouldHideSearch && 'SearchInput--hidden',
)}
value={isClosingSearch ? undefined : (contactsFilter || searchQuery)}
focused={isSearchFocused}
isLoading={isLoading || connectionStatusPosition === 'minimized'}
spinnerColor={connectionStatusPosition === 'minimized' ? 'yellow' : undefined}

View File

@ -0,0 +1,34 @@
.root {
:global(.ListItem-button) {
padding: 0.5rem 0.75rem !important;
:global(.last-message),
:global(.status),
:global(.typing-status) {
line-height: 1.5rem !important;
}
:global(.LastMessageMeta) {
padding-top: 0;
margin-top: -0.5rem;
margin-right: 0 !important;
}
}
:global(.fullName .emoji-small) {
width: 1rem;
height: 1rem;
}
}
.closed-icon {
color: var(--color-pinned);
font-size: 0.75rem;
margin-top: -0.5rem;
margin-right: 0.25rem;
}
.topic-icon {
margin-inline-end: 0.25rem;
}

View File

@ -0,0 +1,236 @@
import React, { memo, useCallback } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { FC } from '../../../lib/teact/teact';
import type {
ApiChat, ApiFormattedText, ApiTopic, ApiMessage, ApiMessageOutgoingStatus,
ApiTypingStatus,
ApiUser,
} from '../../../api/types';
import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import type { ChatAnimationTypes } from './hooks';
import type { AnimationLevel } from '../../../types';
import {
selectCanDeleteTopic,
selectChat,
selectChatMessage, selectCurrentMessageList,
selectDraft,
selectOutgoingStatus, selectThreadInfo, selectThreadParam, selectUser,
} from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import useChatListEntry from './hooks/useChatListEntry';
import renderText from '../../common/helpers/renderText';
import { getMessageAction } from '../../../global/helpers';
import useTopicContextActions from './hooks/useTopicContextActions';
import useFlag from '../../../hooks/useFlag';
import useLang from '../../../hooks/useLang';
import ListItem from '../../ui/ListItem';
import LastMessageMeta from '../../common/LastMessageMeta';
import Badge from './Badge';
import ConfirmDialog from '../../ui/ConfirmDialog';
import TopicIcon from '../../common/TopicIcon';
import styles from './Topic.module.scss';
type OwnProps = {
chatId: string;
topic: ApiTopic;
isSelected: boolean;
style: string;
observeIntersection?: ObserveFn;
orderDiff: number;
animationType: ChatAnimationTypes;
};
type StateProps = {
chat: ApiChat;
canDelete?: boolean;
lastMessage?: ApiMessage;
lastMessageOutgoingStatus?: ApiMessageOutgoingStatus;
actionTargetMessage?: ApiMessage;
actionTargetUserIds?: string[];
lastMessageSender?: ApiUser | ApiChat;
actionTargetChatId?: string;
animationLevel?: AnimationLevel;
typingStatus?: ApiTypingStatus;
draft?: ApiFormattedText;
canScrollDown?: boolean;
wasTopicOpened?: boolean;
};
const Topic: FC<OwnProps & StateProps> = ({
topic,
isSelected,
chatId,
chat,
style,
lastMessage,
canScrollDown,
lastMessageOutgoingStatus,
observeIntersection,
canDelete,
actionTargetMessage,
actionTargetUserIds,
actionTargetChatId,
lastMessageSender,
animationType,
animationLevel,
orderDiff,
typingStatus,
draft,
wasTopicOpened,
}) => {
const { openChat, deleteTopic, focusLastMessage } = getActions();
const lang = useLang();
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useFlag();
const [shouldRenderDeleteModal, markRenderDeleteModal, unmarkRenderDeleteModal] = useFlag();
const {
isPinned, isClosed,
} = topic;
const isMuted = topic.isMuted || (topic.isMuted === undefined && chat.isMuted);
const handleOpenDeleteModal = useCallback(() => {
markRenderDeleteModal();
openDeleteModal();
}, [markRenderDeleteModal, openDeleteModal]);
const handleDelete = useCallback(() => {
deleteTopic({ chatId: chat.id, topicId: topic.id });
}, [chat.id, deleteTopic, topic.id]);
const { renderSubtitle, ref } = useChatListEntry({
chat,
chatId,
lastMessage,
draft,
actionTargetMessage,
actionTargetUserIds,
actionTargetChatId,
lastMessageSender,
lastMessageTopic: topic,
observeIntersection,
isTopic: true,
typingStatus,
animationType,
animationLevel,
orderDiff,
});
const handleOpenTopic = useCallback(() => {
openChat({ id: chatId, threadId: topic.id, shouldReplaceHistory: true });
if (canScrollDown) {
focusLastMessage();
}
}, [openChat, chatId, topic.id, canScrollDown, focusLastMessage]);
const contextActions = useTopicContextActions(topic, chat, wasTopicOpened, canDelete, handleOpenDeleteModal);
return (
<ListItem
className={buildClassName(
styles.root,
'Chat',
isSelected && 'selected',
'chat-item-clickable',
)}
onClick={handleOpenTopic}
style={style}
contextActions={contextActions}
ref={ref}
>
<div className="info">
<div className="info-row">
<div className={buildClassName('title')}>
<TopicIcon topic={topic} className={styles.topicIcon} />
<h3 dir="auto" className="fullName">{renderText(topic.title)}</h3>
</div>
{topic.isMuted && <i className="icon-muted" />}
<div className="separator" />
{isClosed && (
<i className={buildClassName(
'icon-lock-badge',
styles.closedIcon,
)}
/>
)}
{lastMessage && (
<LastMessageMeta
message={lastMessage}
outgoingStatus={lastMessageOutgoingStatus}
/>
)}
</div>
<div className="subtitle">
{renderSubtitle()}
<Badge
chat={chat}
isPinned={isPinned}
isMuted={isMuted}
topic={topic}
wasTopicOpened={wasTopicOpened}
/>
</div>
</div>
{shouldRenderDeleteModal && (
<ConfirmDialog
isOpen={isDeleteModalOpen}
onClose={closeDeleteModal}
onCloseAnimationEnd={unmarkRenderDeleteModal}
confirmIsDestructive
confirmHandler={handleDelete}
text={lang('lng_forum_topic_delete_sure')}
confirmLabel={lang('Delete')}
/>
)}
</ListItem>
);
};
export default memo(withGlobal<OwnProps>(
(global, { chatId, topic, isSelected }) => {
const chat = selectChat(global, chatId);
const lastMessage = selectChatMessage(global, chatId, topic.lastMessageId)!;
const { senderId, replyToMessageId, isOutgoing } = lastMessage || {};
const lastMessageSender = senderId
? (selectUser(global, senderId) || selectChat(global, senderId)) : undefined;
const lastMessageAction = lastMessage ? getMessageAction(lastMessage) : undefined;
const actionTargetMessage = lastMessageAction && replyToMessageId
? selectChatMessage(global, chatId, replyToMessageId)
: undefined;
const { targetUserIds: actionTargetUserIds, targetChatId: actionTargetChatId } = lastMessageAction || {};
const typingStatus = selectThreadParam(global, chatId, topic.id, 'typingStatus');
const draft = selectDraft(global, chatId, topic.id);
const threadInfo = selectThreadInfo(global, chatId, topic.id);
const wasTopicOpened = Boolean(threadInfo?.lastReadInboxMessageId);
const { chatId: currentChatId, threadId: currentThreadId } = selectCurrentMessageList(global) || {};
return {
chat,
lastMessage,
actionTargetUserIds,
actionTargetChatId,
actionTargetMessage,
lastMessageSender,
typingStatus,
canDelete: selectCanDeleteTopic(global, chatId, topic.id),
animationLevel: global.settings.byKey.animationLevel,
draft,
...(isOutgoing && lastMessage && {
lastMessageOutgoingStatus: selectOutgoingStatus(global, lastMessage),
}),
canScrollDown: isSelected && chat?.id === currentChatId && currentThreadId === topic.id,
wasTopicOpened,
};
},
)(Topic));

View File

@ -6,13 +6,13 @@ export enum ChatAnimationTypes {
None,
}
export function useChatAnimationType(orderDiffById: Record<string, number>) {
export function useChatAnimationType<T extends number | string>(orderDiffById: Record<T, number>) {
return useMemo(() => {
const orderDiffs = Object.values(orderDiffById);
const orderDiffs = Object.values(orderDiffById) as T[];
const numberOfUp = orderDiffs.filter((diff) => diff < 0).length;
const numberOfDown = orderDiffs.filter((diff) => diff > 0).length;
return (chatId: string): ChatAnimationTypes => {
return (chatId: T): ChatAnimationTypes => {
const orderDiff = orderDiffById[chatId];
if (orderDiff === 0) {
return ChatAnimationTypes.None;

View File

@ -0,0 +1,224 @@
import React, { useLayoutEffect, useMemo, useRef } from '../../../../lib/teact/teact';
import { getGlobal } from '../../../../global';
import type { AnimationLevel } from '../../../../types';
import type { LangFn } from '../../../../hooks/useLang';
import type {
ApiChat, ApiTopic, ApiMessage, ApiTypingStatus, ApiUser,
} from '../../../../api/types';
import type { ObserveFn } from '../../../../hooks/useIntersectionObserver';
import type { Thread } from '../../../../global/types';
import { ANIMATION_END_DELAY } from '../../../../config';
import { renderTextWithEntities } from '../../../common/helpers/renderTextWithEntities';
import {
getMessageMediaHash,
getMessageMediaThumbDataUri, getMessageRoundVideo,
getMessageSenderName, getMessageSticker, getMessageVideo, isActionMessage, isChatChannel,
} from '../../../../global/helpers';
import { renderActionMessageText } from '../../../common/helpers/renderActionMessageText';
import renderText from '../../../common/helpers/renderText';
import buildClassName from '../../../../util/buildClassName';
import useLang from '../../../../hooks/useLang';
import useEnsureMessage from '../../../../hooks/useEnsureMessage';
import useMedia from '../../../../hooks/useMedia';
import { ChatAnimationTypes } from './useChatAnimationType';
import { fastRaf } from '../../../../util/schedulers';
import MessageSummary from '../../../common/MessageSummary';
import ChatForumLastMessage from '../../../common/ChatForumLastMessage';
import TypingStatus from '../../../common/TypingStatus';
const ANIMATION_DURATION = 200;
export default function useChatListEntry({
chat,
lastMessage,
chatId,
typingStatus,
draft,
actionTargetMessage,
actionTargetUserIds,
lastMessageTopic,
lastMessageSender,
actionTargetChatId,
observeIntersection,
animationType,
orderDiff,
animationLevel,
isTopic,
}: {
chat?: ApiChat;
lastMessage?: ApiMessage;
chatId: string;
typingStatus?: ApiTypingStatus;
draft?: Thread['draft'];
actionTargetMessage?: ApiMessage;
actionTargetUserIds?: string[];
lastMessageTopic?: ApiTopic;
lastMessageSender?: ApiUser | ApiChat;
actionTargetChatId?: string;
observeIntersection?: ObserveFn;
isTopic?: boolean;
animationType: ChatAnimationTypes;
orderDiff: number;
animationLevel?: AnimationLevel;
}) {
const lang = useLang();
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null);
const isAction = lastMessage && isActionMessage(lastMessage);
useEnsureMessage(chatId, isAction ? lastMessage.replyToMessageId : undefined, actionTargetMessage);
const mediaThumbnail = lastMessage && !getMessageSticker(lastMessage)
? getMessageMediaThumbDataUri(lastMessage)
: undefined;
const mediaBlobUrl = useMedia(lastMessage ? getMessageMediaHash(lastMessage, 'micro') : undefined);
const isRoundVideo = Boolean(lastMessage && getMessageRoundVideo(lastMessage));
const actionTargetUsers = useMemo(() => {
if (!actionTargetUserIds) {
return undefined;
}
// No need for expensive global updates on users, so we avoid them
const usersById = getGlobal().users.byId;
return actionTargetUserIds.map((userId) => usersById[userId]).filter(Boolean);
}, [actionTargetUserIds]);
function renderSubtitle() {
if (chat?.isForum && !isTopic) {
return (
<ChatForumLastMessage
chat={chat}
renderLastMessage={renderLastMessageOrTyping}
observeIntersection={observeIntersection}
/>
);
}
return renderLastMessageOrTyping();
}
function renderLastMessageOrTyping() {
if (typingStatus && lastMessage && typingStatus.timestamp > lastMessage.date * 1000) {
return <TypingStatus typingStatus={typingStatus} />;
}
if (draft?.text.length) {
return (
<p className="last-message" dir={lang.isRtl ? 'auto' : 'ltr'}>
<span className="draft">{lang('Draft')}</span>
{renderTextWithEntities(draft.text, draft.entities, undefined, undefined, undefined, undefined, true)}
</p>
);
}
if (!lastMessage) {
return undefined;
}
if (isAction) {
const isChat = chat && (isChatChannel(chat) || lastMessage.senderId === lastMessage.chatId);
return (
<p className="last-message shared-canvas-container" dir={lang.isRtl ? 'auto' : 'ltr'}>
{renderActionMessageText(
lang,
lastMessage,
!isChat ? lastMessageSender as ApiUser : undefined,
isChat ? chat : undefined,
actionTargetUsers,
actionTargetMessage,
actionTargetChatId,
lastMessageTopic,
{ isEmbedded: true },
)}
</p>
);
}
const senderName = getMessageSenderName(lang, chatId, lastMessageSender);
return (
<p className="last-message shared-canvas-container" dir={lang.isRtl ? 'auto' : 'ltr'}>
{senderName && (
<>
<span className="sender-name">{renderText(senderName)}</span>
<span className="colon">:</span>
</>
)}
{renderSummary(lang, lastMessage, observeIntersection, mediaBlobUrl || mediaThumbnail, isRoundVideo)}
</p>
);
}
// Sets animation excess values when `orderDiff` changes and then resets excess values to animate
useLayoutEffect(() => {
const element = ref.current;
if (animationLevel === 0 || !element) {
return;
}
// TODO Refactor animation: create `useListAnimation` that owns `orderDiff` and `animationType`
if (animationType === ChatAnimationTypes.Opacity) {
element.style.opacity = '0';
fastRaf(() => {
element.classList.add('animate-opacity');
element.style.opacity = '1';
});
} else if (animationType === ChatAnimationTypes.Move) {
element.style.transform = `translate3d(0, ${-orderDiff * 100}%, 0)`;
fastRaf(() => {
element.classList.add('animate-transform');
element.style.transform = '';
});
} else {
return;
}
setTimeout(() => {
fastRaf(() => {
element.classList.remove('animate-opacity', 'animate-transform');
element.style.opacity = '';
element.style.transform = '';
});
}, ANIMATION_DURATION + ANIMATION_END_DELAY);
}, [animationLevel, orderDiff, animationType]);
return {
renderSubtitle,
ref,
};
}
function renderSummary(
lang: LangFn, message: ApiMessage, observeIntersection?: ObserveFn, blobUrl?: string, isRoundVideo?: boolean,
) {
const messageSummary = (
<MessageSummary
lang={lang}
message={message}
noEmoji={Boolean(blobUrl)}
observeIntersectionForLoading={observeIntersection}
/>
);
if (!blobUrl) {
return messageSummary;
}
return (
<span className="media-preview">
<img src={blobUrl} alt="" className={buildClassName('media-preview--image', isRoundVideo && 'round')} />
{getMessageVideo(message) && <i className="icon-play" />}
{messageSummary}
</span>
);
}

View File

@ -0,0 +1,36 @@
import { useMemo } from '../../../../lib/teact/teact';
import usePrevious from '../../../../hooks/usePrevious';
import { mapValues } from '../../../../util/iteratees';
import { useChatAnimationType } from './useChatAnimationType';
export default function useChatOrderDiff(orderedIds: (string | number)[] | undefined) {
const orderById = useMemo(() => {
if (!orderedIds) {
return undefined;
}
return orderedIds.reduce((acc, id, i) => {
acc[id] = i;
return acc;
}, {} as Record<string, number>);
}, [orderedIds]);
const prevOrderById = usePrevious(orderById);
const orderDiffById = useMemo(() => {
if (!orderById || !prevOrderById) {
return {};
}
return mapValues(orderById, (order, id) => {
return prevOrderById[id] !== undefined ? order - prevOrderById[id] : -Infinity;
});
}, [orderById, prevOrderById]);
const getAnimationType = useChatAnimationType(orderDiffById);
return {
orderDiffById,
getAnimationType,
};
}

View File

@ -0,0 +1,20 @@
import { useRef } from '../../../../lib/teact/teact';
import usePrevious from '../../../../hooks/usePrevious';
import useForceUpdate from '../../../../hooks/useForceUpdate';
export default function useLeftHeaderButtonRtlForumTransition(shouldHideSearch?: boolean) {
const forceUpdate = useForceUpdate();
const shouldDisableDropdownMenuTransitionRef = useRef(shouldHideSearch);
const prevShouldHideSearch = usePrevious(shouldHideSearch);
function handleDropdownMenuTransitionEnd() {
shouldDisableDropdownMenuTransitionRef.current = Boolean(shouldHideSearch);
forceUpdate();
}
if (shouldHideSearch === false && prevShouldHideSearch !== shouldHideSearch) {
shouldDisableDropdownMenuTransitionRef.current = false;
}
return { shouldDisableDropdownMenuTransitionRef, handleDropdownMenuTransitionEnd };
}

View File

@ -0,0 +1,98 @@
import { getActions } from '../../../../global';
import type { ApiChat, ApiTopic } from '../../../../api/types';
import { compact } from '../../../../util/iteratees';
import { getHasAdminRight } from '../../../../global/helpers';
import useLang from '../../../../hooks/useLang';
import { useMemo } from '../../../../lib/teact/teact';
export default function useTopicContextActions(
topic: ApiTopic,
chat: ApiChat,
wasOpened?: boolean,
canDelete?: boolean,
handleDelete?: NoneToVoidFunction,
) {
const lang = useLang();
return useMemo(() => {
const {
isPinned, isMuted, isClosed, isOwner, id: topicId,
} = topic;
const chatId = chat.id;
const {
editTopic,
toggleTopicPinned,
markTopicRead,
updateTopicMutedState,
} = getActions();
const canToggleClosed = isOwner || chat.isCreator || getHasAdminRight(chat, 'manageTopics');
const canTogglePinned = chat.isCreator || getHasAdminRight(chat, 'manageTopics');
const actionUnreadMark = topic.unreadCount || !wasOpened
? {
title: lang('MarkAsRead'),
icon: 'readchats',
handler: () => {
markTopicRead({ chatId, topicId });
},
}
: undefined;
const actionPin = canTogglePinned ? (isPinned
? {
title: lang('UnpinFromTop'),
icon: 'unpin',
handler: () => toggleTopicPinned({ chatId, topicId, isPinned: false }),
}
: {
title: lang('PinToTop'),
icon: 'pin',
handler: () => toggleTopicPinned({ chatId, topicId, isPinned: true }),
}) : undefined;
const actionMute = ((chat.isMuted && isMuted !== false) || isMuted === true)
? {
title: lang('ChatList.Unmute'),
icon: 'unmute',
handler: () => updateTopicMutedState({ chatId, topicId, isMuted: false }),
}
: {
title: lang('ChatList.Mute'),
icon: 'mute',
handler: () => updateTopicMutedState({ chatId, topicId, isMuted: true }),
};
const actionCloseTopic = canToggleClosed ? (isClosed
? {
title: lang('lng_forum_topic_reopen'),
icon: 'reopen-topic',
handler: () => editTopic({ chatId, topicId, isClosed: false }),
}
: {
title: lang('lng_forum_topic_close'),
icon: 'close-topic',
handler: () => editTopic({ chatId, topicId, isClosed: true }),
}) : undefined;
const actionDelete = canDelete ? {
title: lang('lng_forum_topic_delete'),
icon: 'delete',
destructive: true,
handler: handleDelete,
} : undefined;
return compact([
actionPin,
actionUnreadMark,
actionMute,
actionCloseTopic,
actionDelete,
]);
}, [topic, chat, wasOpened, lang, canDelete, handleDelete]);
}

View File

@ -7,13 +7,15 @@ import { LoadMoreDirection } from '../../../types';
import { MEMO_EMPTY_ARRAY } from '../../../util/memo';
import { throttle } from '../../../util/schedulers';
import useLang from '../../../hooks/useLang';
import { renderMessageSummary } from '../../common/helpers/renderMessageText';
import { IS_SINGLE_COLUMN_LAYOUT } from '../../../util/environment';
import useLang from '../../../hooks/useLang';
import InfiniteScroll from '../../ui/InfiniteScroll';
import NothingFound from '../../common/NothingFound';
import ChatMessage from './ChatMessage';
import DateSuggest from './DateSuggest';
import LeftSearchResultTopic from './LeftSearchResultTopic';
export type OwnProps = {
searchQuery?: string;
@ -28,6 +30,8 @@ type StateProps = {
globalMessagesByChatId?: Record<string, { byId: Record<number, ApiMessage> }>;
chatsById: Record<string, ApiChat>;
fetchingStatus?: { chats?: boolean; messages?: boolean };
foundTopicIds?: number[];
searchChatId?: string;
lastSyncTime?: number;
};
@ -42,9 +46,12 @@ const ChatMessageResults: FC<OwnProps & StateProps> = ({
chatsById,
fetchingStatus,
lastSyncTime,
foundTopicIds,
searchChatId,
onSearchDateSelect,
onReset,
}) => {
const { searchMessagesGlobal } = getActions();
const { searchMessagesGlobal, openChat } = getActions();
const lang = useLang();
const handleLoadMore = useCallback(({ direction }: { direction: LoadMoreDirection }) => {
@ -59,6 +66,17 @@ const ChatMessageResults: FC<OwnProps & StateProps> = ({
}
}, [currentUserId, lastSyncTime, searchMessagesGlobal, searchQuery]);
const handleTopicClick = useCallback(
(id: number) => {
openChat({ id: searchChatId, threadId: id, shouldReplaceHistory: true });
if (!IS_SINGLE_COLUMN_LAYOUT) {
onReset();
}
},
[openChat, searchChatId, onReset],
);
const foundMessages = useMemo(() => {
if (!foundIds || foundIds.length === 0) {
return MEMO_EMPTY_ARRAY;
@ -91,7 +109,8 @@ const ChatMessageResults: FC<OwnProps & StateProps> = ({
);
}
const nothingFound = fetchingStatus && !fetchingStatus.chats && !fetchingStatus.messages && !foundMessages.length;
const nothingFound = fetchingStatus && !fetchingStatus.chats && !fetchingStatus.messages && !foundMessages.length
&& !foundTopicIds?.length;
return (
<div className="LeftSearch">
@ -115,7 +134,30 @@ const ChatMessageResults: FC<OwnProps & StateProps> = ({
description={lang('ChatList.Search.NoResultsDescription')}
/>
)}
{foundMessages.map(renderFoundMessage)}
{Boolean(foundTopicIds?.length) && (
<div className="pb-2">
<h3 className="section-heading topic-search-heading" dir={lang.isRtl ? 'auto' : undefined}>
{lang('Topics')}
</h3>
{foundTopicIds!.map((id) => {
return (
<LeftSearchResultTopic
chatId={searchChatId!}
topicId={id}
onClick={handleTopicClick}
/>
);
})}
</div>
)}
{Boolean(foundMessages.length) && (
<div className="pb-2">
<h3 className="section-heading topic-search-heading" dir={lang.isRtl ? 'auto' : undefined}>
{lang('SearchMessages')}
</h3>
{foundMessages.map(renderFoundMessage)}
</div>
)}
</InfiniteScroll>
</div>
);
@ -125,7 +167,9 @@ export default memo(withGlobal<OwnProps>(
(global): StateProps => {
const { byId: chatsById } = global.chats;
const { currentUserId, messages: { byChatId: globalMessagesByChatId }, lastSyncTime } = global;
const { fetchingStatus, resultsByType } = global.globalSearch;
const {
fetchingStatus, resultsByType, foundTopicIds, chatId: searchChatId,
} = global.globalSearch;
const { foundIds } = (resultsByType?.text) || {};
@ -135,7 +179,9 @@ export default memo(withGlobal<OwnProps>(
globalMessagesByChatId,
chatsById,
fetchingStatus,
foundTopicIds,
lastSyncTime,
searchChatId,
};
},
)(ChatMessageResults));

View File

@ -54,12 +54,18 @@
right: 0.625rem;
}
}
&.topic-search-heading {
margin-left: -1.0625rem !important;
padding-left: 2.125rem;
}
}
.LeftSearch .search-section .section-heading,
.RecentContacts .search-section .section-heading {
margin-left: -1rem !important;
width: calc(100% + 2rem);
margin-left: -0.5rem !important;
padding-left: 1.5rem;
width: calc(100% + 0.625rem);
box-shadow: 0 -1px 0 0 var(--color-borders);
&::before {
@ -135,6 +141,28 @@
}
}
}
.topic-item {
display: flex;
align-items: center;
font-size: 1rem;
line-height: 1.6875rem;
font-weight: 500;
overflow: hidden;
.topic-icon {
--custom-emoji-size: 2rem;
margin-inline-end: 0.25rem !important;
width: 2rem;
height: 2rem;
font-size: 2rem !important;
}
.fullName {
overflow: hidden;
text-overflow: ellipsis;
}
}
}
.ListItem.search-result-message {
@ -150,12 +178,12 @@
@media (max-width: 600px) {
.ListItem {
margin: 0 -0.625rem;
margin: 0 -0.125rem 0 -0.5rem;
}
}
.search-section {
padding: 0 1rem 0.5rem;
padding: 0 0.125rem 0.5rem 0.5rem;
.section-heading {
color: var(--color-text-secondary);

View File

@ -0,0 +1,71 @@
import type { FC } from '../../../lib/teact/teact';
import React, { memo, useCallback } from '../../../lib/teact/teact';
import { withGlobal } from '../../../global';
import type { ApiTopic } from '../../../api/types';
import {
selectChat,
} from '../../../global/selectors';
import { REM } from '../../common/helpers/mediaDimensions';
import renderText from '../../common/helpers/renderText';
import useSelectWithEnter from '../../../hooks/useSelectWithEnter';
import ListItem from '../../ui/ListItem';
import TopicIcon from '../../common/TopicIcon';
type OwnProps = {
chatId: string;
topicId: number;
onClick: (id: number) => void;
};
type StateProps = {
topic?: ApiTopic;
};
const TOPIC_ICON_SIZE = 2 * REM;
const LeftSearchResultTopic: FC<OwnProps & StateProps> = ({
topicId,
topic,
onClick,
}) => {
const handleClick = useCallback(() => {
onClick(topicId);
}, [topicId, onClick]);
const buttonRef = useSelectWithEnter(handleClick);
if (!topic) {
return undefined;
}
return (
<ListItem
className="chat-item-clickable search-result"
onClick={handleClick}
buttonClassName="topic-item"
buttonRef={buttonRef}
>
<TopicIcon
size={TOPIC_ICON_SIZE}
topic={topic}
className="topic-icon"
letterClassName="topic-icon-letter"
/>
<div dir="auto" className="fullName">{renderText(topic.title)}</div>
</ListItem>
);
};
export default memo(withGlobal<OwnProps>(
(global, { chatId, topicId }): StateProps => {
const chat = selectChat(global, chatId);
const topic = chat?.topics?.[topicId];
return {
topic,
};
},
)(LeftSearchResultTopic));

View File

@ -38,7 +38,11 @@
overflow: hidden;
&:first-child {
margin-left: 0.125rem;
margin-left: 0.5rem;
@media (max-width: 600px) {
margin-left: 0.125rem;
}
}
&:last-child {

View File

@ -33,8 +33,13 @@ const DraftRecipientPicker: FC<OwnProps> = ({
}
}, [isOpen, markIsShown]);
const handleSelectRecipient = useCallback((recipientId: string) => {
openChatWithDraft({ chatId: recipientId, text: requestedDraft!.text, files: requestedDraft!.files });
const handleSelectRecipient = useCallback((recipientId: string, threadId?: number) => {
openChatWithDraft({
chatId: recipientId,
threadId,
text: requestedDraft!.text,
files: requestedDraft!.files,
});
}, [openChatWithDraft, requestedDraft]);
const handleClose = useCallback(() => {

View File

@ -24,7 +24,7 @@ const ForwardRecipientPicker: FC<OwnProps & StateProps> = ({
isManyMessages,
}) => {
const {
setForwardChatId,
setForwardChatOrTopic,
exitForwardMode,
forwardToSavedMessages,
showNotification,
@ -39,7 +39,7 @@ const ForwardRecipientPicker: FC<OwnProps & StateProps> = ({
}
}, [isOpen, markIsShown]);
const handleSelectRecipient = useCallback((recipientId: string) => {
const handleSelectRecipient = useCallback((recipientId: string, threadId?: number) => {
if (recipientId === currentUserId) {
forwardToSavedMessages();
showNotification({
@ -48,9 +48,9 @@ const ForwardRecipientPicker: FC<OwnProps & StateProps> = ({
: 'Conversation.ForwardTooltip.SavedMessages.One'),
});
} else {
setForwardChatId({ id: recipientId });
setForwardChatOrTopic({ chatId: recipientId, topicId: threadId });
}
}, [currentUserId, forwardToSavedMessages, isManyMessages, lang, setForwardChatId, showNotification]);
}, [currentUserId, forwardToSavedMessages, isManyMessages, lang, setForwardChatOrTopic, showNotification]);
const handleClose = useCallback(() => {
exitForwardMode();

View File

@ -28,9 +28,14 @@
}
#LeftColumn {
min-width: 12rem;
width: 33vw;
max-width: 26.5rem;
--left-column-min-width: 16rem;
--left-column-max-width: 26.5rem;
min-width: var(--left-column-min-width);
max-width: var(--left-column-max-width);
height: 100%;
position: relative;
background-color: var(--color-background);
@ -45,12 +50,12 @@
}
@media (min-width: 926px) {
max-width: 40vw;
--left-column-max-width: 40vw;
}
@media (min-width: 1276px) {
width: 25vw;
max-width: 33vw;
--left-column-max-width: 33vw;
}
@media (max-width: 925px) {
@ -115,6 +120,7 @@
@media (max-width: 600px) {
max-width: none;
--left-column-max-width: calc(100vw - env(safe-area-inset-left));
transform: translate3d(-20vw, 0, 0);
left: env(safe-area-inset-left) !important;
width: calc(100vw - env(safe-area-inset-left)) !important;

View File

@ -108,7 +108,7 @@ const WebAppModal: FC<OwnProps & StateProps> = ({
const lang = useLang();
const {
url, buttonText, queryId,
url, buttonText, queryId, replyToMessageId, threadId,
} = webApp || {};
const isOpen = Boolean(url);
const isSimple = !queryId;
@ -204,6 +204,8 @@ const WebAppModal: FC<OwnProps & StateProps> = ({
botId: bot!.id,
queryId: queryId!,
peerId: chat!.id,
replyToMessageId,
threadId,
});
}, queryId ? PROLONG_INTERVAL : undefined, true);

View File

@ -22,7 +22,7 @@ import {
selectListedIds,
selectOutlyingIds,
selectScheduledMessage,
selectScheduledMessages,
selectChatScheduledMessages,
selectUser,
} from '../../global/selectors';
import { stopCurrentAudio } from '../../util/audioPlayer';
@ -463,7 +463,7 @@ export default memo(withGlobal(
let chatMessages: Record<number, ApiMessage> | undefined;
if (origin && [MediaViewerOrigin.ScheduledAlbum, MediaViewerOrigin.ScheduledInline].includes(origin)) {
chatMessages = selectScheduledMessages(global, chatId);
chatMessages = selectChatScheduledMessages(global, chatId);
} else {
chatMessages = selectChatMessages(global, chatId);
}

View File

@ -5,7 +5,7 @@ import React, {
import { getActions, withGlobal } from '../../global';
import type {
ApiUser, ApiMessage, ApiChat, ApiSticker,
ApiUser, ApiMessage, ApiChat, ApiSticker, ApiTopic,
} from '../../api/types';
import type { FocusDirection } from '../../types';
@ -14,6 +14,7 @@ import {
selectChatMessage,
selectIsMessageFocused,
selectChat,
selectTopicFromMessage,
} from '../../global/selectors';
import { getMessageHtmlId, isChatChannel } from '../../global/helpers';
import buildClassName from '../../util/buildClassName';
@ -39,6 +40,7 @@ type OwnProps = {
isEmbedded?: boolean;
appearanceOrder?: number;
isLastInList?: boolean;
isInsideTopic?: boolean;
memoFirstUnreadIdRef?: { current: number | undefined };
};
@ -50,6 +52,7 @@ type StateProps = {
targetMessage?: ApiMessage;
targetChatId?: string;
isFocused: boolean;
topic?: ApiTopic;
focusDirection?: FocusDirection;
noFocusHighlight?: boolean;
premiumGiftSticker?: ApiSticker;
@ -59,9 +62,6 @@ const APPEARANCE_DELAY = 10;
const ActionMessage: FC<OwnProps & StateProps> = ({
message,
observeIntersectionForReading,
observeIntersectionForLoading,
observeIntersectionForPlaying,
isEmbedded,
appearanceOrder = 0,
isLastInList,
@ -75,7 +75,12 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
focusDirection,
noFocusHighlight,
premiumGiftSticker,
isInsideTopic,
topic,
memoFirstUnreadIdRef,
observeIntersectionForReading,
observeIntersectionForLoading,
observeIntersectionForPlaying,
}) => {
const { openPremiumModal, requestConfetti } = getActions();
@ -130,6 +135,7 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
targetUsers,
targetMessage,
targetChatId,
topic,
{ isEmbedded },
observeIntersectionForLoading,
observeIntersectionForPlaying,
@ -155,6 +161,12 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
});
};
// TODO: Refactoring for action rendering
const shouldSkipRender = isInsideTopic && message.content.action?.text === 'TopicWasCreatedAction';
if (shouldSkipRender) {
return <span ref={ref} />;
}
if (isEmbedded) {
return <span ref={ref} className="embedded-action-message">{content}</span>;
}
@ -229,6 +241,7 @@ export default memo(withGlobal<OwnProps>(
const senderUser = !isChat && userId ? selectUser(global, userId) : undefined;
const senderChat = isChat ? chat : undefined;
const premiumGiftSticker = global.premiumGifts?.stickers?.[0];
const topic = selectTopicFromMessage(global, message);
return {
usersById,
@ -239,6 +252,7 @@ export default memo(withGlobal<OwnProps>(
targetMessage,
isFocused,
premiumGiftSticker,
topic,
...(isFocused && { focusDirection, noFocusHighlight }),
};
},

View File

@ -39,6 +39,8 @@ interface OwnProps {
threadId: number;
messageListType: MessageListType;
canExpandActions: boolean;
withForumActions?: boolean;
onTopicSearch?: NoneToVoidFunction;
}
interface StateProps {
@ -81,10 +83,12 @@ const HeaderActions: FC<OwnProps & StateProps> = ({
canCreateVoiceChat,
pendingJoinRequests,
isRightColumnShown,
withForumActions,
canExpandActions,
shouldJoinToSend,
shouldSendJoinRequest,
noAnimation,
onTopicSearch,
}) => {
const {
joinChannel,
@ -94,6 +98,7 @@ const HeaderActions: FC<OwnProps & StateProps> = ({
requestCall,
requestNextManagementScreen,
showNotification,
openChat,
} = getActions();
// eslint-disable-next-line no-null/no-null
const menuButtonRef = useRef<HTMLButtonElement>(null);
@ -137,6 +142,11 @@ const HeaderActions: FC<OwnProps & StateProps> = ({
}, [requestNextManagementScreen]);
const handleSearchClick = useCallback(() => {
if (withForumActions) {
onTopicSearch?.();
return;
}
openLocalTextSearch();
if (IS_SINGLE_COLUMN_LAYOUT) {
@ -151,7 +161,11 @@ const HeaderActions: FC<OwnProps & StateProps> = ({
} else {
setTimeout(setFocusInSearchInput, SEARCH_FOCUS_DELAY_MS);
}
}, [noAnimation, openLocalTextSearch]);
}, [noAnimation, onTopicSearch, openLocalTextSearch, withForumActions]);
const handleAsMessagesClick = useCallback(() => {
openChat({ id: chatId, threadId: MAIN_THREAD_ID });
}, [chatId, openChat]);
function handleRequestCall() {
requestCall({ userId: chatId });
@ -240,7 +254,7 @@ const HeaderActions: FC<OwnProps & StateProps> = ({
)}
</>
)}
{Boolean(pendingJoinRequests) && (
{!withForumActions && Boolean(pendingJoinRequests) && (
<Button
round
className="badge-button"
@ -285,8 +299,12 @@ const HeaderActions: FC<OwnProps & StateProps> = ({
canLeave={canLeave}
canEnterVoiceChat={canEnterVoiceChat}
canCreateVoiceChat={canCreateVoiceChat}
pendingJoinRequests={pendingJoinRequests}
onJoinRequestsClick={handleJoinRequestsClick}
withForumActions={withForumActions}
onSubscribeChannel={handleSubscribeClick}
onSearchClick={handleSearchClick}
onAsMessagesClick={handleAsMessagesClick}
onClose={handleHeaderMenuClose}
onCloseAnimationEnd={handleHeaderMenuHide}
/>
@ -315,17 +333,17 @@ export default memo(withGlobal<OwnProps>(
const canRestartBot = Boolean(bot && selectIsUserBlocked(global, bot.id));
const canStartBot = !canRestartBot && Boolean(selectIsChatBotNotStarted(global, chatId));
const canSubscribe = Boolean(
isMainThread && (isChannel || isChatSuperGroup(chat)) && chat.isNotJoined,
(isMainThread || chat.isForum) && (isChannel || isChatSuperGroup(chat)) && chat.isNotJoined,
);
const canSearch = isMainThread || isDiscussionThread;
const canCall = ARE_CALLS_SUPPORTED && isUserId(chat.id) && !isChatWithSelf && !bot;
const canMute = isMainThread && !isChatWithSelf && !canSubscribe;
const canLeave = isMainThread && !canSubscribe;
const canEnterVoiceChat = ARE_CALLS_SUPPORTED && chat.isCallActive;
const canCreateVoiceChat = ARE_CALLS_SUPPORTED && !chat.isCallActive
const canEnterVoiceChat = ARE_CALLS_SUPPORTED && isMainThread && chat.isCallActive;
const canCreateVoiceChat = ARE_CALLS_SUPPORTED && isMainThread && !chat.isCallActive
&& (chat.adminRights?.manageCall || (chat.isCreator && isChatBasicGroup(chat)));
const canViewStatistics = chat.fullInfo?.canViewStatistics;
const pendingJoinRequests = chat.fullInfo?.requestsPending;
const canViewStatistics = isMainThread && chat.fullInfo?.canViewStatistics;
const pendingJoinRequests = isMainThread ? chat.fullInfo?.requestsPending : undefined;
const shouldJoinToSend = Boolean(chat?.isNotJoined && chat.isJoinToSend);
const shouldSendJoinRequest = Boolean(chat?.isNotJoined && chat.isJoinRequest);
const noAnimation = global.settings.byKey.animationLevel === ANIMATION_LEVEL_MIN;

View File

@ -6,17 +6,25 @@ import { getActions, withGlobal } from '../../global';
import type { ApiBotCommand, ApiChat } from '../../api/types';
import type { IAnchorPosition } from '../../types';
import { MAIN_THREAD_ID } from '../../api/types';
import { REPLIES_USER_ID } from '../../config';
import { IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment';
import { disableScrolling, enableScrolling } from '../../util/scrollLock';
import {
selectChat, selectNotifySettings, selectNotifyExceptions, selectUser, selectChatBot, selectIsPremiumPurchaseBlocked,
selectChat,
selectNotifySettings,
selectNotifyExceptions,
selectUser,
selectChatBot,
selectIsPremiumPurchaseBlocked,
selectCurrentMessageList,
} from '../../global/selectors';
import {
isUserId, getCanDeleteChat, selectIsChatMuted, getCanAddContact, isChatChannel, isChatGroup,
} from '../../global/helpers';
import useShowTransition from '../../hooks/useShowTransition';
import usePrevDuringAnimation from '../../hooks/usePrevDuringAnimation';
import useLang from '../../hooks/useLang';
import Portal from '../ui/Portal';
@ -56,13 +64,17 @@ export type OwnProps = {
canCall?: boolean;
canMute?: boolean;
canViewStatistics?: boolean;
withForumActions?: boolean;
canLeave?: boolean;
canEnterVoiceChat?: boolean;
canCreateVoiceChat?: boolean;
pendingJoinRequests?: number;
onSubscribeChannel: () => void;
onSearchClick: () => void;
onAsMessagesClick: () => void;
onClose: () => void;
onCloseAnimationEnd: () => void;
onJoinRequestsClick?: () => void;
};
type StateProps = {
@ -70,20 +82,28 @@ type StateProps = {
botCommands?: ApiBotCommand[];
isPrivate?: boolean;
isMuted?: boolean;
isTopic?: boolean;
canAddContact?: boolean;
canReportChat?: boolean;
canDeleteChat?: boolean;
canGiftPremium?: boolean;
hasLinkedChat?: boolean;
isChatInfoShown?: boolean;
};
const CLOSE_MENU_ANIMATION_DURATION = 200;
const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
chatId,
threadId,
isOpen,
withExtraActions,
anchor,
isChannel,
botCommands,
withForumActions,
isTopic,
isChatInfoShown,
canStartBot,
canRestartBot,
canSubscribe,
@ -91,6 +111,7 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
canCall,
canMute,
canViewStatistics,
pendingJoinRequests,
canLeave,
canEnterVoiceChat,
canCreateVoiceChat,
@ -102,8 +123,10 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
canGiftPremium,
hasLinkedChat,
canAddContact,
onJoinRequestsClick,
onSubscribeChannel,
onSearchClick,
onAsMessagesClick,
onClose,
onCloseAnimationEnd,
}) => {
@ -119,13 +142,18 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
requestCall,
toggleStatistics,
openGiftPremiumModal,
openChatWithInfo,
} = getActions();
const [isMenuOpen, setIsMenuOpen] = useState(true);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isReportModalOpen, setIsReportModalOpen] = useState(false);
const { x, y } = anchor;
useShowTransition(isOpen, onCloseAnimationEnd, undefined, false);
const isViewGroupInfoShown = usePrevDuringAnimation(
(!isChatInfoShown && (withForumActions || isTopic)) ? true : undefined, CLOSE_MENU_ANIMATION_DURATION,
);
const handleReport = useCallback(() => {
setIsMenuOpen(false);
@ -147,6 +175,11 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
onClose();
}, [onClose]);
const handleViewGroupInfo = useCallback(() => {
openChatWithInfo({ id: chatId, threadId });
closeMenu();
}, [chatId, closeMenu, openChatWithInfo, threadId]);
const closeDeleteModal = useCallback(() => {
setIsDeleteModalOpen(false);
onClose();
@ -224,6 +257,11 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
closeMenu();
}, [closeMenu, enterMessageSelectMode]);
const handleOpenAsMessages = useCallback(() => {
onAsMessagesClick();
closeMenu();
}, [closeMenu, onAsMessagesClick]);
useEffect(() => {
disableScrolling();
@ -263,6 +301,31 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
style={`left: ${x}px;top: ${y}px;`}
onClose={closeMenu}
>
{isViewGroupInfoShown && (
<MenuItem
icon="info"
onClick={handleViewGroupInfo}
>
{isTopic ? lang('lng_context_view_topic') : lang('lng_context_view_group')}
</MenuItem>
)}
{withForumActions && Boolean(pendingJoinRequests) && (
<MenuItem
icon="user"
onClick={onJoinRequestsClick}
>
{isChannel ? lang('SubscribeRequests') : lang('MemberRequests')}
<div className="right-badge">{pendingJoinRequests}</div>
</MenuItem>
)}
{withForumActions && !isTopic && (
<MenuItem
icon="message"
onClick={handleOpenAsMessages}
>
{lang('lng_forum_view_as_messages')}
</MenuItem>
)}
{withExtraActions && canStartBot && (
<MenuItem
icon="bots"
@ -343,12 +406,14 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
{lang(isChannel ? 'ViewDiscussion' : 'lng_profile_view_channel')}
</MenuItem>
)}
<MenuItem
icon="select"
onClick={handleSelectMessages}
>
{lang('ReportSelectMessages')}
</MenuItem>
{!withForumActions && (
<MenuItem
icon="select"
onClick={handleSelectMessages}
>
{lang('ReportSelectMessages')}
</MenuItem>
)}
{canViewStatistics && (
<MenuItem
icon="stats"
@ -407,7 +472,7 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
};
export default memo(withGlobal<OwnProps>(
(global, { chatId }): StateProps => {
(global, { chatId, threadId }): StateProps => {
const chat = selectChat(global, chatId);
if (!chat || chat.isRestricted) {
return {};
@ -415,7 +480,9 @@ export default memo(withGlobal<OwnProps>(
const isPrivate = isUserId(chat.id);
const user = isPrivate ? selectUser(global, chatId) : undefined;
const canAddContact = user && getCanAddContact(user);
const canReportChat = isChatChannel(chat) || isChatGroup(chat) || (user && !user.isSelf);
const isMainThread = threadId === MAIN_THREAD_ID;
const canReportChat = isMainThread && (isChatChannel(chat) || isChatGroup(chat) || (user && !user.isSelf));
const { chatId: currentChatId, threadId: currentThreadId } = selectCurrentMessageList(global) || {};
const chatBot = chatId !== REPLIES_USER_ID ? selectChatBot(global, chatId) : undefined;
const canGiftPremium = Boolean(
@ -428,12 +495,14 @@ export default memo(withGlobal<OwnProps>(
chat,
isMuted: selectIsChatMuted(chat, selectNotifySettings(global), selectNotifyExceptions(global)),
isPrivate,
isTopic: chat?.isForum && !isMainThread,
canAddContact,
canReportChat,
canDeleteChat: getCanDeleteChat(chat),
canGiftPremium,
hasLinkedChat: Boolean(chat?.fullInfo?.linkedChatId),
botCommands: chatBot?.fullInfo?.botInfo?.commands,
isChatInfoShown: global.isChatInfoShown && currentChatId === chatId && currentThreadId === threadId,
};
},
)(HeaderMenuContainer));

View File

@ -8,6 +8,7 @@ import { getPictogramDimensions } from '../common/helpers/mediaDimensions';
import { getMessageMediaHash, getMessageSingleInlineButton } from '../../global/helpers';
import buildClassName from '../../util/buildClassName';
import { IS_TOUCH_ENV } from '../../util/environment';
import renderText from '../common/helpers/renderText';
import useMedia from '../../hooks/useMedia';
import useThumbnail from '../../hooks/useThumbnail';
@ -104,7 +105,7 @@ const HeaderPinnedMessage: FC<OwnProps> = ({
{mediaThumbnail && renderPictogram(mediaThumbnail, mediaBlobUrl)}
<div className="message-text">
<div className="title" dir="auto">
{customTitle || `${lang('PinnedMessage')} ${index > 0 ? `#${count - index}` : ''}`}
{customTitle ? renderText(customTitle) : `${lang('PinnedMessage')} ${index > 0 ? `#${count - index}` : ''}`}
</div>
<p dir="auto">
<MessageSummary lang={lang} message={message} noEmoji={Boolean(mediaThumbnail)} />

View File

@ -24,7 +24,7 @@ import {
selectScrollOffset,
selectThreadTopMessageId,
selectFirstMessageId,
selectScheduledMessages,
selectChatScheduledMessages,
selectCurrentMessageIds,
selectIsCurrentUserPremium,
} from '../../global/selectors';
@ -226,7 +226,8 @@ const MessageList: FC<OwnProps & StateProps> = ({
return undefined;
}
const viewportIds = threadTopMessageId && (!messageIds[0] || threadFirstMessageId === messageIds[0])
const viewportIds = threadTopMessageId && threadFirstMessageId !== threadTopMessageId
&& (!messageIds[0] || threadFirstMessageId === messageIds[0])
? [threadTopMessageId, ...messageIds]
: messageIds;
@ -625,12 +626,12 @@ export default memo(withGlobal<OwnProps>(
const messageIds = selectCurrentMessageIds(global, chatId, threadId, type);
const messagesById = type === 'scheduled'
? selectScheduledMessages(global, chatId)
? selectChatScheduledMessages(global, chatId)
: selectChatMessages(global, chatId);
const threadTopMessageId = selectThreadTopMessageId(global, chatId, threadId);
if (
threadId !== MAIN_THREAD_ID
threadId !== MAIN_THREAD_ID && !chat?.isForum
&& !(messagesById && threadTopMessageId && messagesById[threadTopMessageId])
) {
return {};

View File

@ -22,10 +22,12 @@ import Message from './message/Message';
import SponsoredMessage from './message/SponsoredMessage';
import ActionMessage from './ActionMessage';
import { getActions } from '../../global';
import { MAIN_THREAD_ID } from '../../api/types';
interface OwnProps {
isCurrentUserPremium?: boolean;
chatId: string;
threadId: number;
messageIds: number[];
messageGroups: MessageDateGroup[];
isViewportNewest: boolean;
@ -36,7 +38,6 @@ interface OwnProps {
anchorIdRef: { current: string | undefined };
memoUnreadDividerBeforeIdRef: { current: number | undefined };
memoFirstUnreadIdRef: { current: number | undefined };
threadId: number;
type: MessageListType;
isReady: boolean;
isScrollingRef: { current: boolean | undefined };
@ -54,6 +55,7 @@ const UNREAD_DIVIDER_CLASS = 'unread-divider';
const MessageListContent: FC<OwnProps> = ({
isCurrentUserPremium,
chatId,
threadId,
messageIds,
messageGroups,
isViewportNewest,
@ -64,7 +66,6 @@ const MessageListContent: FC<OwnProps> = ({
anchorIdRef,
memoUnreadDividerBeforeIdRef,
memoFirstUnreadIdRef,
threadId,
type,
isReady,
isScrollingRef,
@ -141,6 +142,7 @@ const MessageListContent: FC<OwnProps> = ({
<ActionMessage
key={message.id}
message={message}
isInsideTopic={Boolean(threadId && threadId !== MAIN_THREAD_ID)}
observeIntersectionForReading={observeIntersectionForReading}
observeIntersectionForLoading={observeIntersectionForLoading}
observeIntersectionForPlaying={observeIntersectionForPlaying}

View File

@ -2,7 +2,7 @@ import type { FC } from '../../lib/teact/teact';
import React, { memo } from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../lib/teact/teactn';
import { createMessageHash } from '../../util/routing';
import { createLocationHash } from '../../util/routing';
import useHistoryBack from '../../hooks/useHistoryBack';
import type { MessageList as GlobalMessageList } from '../../global/types';
@ -22,7 +22,7 @@ const MessageListHistoryHandler: FC<StateProps> = ({ messageLists }) => {
const MessageHistoryRecord: FC<GlobalMessageList> = ({ chatId, type, threadId }) => {
useHistoryBack({
isActive: true,
hash: createMessageHash(chatId, type, threadId),
hash: createLocationHash(chatId, type, threadId),
onBack: closeChat,
});
};

View File

@ -4,7 +4,7 @@ import React, {
} from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global';
import type { ApiChatBannedRights } from '../../api/types';
import type { ApiChat, ApiChatBannedRights } from '../../api/types';
import { MAIN_THREAD_ID } from '../../api/types';
import type {
MessageListType,
@ -42,10 +42,17 @@ import {
selectIsRightColumnShown,
selectIsUserBlocked,
selectPinnedIds,
selectReplyingToId,
selectTheme,
} from '../../global/selectors';
import {
getCanPostInChat, getMessageSendingRestrictionReason, isChatChannel, isChatGroup, isChatSuperGroup, isUserId,
getCanPostInChat,
getMessageSendingRestrictionReason,
getForumComposerPlaceholder,
isChatChannel,
isChatGroup,
isChatSuperGroup,
isUserId,
} from '../../global/helpers';
import captureEscKeyListener from '../../util/captureEscKeyListener';
import buildClassName from '../../util/buildClassName';
@ -80,6 +87,8 @@ type StateProps = {
chatId?: string;
threadId?: number;
messageListType?: MessageListType;
chat?: ApiChat;
replyingToId?: number;
isPrivate?: boolean;
isPinnedMessageList?: boolean;
isScheduledMessageList?: boolean;
@ -125,6 +134,8 @@ const MiddleColumn: FC<StateProps> = ({
chatId,
threadId,
messageListType,
chat,
replyingToId,
isPrivate,
isPinnedMessageList,
canPost,
@ -302,10 +313,10 @@ const MiddleColumn: FC<StateProps> = ({
}, []);
const handleUnpinAllMessages = useCallback(() => {
unpinAllMessages({ chatId });
unpinAllMessages({ chatId, threadId });
closeUnpinModal();
openPreviousChat();
}, [unpinAllMessages, chatId, closeUnpinModal, openPreviousChat]);
}, [unpinAllMessages, chatId, threadId, closeUnpinModal, openPreviousChat]);
const handleTabletFocus = useCallback(() => {
openChat({ id: chatId });
@ -352,6 +363,9 @@ const MiddleColumn: FC<StateProps> = ({
const messageSendingRestrictionReason = getMessageSendingRestrictionReason(
lang, currentUserBannedRights, defaultBannedRights,
);
const forumComposerPlaceholder = getForumComposerPlaceholder(lang, chat, threadId, Boolean(replyingToId));
const composerRestrictionMessage = messageSendingRestrictionReason || forumComposerPlaceholder;
// CSS Variables calculation doesn't work properly with transforms, so we calculate transform values in JS
const {
@ -381,10 +395,11 @@ const MiddleColumn: FC<StateProps> = ({
const isMessagingDisabled = Boolean(
!isPinnedMessageList && !renderingCanPost && !renderingCanRestartBot && !renderingCanStartBot
&& !renderingCanSubscribe && messageSendingRestrictionReason,
&& !renderingCanSubscribe && composerRestrictionMessage,
);
const withMessageListBottomShift = Boolean(
renderingCanRestartBot || renderingCanSubscribe || renderingCanStartBot || isPinnedMessageList,
renderingCanRestartBot || renderingCanSubscribe || renderingShouldSendJoinRequest || renderingCanStartBot
|| isPinnedMessageList,
);
const withExtraShift = Boolean(isMessagingDisabled || isSelectModeActive || isPinnedMessageList);
@ -469,7 +484,7 @@ const MiddleColumn: FC<StateProps> = ({
<div className={messagingDisabledClassName}>
<div className="messaging-disabled-inner">
<span>
{messageSendingRestrictionReason}
{composerRestrictionMessage}
</span>
</div>
</div>
@ -609,7 +624,7 @@ export default memo(withGlobal(
const isPrivate = isUserId(chatId);
const chat = selectChat(global, chatId);
const bot = selectChatBot(global, chatId);
const pinnedIds = selectPinnedIds(global, chatId);
const pinnedIds = selectPinnedIds(global, chatId, threadId);
const { chatId: audioChatId, messageId: audioMessageId } = global.audioPlayer;
const canPost = chat && getCanPostInChat(chat, threadId);
@ -626,24 +641,30 @@ export default memo(withGlobal(
const canRestartBot = Boolean(bot && selectIsUserBlocked(global, bot.id));
const canStartBot = !canRestartBot && isBotNotStarted;
const shouldLoadFullChat = Boolean(chat && isChatGroup(chat) && !chat.fullInfo && lastSyncTime);
const replyingToId = selectReplyingToId(global, chatId, threadId);
const shouldBlockBeforeReply = chat?.isForum ? (threadId === MAIN_THREAD_ID && !replyingToId) : false;
return {
...state,
chatId,
threadId,
messageListType,
chat,
replyingToId,
isPrivate,
areChatSettingsLoaded: Boolean(chat?.settings),
canPost: !isPinnedMessageList
&& (!chat || canPost)
&& !(isScheduledMessageList && chat?.isForum && threadId === MAIN_THREAD_ID)
&& !isBotNotStarted
&& !(shouldJoinToSend && chat?.isNotJoined),
&& !(shouldJoinToSend && chat?.isNotJoined)
&& !shouldBlockBeforeReply,
isPinnedMessageList,
isScheduledMessageList,
currentUserBannedRights: chat?.currentUserBannedRights,
defaultBannedRights: chat?.defaultBannedRights,
hasPinnedOrAudioPlayer: (
threadId !== MAIN_THREAD_ID
(threadId !== MAIN_THREAD_ID && !chat?.isForum)
|| Boolean(!isPinnedMessageList && pinnedIds?.length)
|| Boolean(audioChatId && audioMessageId)
),

View File

@ -284,6 +284,8 @@
}
.title {
--custom-emoji-size: 1.375rem;
display: flex;
align-items: center;
@ -291,10 +293,6 @@
width: 1.25rem;
height: 1.25rem;
}
.custom-emoji {
--custom-emoji-size: 1.375rem;
}
}
.status,
@ -322,11 +320,33 @@
}
}
.Avatar {
margin-right: 0.625rem;
.Avatar, .topic-header-icon {
// TODO For some reason webpack imports `Audio.scss` second time when loading calls bundle
width: 2.5rem !important;
height: 2.5rem !important;
margin-right: 0.625rem;
}
.topic-header-icon {
--custom-emoji-size: 2.5rem;
font-size: 2.5rem;
.emoji-small {
width: 1.25rem;
height: 1.25rem;
}
.topic-icon-letter {
font-size: 1.25rem;
}
&.general-forum-icon {
color: var(--color-text-secondary);
}
}
.Avatar {
font-size: 1.0625rem;
}

View File

@ -39,6 +39,7 @@ import {
selectPinnedIds,
selectScheduledIds,
selectThreadInfo,
selectThreadParam,
selectThreadTopMessageId,
} from '../../global/selectors';
import useEnsureMessage from '../../hooks/useEnsureMessage';
@ -141,12 +142,13 @@ const MiddleHeader: FC<OwnProps & StateProps> = ({
const chatTitleLength = chat && getChatTitle(lang, chat).length;
const topMessageTitle = topMessageSender ? getSenderTitle(lang, topMessageSender) : undefined;
const { settings } = chat || {};
const isForum = chat?.isForum;
useEffect(() => {
if (threadId === MAIN_THREAD_ID && lastSyncTime && isReady) {
loadPinnedMessages({ chatId });
if (lastSyncTime && isReady && (threadId === MAIN_THREAD_ID || isForum)) {
loadPinnedMessages({ chatId, threadId });
}
}, [chatId, loadPinnedMessages, lastSyncTime, threadId, isReady]);
}, [chatId, loadPinnedMessages, lastSyncTime, threadId, isReady, isForum]);
// Reset pinned index when switching chats and pinning/unpinning
useEffect(() => {
@ -165,8 +167,8 @@ const MiddleHeader: FC<OwnProps & StateProps> = ({
const shouldAnimateTools = useRef<boolean>(true);
const handleHeaderClick = useCallback(() => {
openChatWithInfo({ id: chatId });
}, [openChatWithInfo, chatId]);
openChatWithInfo({ id: chatId, threadId });
}, [openChatWithInfo, chatId, threadId]);
const handleUnpinMessage = useCallback((messageId: number) => {
pinMessage({ chatId, messageId, isUnpin: true });
@ -182,8 +184,8 @@ const MiddleHeader: FC<OwnProps & StateProps> = ({
}, [pinnedMessage, focusMessage, threadId, pinnedMessagesCount, pinnedMessageIndex]);
const handleAllPinnedClick = useCallback(() => {
openChat({ id: chatId, threadId: MAIN_THREAD_ID, type: 'pinned' });
}, [openChat, chatId]);
openChat({ id: chatId, threadId, type: 'pinned' });
}, [openChat, chatId, threadId]);
const setBackButtonActive = useCallback(() => {
setTimeout(() => {
@ -207,7 +209,7 @@ const MiddleHeader: FC<OwnProps & StateProps> = ({
return;
}
if (threadId === MAIN_THREAD_ID && messageListType === 'thread' && currentTransitionKey === 0) {
if (messageListType === 'thread' && currentTransitionKey === 0) {
if (IS_SINGLE_COLUMN_LAYOUT || shouldShowCloseButton) {
e.stopPropagation(); // Stop propagation to prevent chat re-opening on tablets
openChat({ id: undefined }, { forceOnHeavyAnimation: true });
@ -223,7 +225,7 @@ const MiddleHeader: FC<OwnProps & StateProps> = ({
openPreviousChat();
setBackButtonActive();
}, [
threadId, messageListType, currentTransitionKey, isSelectModeActive, openPreviousChat, shouldShowCloseButton,
messageListType, currentTransitionKey, isSelectModeActive, openPreviousChat, shouldShowCloseButton,
openChat, toggleLeftColumn, exitMessageSelectMode, setBackButtonActive,
]);
@ -304,35 +306,29 @@ const MiddleHeader: FC<OwnProps & StateProps> = ({
const { connectionStatusText } = useConnectionStatus(lang, connectionState, isSyncing, true);
function renderInfo() {
if (messageListType === 'thread') {
if (threadId === MAIN_THREAD_ID || chat?.isForum) {
return renderChatInfo();
}
}
return (
messageListType === 'thread' && threadId === MAIN_THREAD_ID ? (
renderMainThreadInfo()
) : messageListType === 'thread' ? (
<>
{renderBackButton()}
<h3>
{lang('CommentsCount', messagesCount, 'i')}
</h3>
</>
) : messageListType === 'pinned' ? (
<>
{renderBackButton()}
<h3>
{lang('PinnedMessagesCount', messagesCount, 'i')}
</h3>
</>
) : messageListType === 'scheduled' ? (
<>
{renderBackButton()}
<h3>
{isChatWithSelf ? lang('Reminders') : lang('messages', messagesCount, 'i')}
</h3>
</>
) : undefined
<>
{renderBackButton()}
<h3>
{messagesCount !== undefined ? (
messageListType === 'thread' ? (lang('CommentsCount', messagesCount, 'i'))
: messageListType === 'pinned' ? (lang('PinnedMessagesCount', messagesCount, 'i'))
: messageListType === 'scheduled' ? (
isChatWithSelf ? lang('Reminders') : lang('messages', messagesCount, 'i')
) : undefined
) : lang('Loading')}
</h3>
</>
);
}
function renderMainThreadInfo() {
function renderChatInfo() {
return (
<>
{(isLeftColumnHideable || currentTransitionKey > 0) && renderBackButton(shouldShowCloseButton, true)}
@ -355,11 +351,12 @@ const MiddleHeader: FC<OwnProps & StateProps> = ({
<GroupChatInfo
key={chatId}
chatId={chatId}
threadId={threadId}
typingStatus={typingStatus}
status={connectionStatusText}
withDots={Boolean(connectionStatusText)}
withMediaViewer
withFullInfo
withMediaViewer={threadId === MAIN_THREAD_ID}
withFullInfo={threadId === MAIN_THREAD_ID}
withUpdatingStatus
withVideoAvatar={isReady}
noRtl
@ -456,7 +453,6 @@ export default memo(withGlobal<OwnProps>(
(global, { chatId, threadId, messageListType }): StateProps => {
const { isLeftColumnShown, lastSyncTime, shouldSkipHistoryAnimations } = global;
const chat = selectChat(global, chatId);
const { typingStatus } = chat || {};
const { chatId: audioChatId, messageId: audioMessageId } = global.audioPlayer;
const audioMessage = audioChatId && audioMessageId
@ -465,10 +461,10 @@ export default memo(withGlobal<OwnProps>(
let messagesCount: number | undefined;
if (messageListType === 'pinned') {
const pinnedIds = selectPinnedIds(global, chatId);
const pinnedIds = selectPinnedIds(global, chatId, threadId);
messagesCount = pinnedIds?.length;
} else if (messageListType === 'scheduled') {
const scheduledIds = selectScheduledIds(global, chatId);
const scheduledIds = selectScheduledIds(global, chatId, threadId);
messagesCount = scheduledIds?.length;
} else if (messageListType === 'thread' && threadId !== MAIN_THREAD_ID) {
const threadInfo = selectThreadInfo(global, chatId, threadId);
@ -480,9 +476,10 @@ export default memo(withGlobal<OwnProps>(
const canRestartBot = Boolean(isChatWithBot && selectIsUserBlocked(global, chatId));
const canStartBot = isChatWithBot && !canRestartBot && Boolean(selectIsChatBotNotStarted(global, chatId));
const canSubscribe = Boolean(
isMainThread && chat && (isChatChannel(chat) || isChatSuperGroup(chat)) && chat.isNotJoined,
chat && (isMainThread || chat.isForum) && (isChatChannel(chat) || isChatSuperGroup(chat)) && chat.isNotJoined,
);
const shouldSendJoinRequest = Boolean(chat?.isNotJoined && chat.isJoinRequest);
const typingStatus = selectThreadParam(global, chatId, threadId, 'typingStatus');
const state: StateProps = {
typingStatus,
@ -508,7 +505,7 @@ export default memo(withGlobal<OwnProps>(
Object.assign(state, { messagesById });
if (threadId !== MAIN_THREAD_ID) {
if (threadId !== MAIN_THREAD_ID && !chat?.isForum) {
const pinnedMessageId = selectThreadTopMessageId(global, chatId, threadId);
const message = pinnedMessageId ? selectChatMessage(global, chatId, pinnedMessageId) : undefined;
const topMessageSender = message ? selectForwardedSender(global, message) : undefined;
@ -521,7 +518,7 @@ export default memo(withGlobal<OwnProps>(
};
}
const pinnedMessageIds = selectPinnedIds(global, chatId);
const pinnedMessageIds = selectPinnedIds(global, chatId, threadId);
if (pinnedMessageIds?.length) {
const firstPinnedMessage = messagesById[pinnedMessageIds[0]];
const {

View File

@ -19,6 +19,7 @@ type OwnProps = {
bot: ApiAttachBot;
theme: ISettings['theme'];
chatId: string;
threadId?: number;
onMenuOpened: VoidFunction;
onMenuClosed: VoidFunction;
};
@ -27,6 +28,7 @@ const AttachBotItem: FC<OwnProps> = ({
bot,
theme,
chatId,
threadId,
onMenuOpened,
onMenuClosed,
}) => {
@ -74,6 +76,7 @@ const AttachBotItem: FC<OwnProps> = ({
onClick={() => callAttachBot({
botId: bot.id,
chatId,
threadId,
})}
onContextMenu={handleContextMenu}
>

View File

@ -24,6 +24,7 @@ import './AttachMenu.scss';
export type OwnProps = {
chatId: string;
threadId?: number;
isButtonVisible: boolean;
canAttachMedia: boolean;
canAttachPolls: boolean;
@ -37,6 +38,7 @@ export type OwnProps = {
const AttachMenu: FC<OwnProps> = ({
chatId,
threadId,
isButtonVisible,
canAttachMedia,
canAttachPolls,
@ -149,6 +151,7 @@ const AttachMenu: FC<OwnProps> = ({
<AttachBotItem
bot={bot}
chatId={chatId}
threadId={threadId}
theme={theme}
onMenuOpened={markAttachmentBotMenuOpen}
onMenuClosed={unmarkAttachmentBotMenuOpen}

View File

@ -21,9 +21,6 @@ import type {
ApiBotMenuButton,
ApiAttachMenuPeerType,
} from '../../../api/types';
import {
MAIN_THREAD_ID,
} from '../../../api/types';
import type { InlineBotSettings, ISettings } from '../../../types';
import {
@ -322,7 +319,7 @@ const Composer: FC<OwnProps & StateProps> = ({
}, [chatId]);
useEffect(() => {
if (chatId && lastSyncTime && threadId === MAIN_THREAD_ID && isReady) {
if (chatId && lastSyncTime && isReady) {
loadScheduledHistory({ chatId });
}
}, [isReady, chatId, loadScheduledHistory, lastSyncTime, threadId]);
@ -567,7 +564,7 @@ const Composer: FC<OwnProps & StateProps> = ({
stopRecordingVoiceRef.current!();
resetComposer();
};
}, [chatId, resetComposer, stopRecordingVoiceRef]);
}, [chatId, threadId, resetComposer, stopRecordingVoiceRef]);
const showCustomEmojiPremiumNotification = useCallback(() => {
const notificationNumber = customEmojiNotificationNumber.current;
@ -741,11 +738,14 @@ const Composer: FC<OwnProps & StateProps> = ({
]);
const handleClickBotMenu = useCallback(() => {
if (botMenuButton?.type !== 'webApp') return;
if (botMenuButton?.type !== 'webApp') {
return;
}
callAttachBot({
botId: chatId, chatId, isFromBotMenu: true, url: botMenuButton.url,
botId: chatId, chatId, isFromBotMenu: true, url: botMenuButton.url, threadId,
});
}, [botMenuButton, callAttachBot, chatId]);
}, [botMenuButton, callAttachBot, chatId, threadId]);
const handleActivateBotCommandMenu = useCallback(() => {
closeSymbolMenu();
@ -1298,6 +1298,7 @@ const Composer: FC<OwnProps & StateProps> = ({
)}
<AttachMenu
chatId={chatId}
threadId={threadId}
isButtonVisible={!activeVoiceRecording && !editingMessage}
canAttachMedia={canAttachMedia}
canAttachPolls={canAttachPolls}
@ -1414,7 +1415,7 @@ export default memo(withGlobal<OwnProps>(
const isChatWithBot = Boolean(chatBot);
const isChatWithSelf = selectIsChatWithSelf(global, chatId);
const messageWithActualBotKeyboard = isChatWithBot && selectNewestMessageWithBotKeyboardButtons(global, chatId);
const scheduledIds = selectScheduledIds(global, chatId);
const scheduledIds = selectScheduledIds(global, chatId, threadId);
const { language, shouldSuggestStickers, shouldSuggestCustomEmoji } = global.settings.byKey;
const baseEmojiKeywords = global.emojiKeywords[BASE_EMOJI_KEYWORD_LANG];
const emojiKeywords = language !== BASE_EMOJI_KEYWORD_LANG ? global.emojiKeywords[language] : undefined;
@ -1453,8 +1454,7 @@ export default memo(withGlobal<OwnProps>(
isRightColumnShown: selectIsRightColumnShown(global),
isSelectModeActive: selectIsInSelectMode(global),
withScheduledButton: (
threadId === MAIN_THREAD_ID
&& messageListType === 'thread'
messageListType === 'thread'
&& Boolean(scheduledIds?.length)
),
shouldSchedule: messageListType === 'scheduled',

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