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 = '';
const translationValues = [];
let type: ApiAction['type'] = 'other';
let photo: ApiPhoto | undefined;
const targetUserId = 'users' in action
// Api returns array of userIds, but no action currently has multiple users in it
? action.users && action.users[0]
: ('userId' in action && action.userId) || undefined;
const targetUserIds = 'users' in action
? action.users && action.users
: ('userId' in action && [action.userId]) || [];
let targetChatId: number | undefined;
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) {
text = isChannelPost
? `Channel renamed to «${action.title}»`
: `%action_origin% changed group name to «${action.title}»`;
if (isChannelPost) {
text = 'Channel.MessageTitleUpdated';
translationValues.push(action.title);
} else {
text = 'Notification.ChangedGroupName';
translationValues.push('%action_origin%', action.title);
}
} else if (action instanceof GramJs.MessageActionChatEditPhoto) {
text = isChannelPost
? 'Channel photo updated'
: '%action_origin% updated group photo';
if (isChannelPost) {
text = 'Channel.MessagePhotoUpdated';
} else {
text = 'Notification.ChangedGroupPhoto';
translationValues.push('%action_origin%');
}
} else if (action instanceof GramJs.MessageActionChatDeletePhoto) {
text = isChannelPost
? 'Channel photo was deleted'
: 'Chat photo was deleted';
if (isChannelPost) {
text = 'Channel.MessagePhotoRemoved';
} else {
text = 'Group.MessagePhotoRemoved';
}
} else if (action instanceof GramJs.MessageActionChatAddUser) {
text = !senderId || senderId === targetUserId
? '%target_user% joined the group'
: '%action_origin% added %target_user% to the group';
if (!senderId || targetUserIds.includes(senderId)) {
text = 'Notification.JoinedChat';
translationValues.push('%target_user%');
} else {
text = 'Notification.Invited';
translationValues.push('%action_origin%', '%target_user%');
}
} else if (action instanceof GramJs.MessageActionChatDeleteUser) {
text = !senderId || senderId === targetUserId
? '%target_user% left the group'
: '%action_origin% removed %target_user% from the group';
if (!senderId || targetUserIds.includes(senderId)) {
text = 'Notification.LeftChat';
translationValues.push('%target_user%');
} else {
text = 'Notification.Kicked';
translationValues.push('%action_origin%', '%target_user%');
}
} 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) {
text = 'Channel created';
text = 'Notification.CreatedChannel';
} else if (action instanceof GramJs.MessageActionChatMigrateTo) {
targetChatId = getApiChatIdFromMtpPeer(action);
text = 'Migrated to %target_chat%';
targetChatId = getApiChatIdFromMtpPeer(action);
translationValues.push('%target_chat%');
} else if (action instanceof GramJs.MessageActionChannelMigrateFrom) {
text = 'Migrated from %target_chat%';
targetChatId = getApiChatIdFromMtpPeer(action);
text = 'Migrated from %target_chat%';
translationValues.push('%target_chat%');
} 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) {
text = 'Chat history was cleared';
text = 'HistoryCleared';
type = 'historyClear';
} 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) {
const mins = Math.max(Math.round(action.duration / 60), 1);
text += ` (${mins} min${mins > 1 ? 's' : ''})`;
if (withDuration) {
const mins = Math.max(Math.round(action.duration! / 60), 1);
translationValues.push(`${mins} min${mins > 1 ? 's' : ''}`);
}
} else if (action instanceof GramJs.MessageActionContactSignUp) {
text = '%action_origin% joined Telegram';
text = 'Notification.Joined';
translationValues.push('%action_origin%');
} else if (action instanceof GramJs.MessageActionPaymentSent) {
const currencySign = getCurrencySign(action.currency);
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) {
if (action.duration) {
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 {
text = 'Voice chat started';
text = 'Notification.VoiceChatStartedChannel';
}
} else {
text = '%ACTION_NOT_IMPLEMENTED%';
text = 'ChatList.UnsupportedMessage';
}
if ('photo' in action && action.photo instanceof GramJs.Photo) {
@ -651,9 +681,10 @@ function buildAction(
return {
text,
type,
targetUserId,
targetUserIds,
targetChatId,
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
? String(mtpUser.photo.photoId)
: undefined;
const userType = buildApiUserType(mtpUser);
return {
id,
@ -33,8 +34,9 @@ export function buildApiUser(mtpUser: GramJs.TypeUser): ApiUser | undefined {
...(mtpUser.self && { isSelf: true }),
...(mtpUser.verified && { isVerified: true }),
...((mtpUser.contact || mtpUser.mutualContact) && { isContact: true }),
type: buildApiUserType(mtpUser),
type: userType,
...(firstName && { firstName }),
...(userType === 'userTypeBot' && { canBeInvitedToGroup: !mtpUser.botNochats }),
...(lastName && { lastName }),
username: mtpUser.username || '',
phoneNumber: mtpUser.phone || '',

View File

@ -172,7 +172,7 @@ export async function searchChats({ query }: { query: string }) {
updateLocalDb(result);
const localPeerIds = result.myResults.map(getApiChatIdFromMtpPeer);
const allChats = [...result.chats, ...result.users]
const allChats = result.chats.concat(result.users)
.map((user) => buildApiChatFromPreview(user))
.filter<ApiChat>(Boolean as any);
const allUsers = result.users.map(buildApiUser).filter((user) => !!user && !user.isSelf) as ApiUser[];
@ -756,15 +756,15 @@ export function updateChatDefaultBannedRights({
}
export function updateChatMemberBannedRights({
chat, user, bannedRights,
}: { chat: ApiChat; user: ApiUser; bannedRights: ApiChatBannedRights }) {
chat, user, bannedRights, untilDate,
}: { chat: ApiChat; user: ApiUser; bannedRights: ApiChatBannedRights; untilDate?: number }) {
const channel = buildInputEntity(chat.id, chat.accessHash) as GramJs.InputChannel;
const participant = buildInputPeer(user.id, user.accessHash) as GramJs.InputUser;
return invokeRequest(new GramJs.channels.EditBanned({
channel,
participant,
bannedRights: buildChatBannedRights(bannedRights),
bannedRights: buildChatBannedRights(bannedRights, untilDate),
}), true);
}
@ -957,6 +957,51 @@ export async function openChatByInvite(hash: string) {
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(
result: GramJs.messages.Dialogs | GramJs.messages.DialogsSlice | GramJs.messages.PeerDialogs,
) {

View File

@ -14,7 +14,7 @@ export {
fetchChatFolders, editChatFolder, deleteChatFolder, fetchRecommendedChatFolders,
getChatByUsername, togglePreHistoryHidden, updateChatDefaultBannedRights, updateChatMemberBannedRights,
updateChatTitle, updateChatAbout, toggleSignatures, updateChatAdmin, fetchGroupsForDiscussion, setDiscussionGroup,
migrateChat, openChatByInvite, fetchMembers, importChatInvite,
migrateChat, openChatByInvite, fetchMembers, importChatInvite, addChatMembers, deleteChatMember,
} from './chats';
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 => (
e instanceof GramJs.User && !!e.self && e.id === action.userId
))) {
onUpdate({
'@type': 'updateChat',
id: message.chatId,
chat: {
isRestricted: true,
},
});
onUpdate({
'@type': 'updateChatLeave',
id: message.chatId,

View File

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

View File

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

View File

@ -165,7 +165,7 @@ const DeleteChatModal: FC<OwnProps & StateProps & DispatchProps> = ({
{renderMessage()}
{canDeleteForAll && (
<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 color="danger" className="confirm-dialog-button" isText onClick={handleDeleteChat}>

View File

@ -98,7 +98,7 @@ const DeleteMessageModal: FC<OwnProps & StateProps & DispatchProps> = ({
)}
{canDeleteForAll && (
<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')}
</Button>
)}

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import React, {
FC, memo, useCallback, useLayoutEffect, useRef,
FC, memo, useCallback, useLayoutEffect, useMemo, useRef,
} from '../../../lib/teact/teact';
import { withGlobal } from '../../../lib/teact/teactn';
@ -66,7 +66,8 @@ type StateProps = {
chat?: ApiChat;
isMuted?: boolean;
privateChatUser?: ApiUser;
actionTargetUser?: ApiUser;
usersById?: Record<number, ApiUser>;
actionTargetUserIds?: number[];
actionTargetMessage?: ApiMessage;
actionTargetChatId?: number;
lastMessageSender?: ApiUser;
@ -91,8 +92,9 @@ const Chat: FC<OwnProps & StateProps & DispatchProps> = ({
isPinned,
chat,
isMuted,
usersById,
privateChatUser,
actionTargetUser,
actionTargetUserIds,
lastMessageSender,
lastMessageOutgoingStatus,
actionTargetMessage,
@ -122,6 +124,12 @@ const Chat: FC<OwnProps & StateProps & DispatchProps> = ({
const mediaBlobUrl = useMedia(lastMessage ? getMessageMediaHash(lastMessage, 'micro') : undefined);
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.
useLayoutEffect(() => {
const element = ref.current;
@ -221,7 +229,7 @@ const Chat: FC<OwnProps & StateProps & DispatchProps> = ({
lang,
lastMessage,
actionOrigin,
actionTargetUser,
actionTargetUsers,
actionTargetMessage,
actionTargetChatId,
{ asPlain: true },
@ -322,8 +330,9 @@ export default memo(withGlobal<OwnProps>(
const actionTargetMessage = lastMessageAction && replyToMessageId
? selectChatMessage(global, chat.id, replyToMessageId)
: undefined;
const { targetUserId: actionTargetUserId, targetChatId: actionTargetChatId } = lastMessageAction || {};
const { targetUserIds: actionTargetUserIds, targetChatId: actionTargetChatId } = lastMessageAction || {};
const privateChatUserId = getPrivateChatUserId(chat);
const { byId: usersById } = global.users;
const {
chatId: currentChatId,
threadId: currentThreadId,
@ -336,7 +345,8 @@ export default memo(withGlobal<OwnProps>(
lastMessageSender,
...(isOutgoing && { lastMessageOutgoingStatus: selectOutgoingStatus(global, chat.lastMessage) }),
...(privateChatUserId && { privateChatUser: selectUser(global, privateChatUserId) }),
...(actionTargetUserId && { actionTargetUser: selectUser(global, actionTargetUserId) }),
usersById,
actionTargetUserIds,
actionTargetChatId,
actionTargetMessage,
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 { throttle } from '../../../util/schedulers';
import searchWords from '../../../util/searchWords';
import { getUserFullName, sortChatIds } from '../../../modules/helpers';
import { getUserFullName, isUserBot, sortChatIds } from '../../../modules/helpers';
import useLang from '../../../hooks/useLang';
import useHistoryBack from '../../../hooks/useHistoryBack';
@ -98,7 +98,11 @@ const NewChatStep1: FC<OwnProps & StateProps & DispatchProps> = ({
...foundContactIds,
...(localUserIds || []),
...(globalUserIds || []),
]),
]).filter((contactId) => {
const user = usersById[contactId];
return !user || !isUserBot(user) || user.canBeInvitedToGroup;
}),
chatsById,
false,
selectedMemberIds,

View File

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

View File

@ -90,7 +90,7 @@ const DeleteSelectedMessageModal: FC<OwnProps & StateProps & DispatchProps> = ({
{canDeleteForAll && (
<Button color="danger" className="confirm-dialog-button" isText onClick={handleDeleteMessageForAll}>
{contactName
? lang('ChatList.DeleteForEveryone', renderText(contactName))
? renderText(lang('ChatList.DeleteForEveryone', contactName))
: lang('Conversation.DeleteMessagesForEveryone')}
</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 {
height: 100%;
overflow-y: scroll;
overflow-x: hidden;
display: flex;
flex-direction: column;
overflow-y: scroll;
overflow-x: hidden;
@supports (overflow-y: overlay) {
overflow-y: overlay !important;
}
> .profile-info > .ChatInfo {
grid-area: chat_info;
@ -124,7 +125,14 @@
@media (max-width: 600px) {
padding: .5rem 0;
.ListItem.chat-item-clickable {
margin: 0;
}
}
}
}
.FloatingActionButton {
z-index: 1;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,8 @@
import { useState, useEffect } from '../lib/teact/teact';
import { IAnchorPosition } from '../types';
const MENU_POSITION_VISUAL_COMFORT_SPACE_PX = 16;
export default (
anchor: IAnchorPosition | undefined,
getTriggerElement: () => HTMLElement | null,
@ -31,16 +33,18 @@ export default (
const menuRect = menuEl ? { width: menuEl.offsetWidth, height: menuEl.offsetHeight } : emptyRect;
const rootRect = rootEl ? rootEl.getBoundingClientRect() : emptyRect;
let horizontalPostition: 'left' | 'right';
if (x + menuRect.width + extraPaddingX < rootRect.width + rootRect.left) {
setPositionX('left');
x += 3;
horizontalPostition = 'left';
} else if (x - menuRect.width > 0) {
setPositionX('right');
horizontalPostition = 'right';
x -= 3;
} else {
setPositionX('left');
horizontalPostition = 'left';
x = 16;
}
setPositionX(horizontalPostition);
if (y + menuRect.height < rootRect.height + rootRect.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,
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.editChatTitle#dc452855 chat_id:int title:string = 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.createChat#9cb126e users:Vector<InputUser> title:string = Updates;
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.editChatTitle#dc452855 chat_id:int title:string = 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.createChat#9cb126e users:Vector<InputUser> title:string = Updates;
messages.getDhConfig#26cf8950 version:int random_length:int = messages.DhConfig;

View File

@ -5,7 +5,7 @@ import {
import {
ApiChat, ApiUser, ApiChatFolder, MAIN_THREAD_ID,
} from '../../../api/types';
import { ChatCreationProgress, ManagementProgress } from '../../../types';
import { NewChatMembersProgress, ChatCreationProgress, ManagementProgress } from '../../../types';
import { GlobalActions } from '../../../global/types';
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) {
const result = await callApi('fetchChats', {
limit: CHAT_LIST_LOAD_SLICE,

View File

@ -5,17 +5,19 @@ import {
import { ApiUser } from '../../../api/types';
import { ManagementProgress } from '../../../types';
import { debounce } from '../../../util/schedulers';
import { debounce, throttle } from '../../../util/schedulers';
import { buildCollectionByKey } from '../../../util/iteratees';
import { isChatPrivate } from '../../helpers';
import { callApi } from '../../../api/gramjs';
import { selectChat, selectUser } from '../../selectors';
import {
addChats, addUsers, updateChat, updateManagementProgress, updateUser, updateUsers,
updateUserSearch, updateUserSearchFetchingStatus,
} from '../../reducers';
const runDebouncedForFetchFullUser = debounce((cb) => cb(), 500, false, true);
const TOP_PEERS_REQUEST_COOLDOWN = 60; // 1 min
const runThrottledForSearch = throttle((cb) => cb(), 500, false);
addReducer('loadFullUser', (global, actions, payload) => {
const { userId } = payload!;
@ -200,3 +202,44 @@ addReducer('loadProfilePhotos', (global, actions, payload) => {
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 {
exitMessageSelectMode, replaceThreadParam, updateCurrentMessageList,
} from '../../reducers';
@ -55,6 +56,13 @@ addReducer('resetChatCreation', (global) => {
};
});
addReducer('setNewChatMembersDialogState', (global, actions, payload) => {
return {
...global,
newChatMembersProgress: payload,
};
});
addReducer('openNextChat', (global, actions, payload) => {
const { targetIndexDelta, orderedIds } = payload;

View File

@ -2,7 +2,7 @@ import { addReducer } from '../../../lib/teact/teactn';
import { GlobalState } from '../../../global/types';
import { updateSelectedUserId } from '../../reducers';
import { updateSelectedUserId, updateUserSearch } from '../../reducers';
addReducer('openUserInfo', (global, actions, payload) => {
const { id } = payload!;
@ -13,3 +13,14 @@ addReducer('openUserInfo', (global, actions, payload) => {
const clearSelectedUserId = (global: GlobalState) => updateSelectedUserId(global, undefined);
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);
}
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 { RightColumnContent } from '../../types';
import { NewChatMembersProgress, RightColumnContent } from '../../types';
import { getSystemTheme, IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment';
import { selectCurrentMessageList, selectIsPollResultsOpen } from './messages';
@ -17,8 +17,10 @@ export function selectRightColumnContentKey(global: GlobalState) {
const {
users,
isChatInfoShown,
newChatMembersProgress,
} = global;
const isAddingChatMembersShown = newChatMembersProgress !== NewChatMembersProgress.Closed;
const isPollResults = selectIsPollResultsOpen(global);
const isSearch = Boolean(!IS_SINGLE_COLUMN_LAYOUT && selectCurrentTextSearch(global));
const isManagement = selectCurrentManagement(global);
@ -43,6 +45,8 @@ export function selectRightColumnContentKey(global: GlobalState) {
RightColumnContent.StickerSearch
) : isGifSearch ? (
RightColumnContent.GifSearch
) : isAddingChatMembersShown ? (
RightColumnContent.AddingMembers
) : isUserInfo ? (
RightColumnContent.UserInfo
) : isChatInfo ? (

View File

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

View File

@ -14,6 +14,7 @@ interface LangFn {
}
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_RULES = {
/* eslint-disable max-len */
@ -55,7 +56,8 @@ let currentLangCode: string | undefined;
export const getTranslation: LangFn = (key: string, value?: any, format?: 'i') => {
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) {
return cached;
}
@ -84,7 +86,8 @@ export const getTranslation: LangFn = (key: string, value?: any, format?: 'i') =
if (value !== undefined) {
const formattedValue = format === 'i' ? formatInteger(value) : value;
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;
}
@ -158,5 +161,11 @@ function getPluralOption(amount: number) {
}
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 { ApiChat, ApiMessage } from '../api/types';
import { ApiChat, ApiMessage, ApiUser } from '../api/types';
import { renderActionMessageText } from '../components/common/helpers/renderActionMessageText';
import { DEBUG } from '../config';
import { getDispatch, getGlobal, setGlobal } from '../lib/teact/teactn';
@ -221,10 +221,13 @@ function getNotificationContent(chat: ApiChat, message: ApiMessage) {
? selectChatMessage(global, chat.id, replyToMessageId)
: undefined;
const {
targetUserId: actionTargetUserId,
targetUserIds: actionTargetUserIds,
targetChatId: actionTargetChatId,
} = 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 privateChatUser = privateChatUserId ? selectUser(global, privateChatUserId) : undefined;
let body: string;
@ -236,7 +239,7 @@ function getNotificationContent(chat: ApiChat, message: ApiMessage) {
getTranslation,
message,
actionOrigin,
actionTargetUser,
actionTargetUsers,
actionTargetMessage,
actionTargetChatId,
{ asPlain: true },