Management: Support adding and removing members, fix editing legacy groups (#1224)

This commit is contained in:
Alexander Zinchuk 2021-07-16 17:44:17 +03:00
parent ec442f13be
commit 08a27a98b8
40 changed files with 778 additions and 103 deletions

View File

@ -573,74 +573,104 @@ function buildAction(
} }
let text = ''; let text = '';
const translationValues = [];
let type: ApiAction['type'] = 'other'; let type: ApiAction['type'] = 'other';
let photo: ApiPhoto | undefined; let photo: ApiPhoto | undefined;
const targetUserId = 'users' in action const targetUserIds = 'users' in action
// Api returns array of userIds, but no action currently has multiple users in it ? action.users && action.users
? action.users && action.users[0] : ('userId' in action && [action.userId]) || [];
: ('userId' in action && action.userId) || undefined;
let targetChatId: number | undefined; let targetChatId: number | undefined;
if (action instanceof GramJs.MessageActionChatCreate) { if (action instanceof GramJs.MessageActionChatCreate) {
text = `%action_origin% created the group «${action.title}»`; text = 'Notification.CreatedChatWithTitle';
translationValues.push('%action_origin%', action.title);
} else if (action instanceof GramJs.MessageActionChatEditTitle) { } else if (action instanceof GramJs.MessageActionChatEditTitle) {
text = isChannelPost if (isChannelPost) {
? `Channel renamed to «${action.title}»` text = 'Channel.MessageTitleUpdated';
: `%action_origin% changed group name to «${action.title}»`; translationValues.push(action.title);
} else {
text = 'Notification.ChangedGroupName';
translationValues.push('%action_origin%', action.title);
}
} else if (action instanceof GramJs.MessageActionChatEditPhoto) { } else if (action instanceof GramJs.MessageActionChatEditPhoto) {
text = isChannelPost if (isChannelPost) {
? 'Channel photo updated' text = 'Channel.MessagePhotoUpdated';
: '%action_origin% updated group photo'; } else {
text = 'Notification.ChangedGroupPhoto';
translationValues.push('%action_origin%');
}
} else if (action instanceof GramJs.MessageActionChatDeletePhoto) { } else if (action instanceof GramJs.MessageActionChatDeletePhoto) {
text = isChannelPost if (isChannelPost) {
? 'Channel photo was deleted' text = 'Channel.MessagePhotoRemoved';
: 'Chat photo was deleted'; } else {
text = 'Group.MessagePhotoRemoved';
}
} else if (action instanceof GramJs.MessageActionChatAddUser) { } else if (action instanceof GramJs.MessageActionChatAddUser) {
text = !senderId || senderId === targetUserId if (!senderId || targetUserIds.includes(senderId)) {
? '%target_user% joined the group' text = 'Notification.JoinedChat';
: '%action_origin% added %target_user% to the group'; translationValues.push('%target_user%');
} else {
text = 'Notification.Invited';
translationValues.push('%action_origin%', '%target_user%');
}
} else if (action instanceof GramJs.MessageActionChatDeleteUser) { } else if (action instanceof GramJs.MessageActionChatDeleteUser) {
text = !senderId || senderId === targetUserId if (!senderId || targetUserIds.includes(senderId)) {
? '%target_user% left the group' text = 'Notification.LeftChat';
: '%action_origin% removed %target_user% from the group'; translationValues.push('%target_user%');
} else {
text = 'Notification.Kicked';
translationValues.push('%action_origin%', '%target_user%');
}
} else if (action instanceof GramJs.MessageActionChatJoinedByLink) { } else if (action instanceof GramJs.MessageActionChatJoinedByLink) {
text = '%action_origin% joined the chat from invitation link'; text = 'Notification.JoinedGroupByLink';
translationValues.push('%action_origin%');
} else if (action instanceof GramJs.MessageActionChannelCreate) { } else if (action instanceof GramJs.MessageActionChannelCreate) {
text = 'Channel created'; text = 'Notification.CreatedChannel';
} else if (action instanceof GramJs.MessageActionChatMigrateTo) { } else if (action instanceof GramJs.MessageActionChatMigrateTo) {
targetChatId = getApiChatIdFromMtpPeer(action);
text = 'Migrated to %target_chat%'; text = 'Migrated to %target_chat%';
targetChatId = getApiChatIdFromMtpPeer(action); translationValues.push('%target_chat%');
} else if (action instanceof GramJs.MessageActionChannelMigrateFrom) { } else if (action instanceof GramJs.MessageActionChannelMigrateFrom) {
text = 'Migrated from %target_chat%';
targetChatId = getApiChatIdFromMtpPeer(action); targetChatId = getApiChatIdFromMtpPeer(action);
text = 'Migrated from %target_chat%';
translationValues.push('%target_chat%');
} else if (action instanceof GramJs.MessageActionPinMessage) { } else if (action instanceof GramJs.MessageActionPinMessage) {
text = '%action_origin% pinned %message%'; text = 'Notification.PinnedTextMessage';
translationValues.push('%action_origin%', '%message%');
} else if (action instanceof GramJs.MessageActionHistoryClear) { } else if (action instanceof GramJs.MessageActionHistoryClear) {
text = 'Chat history was cleared'; text = 'HistoryCleared';
type = 'historyClear'; type = 'historyClear';
} else if (action instanceof GramJs.MessageActionPhoneCall) { } else if (action instanceof GramJs.MessageActionPhoneCall) {
text = `${isOutgoing ? 'Outgoing' : 'Incoming'} ${action.video ? 'Video' : 'Phone'} Call`; const withDuration = Boolean(action.duration);
text = [
withDuration ? 'ChatList.Service' : 'Chat',
action.video ? 'VideoCall' : 'Call',
isOutgoing ? (withDuration ? 'outgoing' : 'Outgoing') : (withDuration ? 'incoming' : 'Incoming'),
].join('.');
if (action.duration) { if (withDuration) {
const mins = Math.max(Math.round(action.duration / 60), 1); const mins = Math.max(Math.round(action.duration! / 60), 1);
text += ` (${mins} min${mins > 1 ? 's' : ''})`; translationValues.push(`${mins} min${mins > 1 ? 's' : ''}`);
} }
} else if (action instanceof GramJs.MessageActionContactSignUp) { } else if (action instanceof GramJs.MessageActionContactSignUp) {
text = '%action_origin% joined Telegram'; text = 'Notification.Joined';
translationValues.push('%action_origin%');
} else if (action instanceof GramJs.MessageActionPaymentSent) { } else if (action instanceof GramJs.MessageActionPaymentSent) {
const currencySign = getCurrencySign(action.currency); const currencySign = getCurrencySign(action.currency);
const amount = (Number(action.totalAmount) / 100).toFixed(2); const amount = (Number(action.totalAmount) / 100).toFixed(2);
text = `You successfully transferred ${currencySign}${amount} to shop for %product%`; text = 'Notification.PaymentSent';
translationValues.push(currencySign, amount, '%product%');
} else if (action instanceof GramJs.MessageActionGroupCall) { } else if (action instanceof GramJs.MessageActionGroupCall) {
if (action.duration) { if (action.duration) {
const mins = Math.max(Math.round(action.duration / 60), 1); const mins = Math.max(Math.round(action.duration / 60), 1);
text = `Voice chat ended (${mins} min${mins > 1 ? 's' : ''})`; text = 'Notification.VoiceChatEnded';
translationValues.push(`${mins} min${mins > 1 ? 's' : ''}`);
} else { } else {
text = 'Voice chat started'; text = 'Notification.VoiceChatStartedChannel';
} }
} else { } else {
text = '%ACTION_NOT_IMPLEMENTED%'; text = 'ChatList.UnsupportedMessage';
} }
if ('photo' in action && action.photo instanceof GramJs.Photo) { if ('photo' in action && action.photo instanceof GramJs.Photo) {
@ -651,9 +681,10 @@ function buildAction(
return { return {
text, text,
type, type,
targetUserId, targetUserIds,
targetChatId, targetChatId,
photo, // TODO Only used internally now, will be used for the UI in future photo, // TODO Only used internally now, will be used for the UI in future
translationValues,
}; };
} }

View File

@ -26,6 +26,7 @@ export function buildApiUser(mtpUser: GramJs.TypeUser): ApiUser | undefined {
const avatarHash = mtpUser.photo instanceof GramJs.UserProfilePhoto const avatarHash = mtpUser.photo instanceof GramJs.UserProfilePhoto
? String(mtpUser.photo.photoId) ? String(mtpUser.photo.photoId)
: undefined; : undefined;
const userType = buildApiUserType(mtpUser);
return { return {
id, id,
@ -33,8 +34,9 @@ export function buildApiUser(mtpUser: GramJs.TypeUser): ApiUser | undefined {
...(mtpUser.self && { isSelf: true }), ...(mtpUser.self && { isSelf: true }),
...(mtpUser.verified && { isVerified: true }), ...(mtpUser.verified && { isVerified: true }),
...((mtpUser.contact || mtpUser.mutualContact) && { isContact: true }), ...((mtpUser.contact || mtpUser.mutualContact) && { isContact: true }),
type: buildApiUserType(mtpUser), type: userType,
...(firstName && { firstName }), ...(firstName && { firstName }),
...(userType === 'userTypeBot' && { canBeInvitedToGroup: !mtpUser.botNochats }),
...(lastName && { lastName }), ...(lastName && { lastName }),
username: mtpUser.username || '', username: mtpUser.username || '',
phoneNumber: mtpUser.phone || '', phoneNumber: mtpUser.phone || '',

View File

@ -172,7 +172,7 @@ export async function searchChats({ query }: { query: string }) {
updateLocalDb(result); updateLocalDb(result);
const localPeerIds = result.myResults.map(getApiChatIdFromMtpPeer); const localPeerIds = result.myResults.map(getApiChatIdFromMtpPeer);
const allChats = [...result.chats, ...result.users] const allChats = result.chats.concat(result.users)
.map((user) => buildApiChatFromPreview(user)) .map((user) => buildApiChatFromPreview(user))
.filter<ApiChat>(Boolean as any); .filter<ApiChat>(Boolean as any);
const allUsers = result.users.map(buildApiUser).filter((user) => !!user && !user.isSelf) as ApiUser[]; const allUsers = result.users.map(buildApiUser).filter((user) => !!user && !user.isSelf) as ApiUser[];
@ -756,15 +756,15 @@ export function updateChatDefaultBannedRights({
} }
export function updateChatMemberBannedRights({ export function updateChatMemberBannedRights({
chat, user, bannedRights, chat, user, bannedRights, untilDate,
}: { chat: ApiChat; user: ApiUser; bannedRights: ApiChatBannedRights }) { }: { chat: ApiChat; user: ApiUser; bannedRights: ApiChatBannedRights; untilDate?: number }) {
const channel = buildInputEntity(chat.id, chat.accessHash) as GramJs.InputChannel; const channel = buildInputEntity(chat.id, chat.accessHash) as GramJs.InputChannel;
const participant = buildInputPeer(user.id, user.accessHash) as GramJs.InputUser; const participant = buildInputPeer(user.id, user.accessHash) as GramJs.InputUser;
return invokeRequest(new GramJs.channels.EditBanned({ return invokeRequest(new GramJs.channels.EditBanned({
channel, channel,
participant, participant,
bannedRights: buildChatBannedRights(bannedRights), bannedRights: buildChatBannedRights(bannedRights, untilDate),
}), true); }), true);
} }
@ -957,6 +957,51 @@ export async function openChatByInvite(hash: string) {
return { chatId: chat.id }; return { chatId: chat.id };
} }
export function addChatMembers(chat: ApiChat, users: ApiUser[]) {
if (chat.type === 'chatTypeChannel' || chat.type === 'chatTypeSuperGroup') {
return invokeRequest(new GramJs.channels.InviteToChannel({
channel: buildInputEntity(chat.id, chat.accessHash) as GramJs.InputChannel,
users: users.map((user) => buildInputEntity(user.id, user.accessHash)) as GramJs.InputUser[],
}), true);
}
return Promise.all(users.map((user) => {
return invokeRequest(new GramJs.messages.AddChatUser({
chatId: buildInputEntity(chat.id) as number,
userId: buildInputEntity(user.id, user.accessHash) as GramJs.InputUser,
}), true);
}));
}
export function deleteChatMember(chat: ApiChat, user: ApiUser) {
if (chat.type === 'chatTypeChannel' || chat.type === 'chatTypeSuperGroup') {
return updateChatMemberBannedRights({
chat,
user,
bannedRights: {
viewMessages: true,
sendMessages: true,
sendMedia: true,
sendStickers: true,
sendGifs: true,
sendGames: true,
sendInline: true,
embedLinks: true,
sendPolls: true,
changeInfo: true,
inviteUsers: true,
pinMessages: true,
},
untilDate: MAX_INT_32,
});
} else {
return invokeRequest(new GramJs.messages.DeleteChatUser({
chatId: buildInputEntity(chat.id) as number,
userId: buildInputEntity(user.id, user.accessHash) as GramJs.InputUser,
}), true);
}
}
function preparePeers( function preparePeers(
result: GramJs.messages.Dialogs | GramJs.messages.DialogsSlice | GramJs.messages.PeerDialogs, result: GramJs.messages.Dialogs | GramJs.messages.DialogsSlice | GramJs.messages.PeerDialogs,
) { ) {

View File

@ -14,7 +14,7 @@ export {
fetchChatFolders, editChatFolder, deleteChatFolder, fetchRecommendedChatFolders, fetchChatFolders, editChatFolder, deleteChatFolder, fetchRecommendedChatFolders,
getChatByUsername, togglePreHistoryHidden, updateChatDefaultBannedRights, updateChatMemberBannedRights, getChatByUsername, togglePreHistoryHidden, updateChatDefaultBannedRights, updateChatMemberBannedRights,
updateChatTitle, updateChatAbout, toggleSignatures, updateChatAdmin, fetchGroupsForDiscussion, setDiscussionGroup, updateChatTitle, updateChatAbout, toggleSignatures, updateChatAdmin, fetchGroupsForDiscussion, setDiscussionGroup,
migrateChat, openChatByInvite, fetchMembers, importChatInvite, migrateChat, openChatByInvite, fetchMembers, importChatInvite, addChatMembers, deleteChatMember,
} from './chats'; } from './chats';
export { export {

View File

@ -213,6 +213,14 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) {
if (update._entities && update._entities.some((e): e is GramJs.User => ( if (update._entities && update._entities.some((e): e is GramJs.User => (
e instanceof GramJs.User && !!e.self && e.id === action.userId e instanceof GramJs.User && !!e.self && e.id === action.userId
))) { ))) {
onUpdate({
'@type': 'updateChat',
id: message.chatId,
chat: {
isRestricted: true,
},
});
onUpdate({ onUpdate({
'@type': 'updateChatLeave', '@type': 'updateChatLeave',
id: message.chatId, id: message.chatId,

View File

@ -145,10 +145,11 @@ export type ApiNewPoll = {
export interface ApiAction { export interface ApiAction {
text: string; text: string;
targetUserId?: number; targetUserIds?: number[];
targetChatId?: number; targetChatId?: number;
type: 'historyClear' | 'other'; type: 'historyClear' | 'other';
photo?: ApiPhoto; photo?: ApiPhoto;
translationValues: string[];
} }
export interface ApiWebPage { export interface ApiWebPage {

View File

@ -15,6 +15,7 @@ export interface ApiUser {
accessHash?: string; accessHash?: string;
avatarHash?: string; avatarHash?: string;
photos?: ApiPhoto[]; photos?: ApiPhoto[];
canBeInvitedToGroup?: boolean;
// Obtained from GetFullUser / UserFullInfo // Obtained from GetFullUser / UserFullInfo
fullInfo?: ApiUserFullInfo; fullInfo?: ApiUserFullInfo;

View File

@ -165,7 +165,7 @@ const DeleteChatModal: FC<OwnProps & StateProps & DispatchProps> = ({
{renderMessage()} {renderMessage()}
{canDeleteForAll && ( {canDeleteForAll && (
<Button color="danger" className="confirm-dialog-button" isText onClick={handleDeleteMessageForAll}> <Button color="danger" className="confirm-dialog-button" isText onClick={handleDeleteMessageForAll}>
{contactName ? lang('ChatList.DeleteForEveryone', contactName) : lang('DeleteForAll')} {contactName ? renderText(lang('ChatList.DeleteForEveryone', contactName)) : lang('DeleteForAll')}
</Button> </Button>
)} )}
<Button color="danger" className="confirm-dialog-button" isText onClick={handleDeleteChat}> <Button color="danger" className="confirm-dialog-button" isText onClick={handleDeleteChat}>

View File

@ -98,7 +98,7 @@ const DeleteMessageModal: FC<OwnProps & StateProps & DispatchProps> = ({
)} )}
{canDeleteForAll && ( {canDeleteForAll && (
<Button color="danger" className="confirm-dialog-button" isText onClick={handleDeleteMessageForAll}> <Button color="danger" className="confirm-dialog-button" isText onClick={handleDeleteMessageForAll}>
{contactName && lang('Conversation.DeleteMessagesFor', renderText(contactName))} {contactName && renderText(lang('Conversation.DeleteMessagesFor', contactName))}
{!contactName && lang('Conversation.DeleteMessagesForEveryone')} {!contactName && lang('Conversation.DeleteMessagesForEveryone')}
</Button> </Button>
)} )}

View File

@ -26,6 +26,7 @@ type OwnProps = {
notFoundText?: string; notFoundText?: string;
searchInputId?: string; searchInputId?: string;
isLoading?: boolean; isLoading?: boolean;
noScrollRestore?: boolean;
onSelectedIdsChange: (ids: number[]) => void; onSelectedIdsChange: (ids: number[]) => void;
onFilterChange: (value: string) => void; onFilterChange: (value: string) => void;
onLoadMore?: () => void; onLoadMore?: () => void;
@ -45,6 +46,7 @@ const Picker: FC<OwnProps> = ({
notFoundText, notFoundText,
searchInputId, searchInputId,
isLoading, isLoading,
noScrollRestore,
onSelectedIdsChange, onSelectedIdsChange,
onFilterChange, onFilterChange,
onLoadMore, onLoadMore,
@ -107,6 +109,7 @@ const Picker: FC<OwnProps> = ({
className="picker-list custom-scroll" className="picker-list custom-scroll"
items={viewportIds} items={viewportIds}
onLoadMore={getMore} onLoadMore={getMore}
noScrollRestore={noScrollRestore}
> >
{viewportIds.map((id) => ( {viewportIds.map((id) => (
<ListItem <ListItem

View File

@ -14,6 +14,7 @@ import {
} from '../../modules/helpers'; } from '../../modules/helpers';
import { pick } from '../../util/iteratees'; import { pick } from '../../util/iteratees';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
import renderText from './helpers/renderText';
import Modal from '../ui/Modal'; import Modal from '../ui/Modal';
import Button from '../ui/Button'; import Button from '../ui/Button';
@ -91,7 +92,7 @@ const PinMessageModal: FC<OwnProps & StateProps & DispatchProps> = ({
{canPinForAll && ( {canPinForAll && (
<Button className="confirm-dialog-button" isText onClick={handlePinMessageForAll}> <Button className="confirm-dialog-button" isText onClick={handlePinMessageForAll}>
{contactName {contactName
? lang('Conversation.PinMessagesFor', contactName) ? renderText(lang('Conversation.PinMessagesFor', contactName))
: lang('Conversation.PinMessageAlert.PinAndNotifyMembers')} : lang('Conversation.PinMessageAlert.PinAndNotifyMembers')}
</Button> </Button>
)} )}

View File

@ -29,7 +29,7 @@ export function renderActionMessageText(
lang: LangFn, lang: LangFn,
message: ApiMessage, message: ApiMessage,
actionOrigin?: ApiUser | ApiChat, actionOrigin?: ApiUser | ApiChat,
targetUser?: ApiUser, targetUsers?: ApiUser[],
targetMessage?: ApiMessage, targetMessage?: ApiMessage,
targetChatId?: number, targetChatId?: number,
options: ActionMessageTextOptions = {}, options: ActionMessageTextOptions = {},
@ -37,13 +37,13 @@ export function renderActionMessageText(
if (!message.content.action) { if (!message.content.action) {
return []; return [];
} }
const { text } = message.content.action; const { text, translationValues } = message.content.action;
const content: TextPart[] = []; const content: TextPart[] = [];
const textOptions: ActionMessageTextOptions = { ...options, maxTextLength: 16 }; const textOptions: ActionMessageTextOptions = { ...options, maxTextLength: 16 };
let unprocessed: string; let unprocessed: string;
let processed = processPlaceholder( let processed = processPlaceholder(
text, lang(text, translationValues && translationValues.length ? translationValues : undefined),
'%action_origin%', '%action_origin%',
actionOrigin actionOrigin
? (!options.isEmbedded && renderOriginContent(lang, actionOrigin, options.asPlain)) || NBSP ? (!options.isEmbedded && renderOriginContent(lang, actionOrigin, options.asPlain)) || NBSP
@ -56,8 +56,8 @@ export function renderActionMessageText(
processed = processPlaceholder( processed = processPlaceholder(
unprocessed, unprocessed,
'%target_user%', '%target_user%',
targetUser targetUsers
? renderUserContent(targetUser, options.asPlain) ? targetUsers.map((user) => renderUserContent(user, options.asPlain)).filter<TextPart>(Boolean as any)
: 'User', : 'User',
); );
@ -180,7 +180,7 @@ function renderMigratedContent(chatId: number, asPlain?: boolean): string | Text
return <ChatLink className="action-link" chatId={chatId}>{text}</ChatLink>; return <ChatLink className="action-link" chatId={chatId}>{text}</ChatLink>;
} }
function processPlaceholder(text: string, placeholder: string, replaceValue?: TextPart): TextPart[] { function processPlaceholder(text: string, placeholder: string, replaceValue?: TextPart | TextPart[]): TextPart[] {
const placeholderPosition = text.indexOf(placeholder); const placeholderPosition = text.indexOf(placeholder);
if (placeholderPosition < 0 || !replaceValue) { if (placeholderPosition < 0 || !replaceValue) {
return [text]; return [text];
@ -188,7 +188,16 @@ function processPlaceholder(text: string, placeholder: string, replaceValue?: Te
const content: TextPart[] = []; const content: TextPart[] = [];
content.push(text.substring(0, placeholderPosition)); content.push(text.substring(0, placeholderPosition));
content.push(replaceValue); if (Array.isArray(replaceValue)) {
replaceValue.forEach((value, index) => {
content.push(value);
if (index + 1 < replaceValue.length) {
content.push(', ');
}
});
} else {
content.push(replaceValue);
}
content.push(text.substring(placeholderPosition + placeholder.length)); content.push(text.substring(placeholderPosition + placeholder.length));
return content; return content;

View File

@ -1,5 +1,5 @@
import React, { import React, {
FC, memo, useCallback, useLayoutEffect, useRef, FC, memo, useCallback, useLayoutEffect, useMemo, useRef,
} from '../../../lib/teact/teact'; } from '../../../lib/teact/teact';
import { withGlobal } from '../../../lib/teact/teactn'; import { withGlobal } from '../../../lib/teact/teactn';
@ -66,7 +66,8 @@ type StateProps = {
chat?: ApiChat; chat?: ApiChat;
isMuted?: boolean; isMuted?: boolean;
privateChatUser?: ApiUser; privateChatUser?: ApiUser;
actionTargetUser?: ApiUser; usersById?: Record<number, ApiUser>;
actionTargetUserIds?: number[];
actionTargetMessage?: ApiMessage; actionTargetMessage?: ApiMessage;
actionTargetChatId?: number; actionTargetChatId?: number;
lastMessageSender?: ApiUser; lastMessageSender?: ApiUser;
@ -91,8 +92,9 @@ const Chat: FC<OwnProps & StateProps & DispatchProps> = ({
isPinned, isPinned,
chat, chat,
isMuted, isMuted,
usersById,
privateChatUser, privateChatUser,
actionTargetUser, actionTargetUserIds,
lastMessageSender, lastMessageSender,
lastMessageOutgoingStatus, lastMessageOutgoingStatus,
actionTargetMessage, actionTargetMessage,
@ -122,6 +124,12 @@ const Chat: FC<OwnProps & StateProps & DispatchProps> = ({
const mediaBlobUrl = useMedia(lastMessage ? getMessageMediaHash(lastMessage, 'micro') : undefined); const mediaBlobUrl = useMedia(lastMessage ? getMessageMediaHash(lastMessage, 'micro') : undefined);
const isRoundVideo = Boolean(lastMessage && getMessageRoundVideo(lastMessage)); const isRoundVideo = Boolean(lastMessage && getMessageRoundVideo(lastMessage));
const actionTargetUsers = useMemo(() => {
return actionTargetUserIds
? actionTargetUserIds.map((userId) => usersById && usersById[userId]).filter<ApiUser>(Boolean as any)
: undefined;
}, [actionTargetUserIds, usersById]);
// Sets animation excess values when `orderDiff` changes and then resets excess values to animate. // Sets animation excess values when `orderDiff` changes and then resets excess values to animate.
useLayoutEffect(() => { useLayoutEffect(() => {
const element = ref.current; const element = ref.current;
@ -221,7 +229,7 @@ const Chat: FC<OwnProps & StateProps & DispatchProps> = ({
lang, lang,
lastMessage, lastMessage,
actionOrigin, actionOrigin,
actionTargetUser, actionTargetUsers,
actionTargetMessage, actionTargetMessage,
actionTargetChatId, actionTargetChatId,
{ asPlain: true }, { asPlain: true },
@ -322,8 +330,9 @@ export default memo(withGlobal<OwnProps>(
const actionTargetMessage = lastMessageAction && replyToMessageId const actionTargetMessage = lastMessageAction && replyToMessageId
? selectChatMessage(global, chat.id, replyToMessageId) ? selectChatMessage(global, chat.id, replyToMessageId)
: undefined; : undefined;
const { targetUserId: actionTargetUserId, targetChatId: actionTargetChatId } = lastMessageAction || {}; const { targetUserIds: actionTargetUserIds, targetChatId: actionTargetChatId } = lastMessageAction || {};
const privateChatUserId = getPrivateChatUserId(chat); const privateChatUserId = getPrivateChatUserId(chat);
const { byId: usersById } = global.users;
const { const {
chatId: currentChatId, chatId: currentChatId,
threadId: currentThreadId, threadId: currentThreadId,
@ -336,7 +345,8 @@ export default memo(withGlobal<OwnProps>(
lastMessageSender, lastMessageSender,
...(isOutgoing && { lastMessageOutgoingStatus: selectOutgoingStatus(global, chat.lastMessage) }), ...(isOutgoing && { lastMessageOutgoingStatus: selectOutgoingStatus(global, chat.lastMessage) }),
...(privateChatUserId && { privateChatUser: selectUser(global, privateChatUserId) }), ...(privateChatUserId && { privateChatUser: selectUser(global, privateChatUserId) }),
...(actionTargetUserId && { actionTargetUser: selectUser(global, actionTargetUserId) }), usersById,
actionTargetUserIds,
actionTargetChatId, actionTargetChatId,
actionTargetMessage, actionTargetMessage,
draft: selectDraft(global, chatId, MAIN_THREAD_ID), draft: selectDraft(global, chatId, MAIN_THREAD_ID),

View File

@ -9,7 +9,7 @@ import { ApiChat, ApiUser } from '../../../api/types';
import { pick, unique } from '../../../util/iteratees'; import { pick, unique } from '../../../util/iteratees';
import { throttle } from '../../../util/schedulers'; import { throttle } from '../../../util/schedulers';
import searchWords from '../../../util/searchWords'; import searchWords from '../../../util/searchWords';
import { getUserFullName, sortChatIds } from '../../../modules/helpers'; import { getUserFullName, isUserBot, sortChatIds } from '../../../modules/helpers';
import useLang from '../../../hooks/useLang'; import useLang from '../../../hooks/useLang';
import useHistoryBack from '../../../hooks/useHistoryBack'; import useHistoryBack from '../../../hooks/useHistoryBack';
@ -98,7 +98,11 @@ const NewChatStep1: FC<OwnProps & StateProps & DispatchProps> = ({
...foundContactIds, ...foundContactIds,
...(localUserIds || []), ...(localUserIds || []),
...(globalUserIds || []), ...(globalUserIds || []),
]), ]).filter((contactId) => {
const user = usersById[contactId];
return !user || !isUserBot(user) || user.canBeInvitedToGroup;
}),
chatsById, chatsById,
false, false,
selectedMemberIds, selectedMemberIds,

View File

@ -1,5 +1,5 @@
import React, { import React, {
FC, memo, useEffect, useRef, FC, memo, useEffect, useMemo, useRef,
} from '../../lib/teact/teact'; } from '../../lib/teact/teact';
import { withGlobal } from '../../lib/teact/teactn'; import { withGlobal } from '../../lib/teact/teactn';
@ -36,8 +36,9 @@ type OwnProps = {
}; };
type StateProps = { type StateProps = {
usersById: Record<number, ApiUser>;
sender?: ApiUser | ApiChat; sender?: ApiUser | ApiChat;
targetUser?: ApiUser; targetUserIds?: number[];
targetMessage?: ApiMessage; targetMessage?: ApiMessage;
targetChatId?: number; targetChatId?: number;
isFocused: boolean; isFocused: boolean;
@ -53,8 +54,9 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
isEmbedded, isEmbedded,
appearanceOrder = 0, appearanceOrder = 0,
isLastInList, isLastInList,
usersById,
sender, sender,
targetUser, targetUserIds,
targetMessage, targetMessage,
targetChatId, targetChatId,
isFocused, isFocused,
@ -81,11 +83,17 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
}, [appearanceOrder, markShown, noAppearanceAnimation]); }, [appearanceOrder, markShown, noAppearanceAnimation]);
const { transitionClassNames } = useShowTransition(isShown, undefined, noAppearanceAnimation, false); const { transitionClassNames } = useShowTransition(isShown, undefined, noAppearanceAnimation, false);
const targetUsers = useMemo(() => {
return targetUserIds
? targetUserIds.map((userId) => usersById && usersById[userId]).filter<ApiUser>(Boolean as any)
: undefined;
}, [targetUserIds, usersById]);
const content = renderActionMessageText( const content = renderActionMessageText(
lang, lang,
message, message,
sender, sender,
targetUser, targetUsers,
targetMessage, targetMessage,
targetChatId, targetChatId,
isEmbedded ? { isEmbedded: true, asPlain: true } : undefined, isEmbedded ? { isEmbedded: true, asPlain: true } : undefined,
@ -140,8 +148,9 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
export default memo(withGlobal<OwnProps>( export default memo(withGlobal<OwnProps>(
(global, { message }): StateProps => { (global, { message }): StateProps => {
const { byId: usersById } = global.users;
const userId = message.senderId; const userId = message.senderId;
const { targetUserId, targetChatId } = message.content.action || {}; const { targetUserIds, targetChatId } = message.content.action || {};
const targetMessageId = message.replyToMessageId; const targetMessageId = message.replyToMessageId;
const targetMessage = targetMessageId const targetMessage = targetMessageId
? selectChatMessage(global, message.chatId, targetMessageId) ? selectChatMessage(global, message.chatId, targetMessageId)
@ -156,9 +165,10 @@ export default memo(withGlobal<OwnProps>(
: userId ? selectUser(global, userId) : undefined; : userId ? selectUser(global, userId) : undefined;
return { return {
usersById,
sender, sender,
...(targetUserId && { targetUser: selectUser(global, targetUserId) }),
targetChatId, targetChatId,
targetUserIds,
targetMessage, targetMessage,
isFocused, isFocused,
...(isFocused && { focusDirection, noFocusHighlight }), ...(isFocused && { focusDirection, noFocusHighlight }),

View File

@ -90,7 +90,7 @@ const DeleteSelectedMessageModal: FC<OwnProps & StateProps & DispatchProps> = ({
{canDeleteForAll && ( {canDeleteForAll && (
<Button color="danger" className="confirm-dialog-button" isText onClick={handleDeleteMessageForAll}> <Button color="danger" className="confirm-dialog-button" isText onClick={handleDeleteMessageForAll}>
{contactName {contactName
? lang('ChatList.DeleteForEveryone', renderText(contactName)) ? renderText(lang('ChatList.DeleteForEveryone', contactName))
: lang('Conversation.DeleteMessagesForEveryone')} : lang('Conversation.DeleteMessagesForEveryone')}
</Button> </Button>
)} )}

View File

@ -0,0 +1,10 @@
.AddChatMembers {
height: 100%;
overflow: hidden;
position: relative;
&-inner {
height: 100%;
overflow: hidden;
}
}

View File

@ -0,0 +1,203 @@
import React, {
FC, useCallback, useMemo, memo, useState, useEffect,
} from '../../lib/teact/teact';
import { withGlobal } from '../../lib/teact/teactn';
import { GlobalActions } from '../../global/types';
import {
ApiChat, ApiChatMember, ApiUpdateConnectionStateType, ApiUser,
} from '../../api/types';
import { NewChatMembersProgress } from '../../types';
import { pick, unique } from '../../util/iteratees';
import { selectChat } from '../../modules/selectors';
import searchWords from '../../util/searchWords';
import {
getUserFullName, isChatChannel, isUserBot, sortChatIds,
} from '../../modules/helpers';
import useLang from '../../hooks/useLang';
import usePrevious from '../../hooks/usePrevious';
import useHistoryBack from '../../hooks/useHistoryBack';
import Picker from '../common/Picker';
import FloatingActionButton from '../ui/FloatingActionButton';
import Spinner from '../ui/Spinner';
import './AddChatMembers.scss';
export type OwnProps = {
chatId: number;
isActive: boolean;
onNextStep: (memberIds: number[]) => void;
onClose: NoneToVoidFunction;
};
type StateProps = {
connectionState?: ApiUpdateConnectionStateType;
isChannel?: boolean;
members?: ApiChatMember[];
currentUserId?: number;
usersById: Record<number, ApiUser>;
chatsById: Record<number, ApiChat>;
localContactIds?: number[];
searchQuery?: string;
isLoading: boolean;
isSearching?: boolean;
localUserIds?: number[];
globalUserIds?: number[];
};
type DispatchProps = Pick<GlobalActions, 'loadContactList' | 'setUserSearchQuery'>;
const AddChatMembers: FC<OwnProps & StateProps & DispatchProps> = ({
isChannel,
connectionState,
members,
onNextStep,
currentUserId,
usersById,
chatsById,
localContactIds,
isLoading,
searchQuery,
isSearching,
localUserIds,
globalUserIds,
setUserSearchQuery,
onClose,
isActive,
loadContactList,
}) => {
const lang = useLang();
const [selectedMemberIds, setSelectedMemberIds] = useState<number[]>([]);
const prevSelectedMemberIds = usePrevious(selectedMemberIds);
const noPickerScrollRestore = prevSelectedMemberIds === selectedMemberIds;
useEffect(() => {
if (isActive && connectionState === 'connectionStateReady') {
loadContactList();
}
}, [connectionState, isActive, loadContactList]);
useHistoryBack(isActive, onClose);
const memberIds = useMemo(() => {
return members ? members.map((member) => member.userId) : [];
}, [members]);
const handleFilterChange = useCallback((query: string) => {
setUserSearchQuery({ query });
}, [setUserSearchQuery]);
const displayedIds = useMemo(() => {
const contactIds = localContactIds
? sortChatIds(localContactIds.filter((id) => id !== currentUserId), chatsById)
: [];
if (!searchQuery) {
return contactIds.filter((id) => !memberIds.includes(id));
}
const foundContactIds = contactIds.filter((id) => {
const user = usersById[id];
if (!user) {
return false;
}
const fullName = getUserFullName(user);
return fullName && searchWords(fullName, searchQuery);
});
return sortChatIds(
unique([
...foundContactIds,
...(localUserIds || []),
...(globalUserIds || []),
]).filter((contactId) => {
const user = usersById[contactId];
// The user can be added to the chat if the following conditions are met:
// the user has not yet been added to the current chat
// AND (it is not found (user from global search) OR it is not a bot OR it is a bot,
// but the current chat is not a channel AND the appropriate permission is set).
return !memberIds.includes(contactId)
&& (!user || !isUserBot(user) || (!isChannel && user.canBeInvitedToGroup));
}),
chatsById,
);
}, [
localContactIds, chatsById, searchQuery, localUserIds, globalUserIds,
currentUserId, usersById, memberIds, isChannel,
]);
const handleNextStep = useCallback(() => {
if (selectedMemberIds.length) {
setUserSearchQuery({ query: '' });
onNextStep(selectedMemberIds);
}
}, [selectedMemberIds, setUserSearchQuery, onNextStep]);
return (
<div className="AddChatMembers">
<div className="AddChatMembers-inner">
<Picker
itemIds={displayedIds}
selectedIds={selectedMemberIds}
filterValue={searchQuery}
filterPlaceholder={lang('lng_channel_add_users')}
searchInputId="new-members-picker-search"
isLoading={isSearching}
onSelectedIdsChange={setSelectedMemberIds}
onFilterChange={handleFilterChange}
noScrollRestore={noPickerScrollRestore}
/>
<FloatingActionButton
isShown={Boolean(selectedMemberIds.length)}
disabled={isLoading}
ariaLabel={lang('lng_channel_add_users')}
onClick={handleNextStep}
>
{isLoading ? (
<Spinner color="white" />
) : (
<i className="icon-arrow-right" />
)}
</FloatingActionButton>
</div>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global, { chatId }): StateProps => {
const chat = selectChat(global, chatId);
const { userIds: localContactIds } = global.contactList || {};
const { byId: usersById } = global.users;
const { byId: chatsById } = global.chats;
const { currentUserId, newChatMembersProgress, connectionState } = global;
const isChannel = chat && isChatChannel(chat);
const {
query: searchQuery,
fetchingStatus,
globalUserIds,
localUserIds,
} = global.userSearch;
return {
isChannel,
members: chat && chat.fullInfo ? chat.fullInfo.members : undefined,
currentUserId,
usersById,
chatsById,
localContactIds,
searchQuery,
isSearching: fetchingStatus,
isLoading: newChatMembersProgress === NewChatMembersProgress.Loading,
globalUserIds,
localUserIds,
connectionState,
};
},
(setGlobal, actions): DispatchProps => pick(actions, ['loadContactList', 'setUserSearchQuery']),
)(AddChatMembers));

View File

@ -0,0 +1,77 @@
import React, { FC, useCallback, memo } from '../../lib/teact/teact';
import { withGlobal } from '../../lib/teact/teactn';
import { GlobalActions } from '../../global/types';
import { ApiChat } from '../../api/types';
import { pick } from '../../util/iteratees';
import { selectCurrentChat, selectUser } from '../../modules/selectors';
import { getUserFirstOrLastName } from '../../modules/helpers';
import renderText from '../common/helpers/renderText';
import useLang from '../../hooks/useLang';
import Modal from '../ui/Modal';
import Button from '../ui/Button';
export type OwnProps = {
isOpen: boolean;
userId?: number;
onClose: () => void;
};
type StateProps = {
chat?: ApiChat;
contactName?: string;
};
type DispatchProps = Pick<GlobalActions, 'deleteChatMember'>;
const DeleteMemberModal: FC<OwnProps & StateProps & DispatchProps> = ({
isOpen,
chat,
userId,
contactName,
onClose,
deleteChatMember,
}) => {
const lang = useLang();
const handleDeleteChatMember = useCallback(() => {
deleteChatMember({ chatId: chat!.id, userId });
onClose();
}, [chat, deleteChatMember, onClose, userId]);
if (!chat || !userId) {
return undefined;
}
return (
<Modal
isOpen={isOpen}
onClose={onClose}
onEnter={handleDeleteChatMember}
className="delete"
title={lang('GroupRemoved.Remove')}
>
<p>{renderText(lang('PeerInfo.Confirm.RemovePeer', contactName))}</p>
<Button color="danger" className="confirm-dialog-button" isText onClick={handleDeleteChatMember}>
{lang('lng_box_remove')}
</Button>
<Button className="confirm-dialog-button" isText onClick={onClose}>{lang('Cancel')}</Button>
</Modal>
);
};
export default memo(withGlobal<OwnProps>(
(global, { userId }): StateProps => {
const chat = selectCurrentChat(global);
const user = userId && selectUser(global, userId);
const contactName = user ? getUserFirstOrLastName(user) : undefined;
return {
chat,
contactName,
};
},
(setGlobal, actions): DispatchProps => pick(actions, ['deleteChatMember']),
)(DeleteMemberModal));

View File

@ -1,14 +1,15 @@
.Profile { .Profile {
height: 100%; height: 100%;
overflow-y: scroll;
overflow-x: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow-y: scroll;
overflow-x: hidden;
@supports (overflow-y: overlay) { @supports (overflow-y: overlay) {
overflow-y: overlay !important; overflow-y: overlay !important;
} }
> .profile-info > .ChatInfo { > .profile-info > .ChatInfo {
grid-area: chat_info; grid-area: chat_info;
@ -124,7 +125,14 @@
@media (max-width: 600px) { @media (max-width: 600px) {
padding: .5rem 0; padding: .5rem 0;
.ListItem.chat-item-clickable {
margin: 0;
}
} }
} }
} }
.FloatingActionButton {
z-index: 1;
}
} }

View File

@ -11,8 +11,7 @@ import {
} from '../../api/types'; } from '../../api/types';
import { GlobalActions } from '../../global/types'; import { GlobalActions } from '../../global/types';
import { import {
ISettings, NewChatMembersProgress, ISettings, MediaViewerOrigin, ProfileState, ProfileTabType, SharedMediaType,
MediaViewerOrigin, ProfileState, ProfileTabType, SharedMediaType,
} from '../../types'; } from '../../types';
import { import {
@ -23,7 +22,7 @@ import {
} from '../../config'; } from '../../config';
import { IS_TOUCH_ENV } from '../../util/environment'; import { IS_TOUCH_ENV } from '../../util/environment';
import { import {
isChatAdmin, isChatChannel, isChatGroup, isChatPrivate, getHasAdminRight, isChatAdmin, isChatChannel, isChatGroup, isChatPrivate,
} from '../../modules/helpers'; } from '../../modules/helpers';
import { import {
selectChatMessages, selectChatMessages,
@ -54,6 +53,8 @@ import ChatExtra from './ChatExtra';
import Media from '../common/Media'; import Media from '../common/Media';
import WebLink from '../common/WebLink'; import WebLink from '../common/WebLink';
import NothingFound from '../common/NothingFound'; import NothingFound from '../common/NothingFound';
import FloatingActionButton from '../ui/FloatingActionButton';
import DeleteMemberModal from './DeleteMemberModal';
import './Profile.scss'; import './Profile.scss';
@ -67,12 +68,15 @@ type OwnProps = {
type StateProps = { type StateProps = {
theme: ISettings['theme']; theme: ISettings['theme'];
isChannel?: boolean; isChannel?: boolean;
currentUserId?: number;
resolvedUserId?: number; resolvedUserId?: number;
chatMessages?: Record<number, ApiMessage>; chatMessages?: Record<number, ApiMessage>;
foundIds?: number[]; foundIds?: number[];
mediaSearchType?: SharedMediaType; mediaSearchType?: SharedMediaType;
hasMembersTab?: boolean; hasMembersTab?: boolean;
areMembersHidden?: boolean; areMembersHidden?: boolean;
canAddMembers?: boolean;
canDeleteMembers?: boolean;
members?: ApiChatMember[]; members?: ApiChatMember[];
usersById?: Record<number, ApiUser>; usersById?: Record<number, ApiUser>;
isRightColumnShown: boolean; isRightColumnShown: boolean;
@ -83,7 +87,7 @@ type StateProps = {
type DispatchProps = Pick<GlobalActions, ( type DispatchProps = Pick<GlobalActions, (
'setLocalMediaSearchType' | 'loadMoreMembers' | 'searchMediaMessagesLocal' | 'openMediaViewer' | 'setLocalMediaSearchType' | 'loadMoreMembers' | 'searchMediaMessagesLocal' | 'openMediaViewer' |
'openAudioPlayer' | 'openUserInfo' | 'focusMessage' | 'loadProfilePhotos' 'openAudioPlayer' | 'openUserInfo' | 'focusMessage' | 'loadProfilePhotos' | 'setNewChatMembersDialogState'
)>; )>;
const TABS = [ const TABS = [
@ -102,11 +106,14 @@ const Profile: FC<OwnProps & StateProps & DispatchProps> = ({
theme, theme,
isChannel, isChannel,
resolvedUserId, resolvedUserId,
currentUserId,
chatMessages, chatMessages,
foundIds, foundIds,
mediaSearchType, mediaSearchType,
hasMembersTab, hasMembersTab,
areMembersHidden, areMembersHidden,
canAddMembers,
canDeleteMembers,
members, members,
usersById, usersById,
isRightColumnShown, isRightColumnShown,
@ -120,6 +127,7 @@ const Profile: FC<OwnProps & StateProps & DispatchProps> = ({
openUserInfo, openUserInfo,
focusMessage, focusMessage,
loadProfilePhotos, loadProfilePhotos,
setNewChatMembersDialogState,
serverTimeOffset, serverTimeOffset,
}) => { }) => {
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
@ -128,6 +136,7 @@ const Profile: FC<OwnProps & StateProps & DispatchProps> = ({
const transitionRef = useRef<HTMLDivElement>(null); const transitionRef = useRef<HTMLDivElement>(null);
const lang = useLang(); const lang = useLang();
const [activeTab, setActiveTab] = useState(0); const [activeTab, setActiveTab] = useState(0);
const [deletingUserId, setDeletingUserId] = useState<number | undefined>();
const tabs = useMemo(() => ([ const tabs = useMemo(() => ([
...(hasMembersTab ? [{ ...(hasMembersTab ? [{
@ -154,6 +163,10 @@ const Profile: FC<OwnProps & StateProps & DispatchProps> = ({
resetCacheBuster(); resetCacheBuster();
}, [releaseTransitionFix, resetCacheBuster]); }, [releaseTransitionFix, resetCacheBuster]);
const handleNewMemberDialogOpen = useCallback(() => {
setNewChatMembersDialogState(NewChatMembersProgress.InProgress);
}, [setNewChatMembersDialogState]);
// Update search type when switching tabs // Update search type when switching tabs
useEffect(() => { useEffect(() => {
setLocalMediaSearchType({ mediaType: tabType }); setLocalMediaSearchType({ mediaType: tabType });
@ -188,6 +201,10 @@ const Profile: FC<OwnProps & StateProps & DispatchProps> = ({
focusMessage({ chatId: profileId, messageId }); focusMessage({ chatId: profileId, messageId });
}, [profileId, focusMessage]); }, [profileId, focusMessage]);
const handleDeleteMembersModalClose = useCallback(() => {
setDeletingUserId(undefined);
}, []);
useEffect(() => { useEffect(() => {
if (!transitionRef.current || !IS_TOUCH_ENV) { if (!transitionRef.current || !IS_TOUCH_ENV) {
return undefined; return undefined;
@ -215,9 +232,19 @@ const Profile: FC<OwnProps & StateProps & DispatchProps> = ({
} }
const canRenderContents = useAsyncRendering([chatId, resultType], renderingDelay); const canRenderContents = useAsyncRendering([chatId, resultType], renderingDelay);
function getMemberContextAction(id: number) {
return id === currentUserId || !canDeleteMembers ? undefined : [{
title: lang('lng_context_remove_from_group'),
icon: 'stop',
handler: () => {
setDeletingUserId(id);
},
}];
}
function renderSharedMedia() { function renderSharedMedia() {
if (!viewportIds || !canRenderContents || !chatMessages) { if (!viewportIds || !canRenderContents || !chatMessages) {
// This is just a single-frame delay so we do not show spinner // This is just a single-frame delay, so we do not show spinner
const noSpinner = isFirstTab && viewportIds && !canRenderContents; const noSpinner = isFirstTab && viewportIds && !canRenderContents;
return ( return (
@ -308,6 +335,7 @@ const Profile: FC<OwnProps & StateProps & DispatchProps> = ({
teactOrderKey={i} teactOrderKey={i}
className="chat-item-clickable scroll-item" className="chat-item-clickable scroll-item"
onClick={() => handleMemberClick(id)} onClick={() => handleMemberClick(id)}
contextActions={getMemberContextAction(id)}
> >
<PrivateChatInfo userId={id} forceShowSelf /> <PrivateChatInfo userId={id} forceShowSelf />
</ListItem> </ListItem>
@ -334,7 +362,9 @@ const Profile: FC<OwnProps & StateProps & DispatchProps> = ({
> >
{!noProfileInfo && renderProfileInfo(chatId, resolvedUserId)} {!noProfileInfo && renderProfileInfo(chatId, resolvedUserId)}
{!isRestricted && ( {!isRestricted && (
<div className="shared-media"> <div
className="shared-media"
>
<Transition <Transition
ref={transitionRef} ref={transitionRef}
name={lang.isRtl ? 'slide-reversed' : 'slide'} name={lang.isRtl ? 'slide-reversed' : 'slide'}
@ -348,8 +378,26 @@ const Profile: FC<OwnProps & StateProps & DispatchProps> = ({
{renderSharedMedia} {renderSharedMedia}
</Transition> </Transition>
<TabList big activeTab={activeTab} tabs={tabs} onSwitchTab={setActiveTab} /> <TabList big activeTab={activeTab} tabs={tabs} onSwitchTab={setActiveTab} />
</div> </div>
)} )}
{canAddMembers && (
<FloatingActionButton
isShown={resultType === 'members'}
onClick={handleNewMemberDialogOpen}
ariaLabel={lang('lng_channel_add_users')}
>
<i className="icon-add-user-filled" />
</FloatingActionButton>
)}
{canDeleteMembers && (
<DeleteMemberModal
isOpen={Boolean(deletingUserId)}
userId={deletingUserId}
onClose={handleDeleteMembersModalClose}
/>
)}
</InfiniteScroll> </InfiniteScroll>
); );
}; };
@ -390,6 +438,8 @@ export default memo(withGlobal<OwnProps>(
const hasMembersTab = isGroup || (isChannel && isChatAdmin(chat!)); const hasMembersTab = isGroup || (isChannel && isChatAdmin(chat!));
const members = chat && chat.fullInfo && chat.fullInfo.members; const members = chat && chat.fullInfo && chat.fullInfo.members;
const areMembersHidden = hasMembersTab && chat && chat.fullInfo && !chat.fullInfo.canViewMembers; const areMembersHidden = hasMembersTab && chat && chat.fullInfo && !chat.fullInfo.canViewMembers;
const canAddMembers = hasMembersTab && chat && (getHasAdminRight(chat, 'inviteUsers') || chat.isCreator);
const canDeleteMembers = hasMembersTab && chat && (getHasAdminRight(chat, 'banUsers') || chat.isCreator);
let resolvedUserId; let resolvedUserId;
if (userId) { if (userId) {
@ -407,10 +457,13 @@ export default memo(withGlobal<OwnProps>(
mediaSearchType, mediaSearchType,
hasMembersTab, hasMembersTab,
areMembersHidden, areMembersHidden,
canAddMembers,
canDeleteMembers,
...(hasMembersTab && members && { ...(hasMembersTab && members && {
members, members,
usersById, usersById,
}), }),
currentUserId: global.currentUserId,
isRightColumnShown: selectIsRightColumnShown(global), isRightColumnShown: selectIsRightColumnShown(global),
isRestricted: chat && chat.isRestricted, isRestricted: chat && chat.isRestricted,
lastSyncTime: global.lastSyncTime, lastSyncTime: global.lastSyncTime,
@ -426,5 +479,6 @@ export default memo(withGlobal<OwnProps>(
'openUserInfo', 'openUserInfo',
'focusMessage', 'focusMessage',
'loadProfilePhotos', 'loadProfilePhotos',
'setNewChatMembersDialogState',
]), ]),
)(Profile)); )(Profile));

View File

@ -17,7 +17,7 @@
// @optimization // @optimization
&:not(:hover) { &:not(:hover) {
.chat-item-clickable:nth-child(n + 18) { .chat-item-clickable:not(.picker-list-item):nth-child(n + 18) {
display: none !important; display: none !important;
} }
} }

View File

@ -4,7 +4,9 @@ import React, {
import { withGlobal } from '../../lib/teact/teactn'; import { withGlobal } from '../../lib/teact/teactn';
import { GlobalActions } from '../../global/types'; import { GlobalActions } from '../../global/types';
import { ManagementScreens, ProfileState, RightColumnContent } from '../../types'; import {
ManagementScreens, NewChatMembersProgress, ProfileState, RightColumnContent,
} from '../../types';
import { MIN_SCREEN_WIDTH_FOR_STATIC_RIGHT_COLUMN } from '../../config'; import { MIN_SCREEN_WIDTH_FOR_STATIC_RIGHT_COLUMN } from '../../config';
import captureEscKeyListener from '../../util/captureEscKeyListener'; import captureEscKeyListener from '../../util/captureEscKeyListener';
@ -27,6 +29,7 @@ import Management from './management/Management.async';
import StickerSearch from './StickerSearch.async'; import StickerSearch from './StickerSearch.async';
import GifSearch from './GifSearch.async'; import GifSearch from './GifSearch.async';
import PollResults from './PollResults.async'; import PollResults from './PollResults.async';
import AddChatMembers from './AddChatMembers';
import './RightColumn.scss'; import './RightColumn.scss';
@ -40,8 +43,8 @@ type StateProps = {
}; };
type DispatchProps = Pick<GlobalActions, ( type DispatchProps = Pick<GlobalActions, (
'toggleChatInfo' | 'toggleManagement' | 'openUserInfo' | 'toggleChatInfo' | 'toggleManagement' | 'openUserInfo' | 'setNewChatMembersDialogState' |
'closeLocalTextSearch' | 'closePollResults' | 'closeLocalTextSearch' | 'closePollResults' | 'addChatMembers' |
'setStickerSearchQuery' | 'setGifSearchQuery' 'setStickerSearchQuery' | 'setGifSearchQuery'
)>; )>;
@ -69,6 +72,8 @@ const RightColumn: FC<StateProps & DispatchProps> = ({
setStickerSearchQuery, setStickerSearchQuery,
setGifSearchQuery, setGifSearchQuery,
closePollResults, closePollResults,
addChatMembers,
setNewChatMembersDialogState,
shouldSkipHistoryAnimations, shouldSkipHistoryAnimations,
}) => { }) => {
const { width: windowWidth } = useWindowSize(); const { width: windowWidth } = useWindowSize();
@ -85,6 +90,7 @@ const RightColumn: FC<StateProps & DispatchProps> = ({
const isStickerSearch = contentKey === RightColumnContent.StickerSearch; const isStickerSearch = contentKey === RightColumnContent.StickerSearch;
const isGifSearch = contentKey === RightColumnContent.GifSearch; const isGifSearch = contentKey === RightColumnContent.GifSearch;
const isPollResults = contentKey === RightColumnContent.PollResults; const isPollResults = contentKey === RightColumnContent.PollResults;
const isAddingChatMembers = contentKey === RightColumnContent.AddingMembers;
const isOverlaying = windowWidth <= MIN_SCREEN_WIDTH_FOR_STATIC_RIGHT_COLUMN; const isOverlaying = windowWidth <= MIN_SCREEN_WIDTH_FOR_STATIC_RIGHT_COLUMN;
const [shouldSkipTransition, setShouldSkipTransition] = useState(!isOpen); const [shouldSkipTransition, setShouldSkipTransition] = useState(!isOpen);
@ -93,6 +99,9 @@ const RightColumn: FC<StateProps & DispatchProps> = ({
const close = useCallback((shouldScrollUp = true) => { const close = useCallback((shouldScrollUp = true) => {
switch (contentKey) { switch (contentKey) {
case RightColumnContent.AddingMembers:
setNewChatMembersDialogState(NewChatMembersProgress.Closed);
break;
case RightColumnContent.ChatInfo: case RightColumnContent.ChatInfo:
if (isScrolledDown && shouldScrollUp) { if (isScrolledDown && shouldScrollUp) {
setProfileState(ProfileState.Profile); setProfileState(ProfileState.Profile);
@ -155,7 +164,7 @@ const RightColumn: FC<StateProps & DispatchProps> = ({
break; break;
} }
}, [ }, [
contentKey, isScrolledDown, toggleChatInfo, openUserInfo, closePollResults, contentKey, isScrolledDown, toggleChatInfo, openUserInfo, closePollResults, setNewChatMembersDialogState,
managementScreen, toggleManagement, closeLocalTextSearch, setStickerSearchQuery, setGifSearchQuery, managementScreen, toggleManagement, closeLocalTextSearch, setStickerSearchQuery, setGifSearchQuery,
]); ]);
@ -164,6 +173,10 @@ const RightColumn: FC<StateProps & DispatchProps> = ({
setIsPromotedByCurrentUser(isPromoted); setIsPromotedByCurrentUser(isPromoted);
}, []); }, []);
const handleAppendingChatMembers = useCallback((memberIds: number[]) => {
addChatMembers({ chatId, memberIds });
}, [addChatMembers, chatId]);
useEffect(() => (isOpen ? captureEscKeyListener(close) : undefined), [isOpen, close]); useEffect(() => (isOpen ? captureEscKeyListener(close) : undefined), [isOpen, close]);
useEffect(() => { useEffect(() => {
@ -194,7 +207,8 @@ const RightColumn: FC<StateProps & DispatchProps> = ({
useHistoryBack(isChatSelected && (contentKey === RightColumnContent.ChatInfo useHistoryBack(isChatSelected && (contentKey === RightColumnContent.ChatInfo
|| contentKey === RightColumnContent.UserInfo || contentKey === RightColumnContent.Management), || contentKey === RightColumnContent.UserInfo || contentKey === RightColumnContent.Management
|| contentKey === RightColumnContent.AddingMembers),
() => close(false), toggleChatInfo); () => close(false), toggleChatInfo);
// eslint-disable-next-line consistent-return // eslint-disable-next-line consistent-return
@ -204,6 +218,15 @@ const RightColumn: FC<StateProps & DispatchProps> = ({
} }
switch (renderingContentKey) { switch (renderingContentKey) {
case RightColumnContent.AddingMembers:
return (
<AddChatMembers
chatId={chatId!}
onNextStep={handleAppendingChatMembers}
isActive={isOpen && isActive}
onClose={close}
/>
);
case RightColumnContent.ChatInfo: case RightColumnContent.ChatInfo:
case RightColumnContent.UserInfo: case RightColumnContent.UserInfo:
return ( return (
@ -258,6 +281,7 @@ const RightColumn: FC<StateProps & DispatchProps> = ({
isStickerSearch={isStickerSearch} isStickerSearch={isStickerSearch}
isGifSearch={isGifSearch} isGifSearch={isGifSearch}
isPollResults={isPollResults} isPollResults={isPollResults}
isAddingChatMembers={isAddingChatMembers}
profileState={profileState} profileState={profileState}
managementScreen={managementScreen} managementScreen={managementScreen}
onClose={close} onClose={close}
@ -299,5 +323,7 @@ export default memo(withGlobal(
'setStickerSearchQuery', 'setStickerSearchQuery',
'setGifSearchQuery', 'setGifSearchQuery',
'closePollResults', 'closePollResults',
'addChatMembers',
'setNewChatMembersDialogState',
]), ]),
)(RightColumn)); )(RightColumn));

View File

@ -36,6 +36,7 @@ type OwnProps = {
isStickerSearch?: boolean; isStickerSearch?: boolean;
isGifSearch?: boolean; isGifSearch?: boolean;
isPollResults?: boolean; isPollResults?: boolean;
isAddingChatMembers?: boolean;
shouldSkipAnimation?: boolean; shouldSkipAnimation?: boolean;
profileState?: ProfileState; profileState?: ProfileState;
managementScreen?: ManagementScreens; managementScreen?: ManagementScreens;
@ -79,6 +80,7 @@ enum HeaderContent {
StickerSearch, StickerSearch,
GifSearch, GifSearch,
PollResults, PollResults,
AddingMembers,
} }
const RightHeader: FC<OwnProps & StateProps & DispatchProps> = ({ const RightHeader: FC<OwnProps & StateProps & DispatchProps> = ({
@ -89,6 +91,7 @@ const RightHeader: FC<OwnProps & StateProps & DispatchProps> = ({
isStickerSearch, isStickerSearch,
isGifSearch, isGifSearch,
isPollResults, isPollResults,
isAddingChatMembers,
profileState, profileState,
managementScreen, managementScreen,
canManage, canManage,
@ -149,6 +152,8 @@ const RightHeader: FC<OwnProps & StateProps & DispatchProps> = ({
HeaderContent.StickerSearch HeaderContent.StickerSearch
) : isGifSearch ? ( ) : isGifSearch ? (
HeaderContent.GifSearch HeaderContent.GifSearch
) : isAddingChatMembers ? (
HeaderContent.AddingMembers
) : isManagement ? ( ) : isManagement ? (
managementScreen === ManagementScreens.Initial ? ( managementScreen === ManagementScreens.Initial ? (
HeaderContent.ManageInitial HeaderContent.ManageInitial
@ -206,6 +211,8 @@ const RightHeader: FC<OwnProps & StateProps & DispatchProps> = ({
</Button> </Button>
</> </>
); );
case HeaderContent.AddingMembers:
return <h3>{lang('GroupAddMembers')}</h3>;
case HeaderContent.ManageInitial: case HeaderContent.ManageInitial:
return <h3>{lang('Edit')}</h3>; return <h3>{lang('Edit')}</h3>;
case HeaderContent.ManageChatPrivacyType: case HeaderContent.ManageChatPrivacyType:
@ -275,6 +282,7 @@ const RightHeader: FC<OwnProps & StateProps & DispatchProps> = ({
IS_SINGLE_COLUMN_LAYOUT IS_SINGLE_COLUMN_LAYOUT
|| contentKey === HeaderContent.SharedMedia || contentKey === HeaderContent.SharedMedia
|| contentKey === HeaderContent.MemberList || contentKey === HeaderContent.MemberList
|| contentKey === HeaderContent.AddingMembers
|| isManagement || isManagement
); );

View File

@ -320,14 +320,15 @@ export default memo(withGlobal<OwnProps>(
const chat = selectChat(global, chatId)!; const chat = selectChat(global, chatId)!;
const { progress } = global.management; const { progress } = global.management;
const hasLinkedChannel = Boolean(chat.fullInfo && chat.fullInfo.linkedChatId); const hasLinkedChannel = Boolean(chat.fullInfo && chat.fullInfo.linkedChatId);
const isBasicGroup = isChatBasicGroup(chat);
return { return {
chat, chat,
progress, progress,
isBasicGroup: isChatBasicGroup(chat), isBasicGroup,
hasLinkedChannel, hasLinkedChannel,
canChangeInfo: getHasAdminRight(chat, 'changeInfo'), canChangeInfo: isBasicGroup ? chat.isCreator : getHasAdminRight(chat, 'changeInfo'),
canBanUsers: getHasAdminRight(chat, 'banUsers'), canBanUsers: isBasicGroup ? chat.isCreator : getHasAdminRight(chat, 'banUsers'),
}; };
}, },
(setGlobal, actions): DispatchProps => pick(actions, [ (setGlobal, actions): DispatchProps => pick(actions, [

View File

@ -147,5 +147,6 @@
margin-left: auto; margin-left: auto;
text-align: right; text-align: right;
font-weight: 500; font-weight: 500;
white-space: pre-wrap;
} }
} }

View File

@ -1,4 +1,5 @@
import { GlobalState } from './types'; import { GlobalState } from './types';
import { NewChatMembersProgress } from '../types';
import { import {
ANIMATION_LEVEL_DEFAULT, DARK_THEME_PATTERN_COLOR, DEFAULT_MESSAGE_TEXT_SIZE_PX, DEFAULT_PATTERN_COLOR, ANIMATION_LEVEL_DEFAULT, DARK_THEME_PATTERN_COLOR, DEFAULT_MESSAGE_TEXT_SIZE_PX, DEFAULT_PATTERN_COLOR,
@ -7,6 +8,7 @@ import {
export const INITIAL_STATE: GlobalState = { export const INITIAL_STATE: GlobalState = {
isLeftColumnShown: true, isLeftColumnShown: true,
isChatInfoShown: false, isChatInfoShown: false,
newChatMembersProgress: NewChatMembersProgress.Closed,
uiReadyState: 0, uiReadyState: 0,
serverTimeOffset: 0, serverTimeOffset: 0,
@ -73,6 +75,8 @@ export const INITIAL_STATE: GlobalState = {
globalSearch: {}, globalSearch: {},
userSearch: {},
localTextSearch: { localTextSearch: {
byChatThreadKey: {}, byChatThreadKey: {},
}, },

View File

@ -40,6 +40,7 @@ import {
NotifyException, NotifyException,
LangCode, LangCode,
EmojiKeywords, EmojiKeywords,
NewChatMembersProgress,
} from '../types'; } from '../types';
export type MessageListType = 'thread' | 'pinned' | 'scheduled'; export type MessageListType = 'thread' | 'pinned' | 'scheduled';
@ -65,6 +66,7 @@ export type GlobalState = {
isChatInfoShown: boolean; isChatInfoShown: boolean;
isLeftColumnShown: boolean; isLeftColumnShown: boolean;
isPollModalOpen?: boolean; isPollModalOpen?: boolean;
newChatMembersProgress?: NewChatMembersProgress;
uiReadyState: 0 | 1 | 2; uiReadyState: 0 | 1 | 2;
shouldSkipHistoryAnimations?: boolean; shouldSkipHistoryAnimations?: boolean;
connectionState?: ApiUpdateConnectionStateType; connectionState?: ApiUpdateConnectionStateType;
@ -248,6 +250,13 @@ export type GlobalState = {
}>>; }>>;
}; };
userSearch: {
query?: string;
fetchingStatus?: boolean;
localUserIds?: number[];
globalUserIds?: number[];
};
localTextSearch: { localTextSearch: {
byChatThreadKey: Record<string, { byChatThreadKey: Record<string, {
isActive: boolean; isActive: boolean;
@ -405,8 +414,8 @@ export type ActionTypes = (
'showNotification' | 'dismissNotification' | 'showDialog' | 'dismissDialog' | 'showNotification' | 'dismissNotification' | 'showDialog' | 'dismissDialog' |
// ui // ui
'toggleChatInfo' | 'setIsUiReady' | 'addRecentEmoji' | 'addRecentSticker' | 'toggleLeftColumn' | 'toggleChatInfo' | 'setIsUiReady' | 'addRecentEmoji' | 'addRecentSticker' | 'toggleLeftColumn' |
'toggleSafeLinkModal' | 'disableHistoryAnimations' | 'openHistoryCalendar' | 'closeHistoryCalendar' | 'toggleSafeLinkModal' | 'openHistoryCalendar' | 'closeHistoryCalendar' | 'disableContextMenuHint' |
'disableContextMenuHint' | 'setNewChatMembersDialogState' | 'disableHistoryAnimations' |
// auth // auth
'setAuthPhoneNumber' | 'setAuthCode' | 'setAuthPassword' | 'signUp' | 'returnToAuthPhoneNumber' | 'signOut' | 'setAuthPhoneNumber' | 'setAuthCode' | 'setAuthPassword' | 'signUp' | 'returnToAuthPhoneNumber' | 'signOut' |
'setAuthRememberMe' | 'clearAuthError' | 'uploadProfilePhoto' | 'goToAuthQrCode' | 'clearCache' | 'setAuthRememberMe' | 'clearAuthError' | 'uploadProfilePhoto' | 'goToAuthQrCode' | 'clearCache' |
@ -418,6 +427,7 @@ export type ActionTypes = (
'loadChatFolders' | 'loadRecommendedChatFolders' | 'editChatFolder' | 'addChatFolder' | 'deleteChatFolder' | 'loadChatFolders' | 'loadRecommendedChatFolders' | 'editChatFolder' | 'addChatFolder' | 'deleteChatFolder' |
'updateChat' | 'toggleSignatures' | 'loadGroupsForDiscussion' | 'linkDiscussionGroup' | 'unlinkDiscussionGroup' | 'updateChat' | 'toggleSignatures' | 'loadGroupsForDiscussion' | 'linkDiscussionGroup' | 'unlinkDiscussionGroup' |
'loadProfilePhotos' | 'loadMoreMembers' | 'setActiveChatFolder' | 'openNextChat' | 'loadProfilePhotos' | 'loadMoreMembers' | 'setActiveChatFolder' | 'openNextChat' |
'addChatMembers' | 'deleteChatMember' |
// messages // messages
'loadViewportMessages' | 'selectMessage' | 'sendMessage' | 'cancelSendingMessage' | 'pinMessage' | 'deleteMessages' | 'loadViewportMessages' | 'selectMessage' | 'sendMessage' | 'cancelSendingMessage' | 'pinMessage' | 'deleteMessages' |
'markMessageListRead' | 'markMessagesRead' | 'loadMessage' | 'focusMessage' | 'focusLastMessage' | 'sendPollVote' | 'markMessageListRead' | 'markMessagesRead' | 'loadMessage' | 'focusMessage' | 'focusLastMessage' | 'sendPollVote' |
@ -446,7 +456,7 @@ export type ActionTypes = (
'acceptInviteConfirmation' | 'acceptInviteConfirmation' |
// users // users
'loadFullUser' | 'openUserInfo' | 'loadNearestCountry' | 'loadTopUsers' | 'loadContactList' | 'loadCurrentUser' | 'loadFullUser' | 'openUserInfo' | 'loadNearestCountry' | 'loadTopUsers' | 'loadContactList' | 'loadCurrentUser' |
'updateProfile' | 'checkUsername' | 'updateContact' | 'deleteUser' | 'loadUser' | 'updateProfile' | 'checkUsername' | 'updateContact' | 'deleteUser' | 'loadUser' | 'setUserSearchQuery' |
// Channel / groups creation // Channel / groups creation
'createChannel' | 'createGroupChat' | 'resetChatCreation' | 'createChannel' | 'createGroupChat' | 'resetChatCreation' |
// settings // settings

View File

@ -1,6 +1,8 @@
import { useState, useEffect } from '../lib/teact/teact'; import { useState, useEffect } from '../lib/teact/teact';
import { IAnchorPosition } from '../types'; import { IAnchorPosition } from '../types';
const MENU_POSITION_VISUAL_COMFORT_SPACE_PX = 16;
export default ( export default (
anchor: IAnchorPosition | undefined, anchor: IAnchorPosition | undefined,
getTriggerElement: () => HTMLElement | null, getTriggerElement: () => HTMLElement | null,
@ -31,16 +33,18 @@ export default (
const menuRect = menuEl ? { width: menuEl.offsetWidth, height: menuEl.offsetHeight } : emptyRect; const menuRect = menuEl ? { width: menuEl.offsetWidth, height: menuEl.offsetHeight } : emptyRect;
const rootRect = rootEl ? rootEl.getBoundingClientRect() : emptyRect; const rootRect = rootEl ? rootEl.getBoundingClientRect() : emptyRect;
let horizontalPostition: 'left' | 'right';
if (x + menuRect.width + extraPaddingX < rootRect.width + rootRect.left) { if (x + menuRect.width + extraPaddingX < rootRect.width + rootRect.left) {
setPositionX('left');
x += 3; x += 3;
horizontalPostition = 'left';
} else if (x - menuRect.width > 0) { } else if (x - menuRect.width > 0) {
setPositionX('right'); horizontalPostition = 'right';
x -= 3; x -= 3;
} else { } else {
setPositionX('left'); horizontalPostition = 'left';
x = 16; x = 16;
} }
setPositionX(horizontalPostition);
if (y + menuRect.height < rootRect.height + rootRect.top) { if (y + menuRect.height < rootRect.height + rootRect.top) {
setPositionY('top'); setPositionY('top');
@ -52,7 +56,11 @@ export default (
} }
} }
setStyle(`left: ${x - triggerRect.left}px; top: ${y - triggerRect.top}px;`); const left = horizontalPostition === 'left'
? Math.min(x - triggerRect.left, rootRect.width - menuRect.width - MENU_POSITION_VISUAL_COMFORT_SPACE_PX)
: Math.max((x - triggerRect.left), menuRect.width + MENU_POSITION_VISUAL_COMFORT_SPACE_PX);
setStyle(`left: ${left}px; top: ${y - triggerRect.top}px;`);
}, [ }, [
anchor, extraPaddingX, extraTopPadding, anchor, extraPaddingX, extraTopPadding,
getMenuElement, getRootElement, getTriggerElement, getMenuElement, getRootElement, getTriggerElement,

View File

@ -987,6 +987,7 @@ messages.getChats#3c6aa187 id:Vector<int> = messages.Chats;
messages.getFullChat#3b831c66 chat_id:int = messages.ChatFull; messages.getFullChat#3b831c66 chat_id:int = messages.ChatFull;
messages.editChatTitle#dc452855 chat_id:int title:string = Updates; messages.editChatTitle#dc452855 chat_id:int title:string = Updates;
messages.editChatPhoto#ca4c79d8 chat_id:int photo:InputChatPhoto = Updates; messages.editChatPhoto#ca4c79d8 chat_id:int photo:InputChatPhoto = Updates;
messages.addChatUser#f9a0aa09 chat_id:int user_id:InputUser fwd_limit:int = Updates;
messages.deleteChatUser#c534459a flags:# revoke_history:flags.0?true chat_id:int user_id:InputUser = Updates; messages.deleteChatUser#c534459a flags:# revoke_history:flags.0?true chat_id:int user_id:InputUser = Updates;
messages.createChat#9cb126e users:Vector<InputUser> title:string = Updates; messages.createChat#9cb126e users:Vector<InputUser> title:string = Updates;
messages.getDhConfig#26cf8950 version:int random_length:int = messages.DhConfig; messages.getDhConfig#26cf8950 version:int random_length:int = messages.DhConfig;

View File

@ -987,6 +987,7 @@ messages.getChats#3c6aa187 id:Vector<int> = messages.Chats;
messages.getFullChat#3b831c66 chat_id:int = messages.ChatFull; messages.getFullChat#3b831c66 chat_id:int = messages.ChatFull;
messages.editChatTitle#dc452855 chat_id:int title:string = Updates; messages.editChatTitle#dc452855 chat_id:int title:string = Updates;
messages.editChatPhoto#ca4c79d8 chat_id:int photo:InputChatPhoto = Updates; messages.editChatPhoto#ca4c79d8 chat_id:int photo:InputChatPhoto = Updates;
messages.addChatUser#f9a0aa09 chat_id:int user_id:InputUser fwd_limit:int = Updates;
messages.deleteChatUser#c534459a flags:# revoke_history:flags.0?true chat_id:int user_id:InputUser = Updates; messages.deleteChatUser#c534459a flags:# revoke_history:flags.0?true chat_id:int user_id:InputUser = Updates;
messages.createChat#9cb126e users:Vector<InputUser> title:string = Updates; messages.createChat#9cb126e users:Vector<InputUser> title:string = Updates;
messages.getDhConfig#26cf8950 version:int random_length:int = messages.DhConfig; messages.getDhConfig#26cf8950 version:int random_length:int = messages.DhConfig;

View File

@ -5,7 +5,7 @@ import {
import { import {
ApiChat, ApiUser, ApiChatFolder, MAIN_THREAD_ID, ApiChat, ApiUser, ApiChatFolder, MAIN_THREAD_ID,
} from '../../../api/types'; } from '../../../api/types';
import { ChatCreationProgress, ManagementProgress } from '../../../types'; import { NewChatMembersProgress, ChatCreationProgress, ManagementProgress } from '../../../types';
import { GlobalActions } from '../../../global/types'; import { GlobalActions } from '../../../global/types';
import { import {
@ -774,6 +774,38 @@ addReducer('loadMoreMembers', (global) => {
})(); })();
}); });
addReducer('addChatMembers', (global, actions, payload) => {
const { chatId, memberIds } = payload;
const chat = selectChat(global, chatId);
const users = (memberIds as number[]).map((userId) => selectUser(global, userId)).filter<ApiUser>(Boolean as any);
if (!chat || !users.length) {
return;
}
actions.setNewChatMembersDialogState(NewChatMembersProgress.Loading);
(async () => {
await callApi('addChatMembers', chat, users);
actions.setNewChatMembersDialogState(NewChatMembersProgress.Closed);
loadFullChat(chat);
})();
});
addReducer('deleteChatMember', (global, actions, payload) => {
const { chatId, userId } = payload;
const chat = selectChat(global, chatId);
const user = selectUser(global, userId);
if (!chat || !user) {
return;
}
(async () => {
await callApi('deleteChatMember', chat, user);
loadFullChat(chat);
})();
});
async function loadChats(listType: 'active' | 'archived', offsetId?: number, offsetDate?: number) { async function loadChats(listType: 'active' | 'archived', offsetId?: number, offsetDate?: number) {
const result = await callApi('fetchChats', { const result = await callApi('fetchChats', {
limit: CHAT_LIST_LOAD_SLICE, limit: CHAT_LIST_LOAD_SLICE,

View File

@ -5,17 +5,19 @@ import {
import { ApiUser } from '../../../api/types'; import { ApiUser } from '../../../api/types';
import { ManagementProgress } from '../../../types'; import { ManagementProgress } from '../../../types';
import { debounce } from '../../../util/schedulers'; import { debounce, throttle } from '../../../util/schedulers';
import { buildCollectionByKey } from '../../../util/iteratees'; import { buildCollectionByKey } from '../../../util/iteratees';
import { isChatPrivate } from '../../helpers'; import { isChatPrivate } from '../../helpers';
import { callApi } from '../../../api/gramjs'; import { callApi } from '../../../api/gramjs';
import { selectChat, selectUser } from '../../selectors'; import { selectChat, selectUser } from '../../selectors';
import { import {
addChats, addUsers, updateChat, updateManagementProgress, updateUser, updateUsers, addChats, addUsers, updateChat, updateManagementProgress, updateUser, updateUsers,
updateUserSearch, updateUserSearchFetchingStatus,
} from '../../reducers'; } from '../../reducers';
const runDebouncedForFetchFullUser = debounce((cb) => cb(), 500, false, true); const runDebouncedForFetchFullUser = debounce((cb) => cb(), 500, false, true);
const TOP_PEERS_REQUEST_COOLDOWN = 60; // 1 min const TOP_PEERS_REQUEST_COOLDOWN = 60; // 1 min
const runThrottledForSearch = throttle((cb) => cb(), 500, false);
addReducer('loadFullUser', (global, actions, payload) => { addReducer('loadFullUser', (global, actions, payload) => {
const { userId } = payload!; const { userId } = payload!;
@ -200,3 +202,44 @@ addReducer('loadProfilePhotos', (global, actions, payload) => {
setGlobal(newGlobal); setGlobal(newGlobal);
})(); })();
}); });
addReducer('setUserSearchQuery', (global, actions, payload) => {
const { query } = payload!;
if (!query) return;
void runThrottledForSearch(() => {
searchUsers(query);
});
});
async function searchUsers(query: string) {
const result = await callApi('searchChats', { query });
let global = getGlobal();
const currentSearchQuery = global.userSearch.query;
if (!result || !currentSearchQuery || (query !== currentSearchQuery)) {
setGlobal(updateUserSearchFetchingStatus(global, false));
return;
}
const { localUsers, globalUsers } = result;
let localUserIds;
let globalUserIds;
if (localUsers.length) {
global = addUsers(global, buildCollectionByKey(localUsers, 'id'));
localUserIds = localUsers.map(({ id }) => id);
}
if (globalUsers.length) {
global = addUsers(global, buildCollectionByKey(globalUsers, 'id'));
globalUserIds = globalUsers.map(({ id }) => id);
}
global = updateUserSearchFetchingStatus(global, false);
global = updateUserSearch(global, { localUserIds, globalUserIds });
setGlobal(global);
}

View File

@ -1,4 +1,5 @@
import { addReducer, setGlobal } from '../../../lib/teact/teactn'; import { addReducer, setGlobal } from '../../../lib/teact/teactn';
import { import {
exitMessageSelectMode, replaceThreadParam, updateCurrentMessageList, exitMessageSelectMode, replaceThreadParam, updateCurrentMessageList,
} from '../../reducers'; } from '../../reducers';
@ -55,6 +56,13 @@ addReducer('resetChatCreation', (global) => {
}; };
}); });
addReducer('setNewChatMembersDialogState', (global, actions, payload) => {
return {
...global,
newChatMembersProgress: payload,
};
});
addReducer('openNextChat', (global, actions, payload) => { addReducer('openNextChat', (global, actions, payload) => {
const { targetIndexDelta, orderedIds } = payload; const { targetIndexDelta, orderedIds } = payload;

View File

@ -2,7 +2,7 @@ import { addReducer } from '../../../lib/teact/teactn';
import { GlobalState } from '../../../global/types'; import { GlobalState } from '../../../global/types';
import { updateSelectedUserId } from '../../reducers'; import { updateSelectedUserId, updateUserSearch } from '../../reducers';
addReducer('openUserInfo', (global, actions, payload) => { addReducer('openUserInfo', (global, actions, payload) => {
const { id } = payload!; const { id } = payload!;
@ -13,3 +13,14 @@ addReducer('openUserInfo', (global, actions, payload) => {
const clearSelectedUserId = (global: GlobalState) => updateSelectedUserId(global, undefined); const clearSelectedUserId = (global: GlobalState) => updateSelectedUserId(global, undefined);
addReducer('openChat', clearSelectedUserId); addReducer('openChat', clearSelectedUserId);
addReducer('setUserSearchQuery', (global, actions, payload) => {
const { query } = payload!;
return updateUserSearch(global, {
globalUserIds: undefined,
localUserIds: undefined,
fetchingStatus: Boolean(query),
query,
});
});

View File

@ -148,3 +148,24 @@ export function deleteUser(global: GlobalState, userId: number): GlobalState {
return replaceUsers(global, byId); return replaceUsers(global, byId);
} }
export function updateUserSearch(
global: GlobalState,
searchStatePartial: Partial<GlobalState['userSearch']>,
) {
return {
...global,
userSearch: {
...global.userSearch,
...searchStatePartial,
},
};
}
export function updateUserSearchFetchingStatus(
global: GlobalState, newState: boolean,
) {
return updateUserSearch(global, {
fetchingStatus: newState,
});
}

View File

@ -1,5 +1,5 @@
import { GlobalState } from '../../global/types'; import { GlobalState } from '../../global/types';
import { RightColumnContent } from '../../types'; import { NewChatMembersProgress, RightColumnContent } from '../../types';
import { getSystemTheme, IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment'; import { getSystemTheme, IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment';
import { selectCurrentMessageList, selectIsPollResultsOpen } from './messages'; import { selectCurrentMessageList, selectIsPollResultsOpen } from './messages';
@ -17,8 +17,10 @@ export function selectRightColumnContentKey(global: GlobalState) {
const { const {
users, users,
isChatInfoShown, isChatInfoShown,
newChatMembersProgress,
} = global; } = global;
const isAddingChatMembersShown = newChatMembersProgress !== NewChatMembersProgress.Closed;
const isPollResults = selectIsPollResultsOpen(global); const isPollResults = selectIsPollResultsOpen(global);
const isSearch = Boolean(!IS_SINGLE_COLUMN_LAYOUT && selectCurrentTextSearch(global)); const isSearch = Boolean(!IS_SINGLE_COLUMN_LAYOUT && selectCurrentTextSearch(global));
const isManagement = selectCurrentManagement(global); const isManagement = selectCurrentManagement(global);
@ -43,6 +45,8 @@ export function selectRightColumnContentKey(global: GlobalState) {
RightColumnContent.StickerSearch RightColumnContent.StickerSearch
) : isGifSearch ? ( ) : isGifSearch ? (
RightColumnContent.GifSearch RightColumnContent.GifSearch
) : isAddingChatMembersShown ? (
RightColumnContent.AddingMembers
) : isUserInfo ? ( ) : isUserInfo ? (
RightColumnContent.UserInfo RightColumnContent.UserInfo
) : isChatInfo ? ( ) : isChatInfo ? (

View File

@ -215,6 +215,7 @@ export enum RightColumnContent {
StickerSearch, StickerSearch,
GifSearch, GifSearch,
PollResults, PollResults,
AddingMembers,
} }
export enum MediaViewerOrigin { export enum MediaViewerOrigin {
@ -249,6 +250,12 @@ export enum ManagementProgress {
Error, Error,
} }
export enum NewChatMembersProgress {
Closed,
InProgress,
Loading,
}
export type ProfileTabType = 'members' | 'media' | 'documents' | 'links' | 'audio'; export type ProfileTabType = 'members' | 'media' | 'documents' | 'links' | 'audio';
export type SharedMediaType = 'media' | 'documents' | 'links' | 'audio'; export type SharedMediaType = 'media' | 'documents' | 'links' | 'audio';
export type ApiPrivacyKey = 'phoneNumber' | 'lastSeen' | 'profilePhoto' | 'forwards' | 'chatInvite'; export type ApiPrivacyKey = 'phoneNumber' | 'lastSeen' | 'profilePhoto' | 'forwards' | 'chatInvite';

View File

@ -14,6 +14,7 @@ interface LangFn {
} }
const FALLBACK_LANG_CODE = 'en'; const FALLBACK_LANG_CODE = 'en';
const SUBSTITUTION_REGEX = /%\d?\$?[sdf@]/g;
const PLURAL_OPTIONS = ['value', 'zeroValue', 'oneValue', 'twoValue', 'fewValue', 'manyValue', 'otherValue'] as const; const PLURAL_OPTIONS = ['value', 'zeroValue', 'oneValue', 'twoValue', 'fewValue', 'manyValue', 'otherValue'] as const;
const PLURAL_RULES = { const PLURAL_RULES = {
/* eslint-disable max-len */ /* eslint-disable max-len */
@ -55,7 +56,8 @@ let currentLangCode: string | undefined;
export const getTranslation: LangFn = (key: string, value?: any, format?: 'i') => { export const getTranslation: LangFn = (key: string, value?: any, format?: 'i') => {
if (value !== undefined) { if (value !== undefined) {
const cached = cache.get(`${key}_${value}_${format}`); const cacheValue = Array.isArray(value) ? JSON.stringify(value) : value;
const cached = cache.get(`${key}_${cacheValue}_${format}`);
if (cached) { if (cached) {
return cached; return cached;
} }
@ -84,7 +86,8 @@ export const getTranslation: LangFn = (key: string, value?: any, format?: 'i') =
if (value !== undefined) { if (value !== undefined) {
const formattedValue = format === 'i' ? formatInteger(value) : value; const formattedValue = format === 'i' ? formatInteger(value) : value;
const result = processTemplate(template, formattedValue); const result = processTemplate(template, formattedValue);
cache.set(`${key}_${value}_${format}`, result); const cacheValue = Array.isArray(value) ? JSON.stringify(value) : value;
cache.set(`${key}_${cacheValue}_${format}`, result);
return result; return result;
} }
@ -158,5 +161,11 @@ function getPluralOption(amount: number) {
} }
function processTemplate(template: string, value: any) { function processTemplate(template: string, value: any) {
return template.replace(/%\d?\$?[sdf@]/, String(value)); value = Array.isArray(value) ? value : [value];
const translationSlices = template.split(SUBSTITUTION_REGEX);
const initialValue = translationSlices.shift();
return translationSlices.reduce((result, str, index) => {
return `${result}${String(value[index] || '')}${str}`;
}, initialValue || '');
} }

View File

@ -1,5 +1,5 @@
import { callApi } from '../api/gramjs'; import { callApi } from '../api/gramjs';
import { ApiChat, ApiMessage } from '../api/types'; import { ApiChat, ApiMessage, ApiUser } from '../api/types';
import { renderActionMessageText } from '../components/common/helpers/renderActionMessageText'; import { renderActionMessageText } from '../components/common/helpers/renderActionMessageText';
import { DEBUG } from '../config'; import { DEBUG } from '../config';
import { getDispatch, getGlobal, setGlobal } from '../lib/teact/teactn'; import { getDispatch, getGlobal, setGlobal } from '../lib/teact/teactn';
@ -221,10 +221,13 @@ function getNotificationContent(chat: ApiChat, message: ApiMessage) {
? selectChatMessage(global, chat.id, replyToMessageId) ? selectChatMessage(global, chat.id, replyToMessageId)
: undefined; : undefined;
const { const {
targetUserId: actionTargetUserId, targetUserIds: actionTargetUserIds,
targetChatId: actionTargetChatId, targetChatId: actionTargetChatId,
} = messageAction || {}; } = messageAction || {};
const actionTargetUser = actionTargetUserId ? selectUser(global, actionTargetUserId) : undefined;
const actionTargetUsers = actionTargetUserIds
? actionTargetUserIds.map((userId) => selectUser(global, userId)).filter<ApiUser>(Boolean as any)
: undefined;
const privateChatUserId = getPrivateChatUserId(chat); const privateChatUserId = getPrivateChatUserId(chat);
const privateChatUser = privateChatUserId ? selectUser(global, privateChatUserId) : undefined; const privateChatUser = privateChatUserId ? selectUser(global, privateChatUserId) : undefined;
let body: string; let body: string;
@ -236,7 +239,7 @@ function getNotificationContent(chat: ApiChat, message: ApiMessage) {
getTranslation, getTranslation,
message, message,
actionOrigin, actionOrigin,
actionTargetUser, actionTargetUsers,
actionTargetMessage, actionTargetMessage,
actionTargetChatId, actionTargetChatId,
{ asPlain: true }, { asPlain: true },