Introduce Forums (#2174)
@ -16,6 +16,7 @@ const config: PlaywrightTestConfig = {
|
||||
video: 'retain-on-failure',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
reporter: [['html', { outputFolder: 'playwright-report' }]],
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
},
|
||||
|
||||
@ -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) }),
|
||||
}));
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
},
|
||||
));
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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]>;
|
||||
|
||||
@ -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;
|
||||
|
||||
1
src/assets/icons/forumTopic/blue.svg
Normal 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 |
1
src/assets/icons/forumTopic/green.svg
Normal 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 |
1
src/assets/icons/forumTopic/grey.svg
Normal 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 |
1
src/assets/icons/forumTopic/red.svg
Normal 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 |
1
src/assets/icons/forumTopic/rose.svg
Normal 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 |
1
src/assets/icons/forumTopic/violet.svg
Normal 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 |
1
src/assets/icons/forumTopic/yellow.svg
Normal 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 |
@ -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}
|
||||
>
|
||||
|
||||
@ -6,6 +6,10 @@ $animation-time: 0.15s;
|
||||
.root {
|
||||
display: inline-flex;
|
||||
white-space: pre;
|
||||
|
||||
&[dir="rtl"] {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
}
|
||||
|
||||
.character-container {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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));
|
||||
|
||||
158
src/components/common/ChatForumLastMessage.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
147
src/components/common/ChatForumLastMessage.tsx
Normal 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);
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -74,6 +74,7 @@ const PickerSelectedItem: FC<OwnProps & StateProps> = ({
|
||||
const fullClassName = buildClassName(
|
||||
'PickerSelectedItem',
|
||||
className,
|
||||
chat?.isForum && 'forum-avatar',
|
||||
isMinimized && 'minimized',
|
||||
canClose && 'closeable',
|
||||
);
|
||||
|
||||
179
src/components/common/ProfileInfo.module.scss
Normal 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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -35,7 +35,7 @@
|
||||
&.replies-bot-account,
|
||||
&.deleted-account,
|
||||
&.saved-messages {
|
||||
font-size: 20rem;
|
||||
font-size: 10rem;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
|
||||
@ -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}
|
||||
|
||||
33
src/components/common/TopicChip.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
48
src/components/common/TopicChip.tsx
Normal 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);
|
||||
30
src/components/common/TopicDefaultIcon.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
43
src/components/common/TopicDefaultIcon.tsx
Normal 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);
|
||||
52
src/components/common/TopicIcon.tsx
Normal 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);
|
||||
@ -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%;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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%);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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;
|
||||
@ -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>
|
||||
|
||||
58
src/components/left/main/EmptyTopic.tsx
Normal 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));
|
||||
96
src/components/left/main/ForumPanel.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
256
src/components/left/main/ForumPanel.tsx
Normal 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));
|
||||
@ -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 */
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
34
src/components/left/main/Topic.module.scss
Normal 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;
|
||||
}
|
||||
236
src/components/left/main/Topic.tsx
Normal 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));
|
||||
@ -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;
|
||||
|
||||
224
src/components/left/main/hooks/useChatListEntry.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
src/components/left/main/hooks/useChatOrderDiff.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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 };
|
||||
}
|
||||
98
src/components/left/main/hooks/useTopicContextActions.ts
Normal 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]);
|
||||
}
|
||||
@ -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));
|
||||
|
||||
@ -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);
|
||||
|
||||
71
src/components/left/search/LeftSearchResultTopic.tsx
Normal 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));
|
||||
@ -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 {
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 }),
|
||||
};
|
||||
},
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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)} />
|
||||
|
||||
@ -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 {};
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@ -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)
|
||||
),
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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}
|
||||
>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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',
|
||||
|
||||