Introduce Chat Lists (#3046)

This commit is contained in:
Alexander Zinchuk 2023-04-26 21:14:39 +04:00
parent bfa9758c9b
commit ce9e5b03d8
67 changed files with 2259 additions and 274 deletions

View File

@ -10,7 +10,7 @@ import { DEFAULT_LIMITS } from '../../../config';
type LimitType = 'default' | 'premium';
type Limit = 'upload_max_fileparts' | 'stickers_faved_limit' | 'saved_gifs_limit' | 'dialog_filters_chats_limit' |
'dialog_filters_limit' | 'dialogs_folder_pinned_limit' | 'dialogs_pinned_limit' | 'caption_length_limit' |
'channels_limit' | 'channels_public_limit' | 'about_length_limit';
'channels_limit' | 'channels_public_limit' | 'about_length_limit' | 'chatlist_invites_limit' | 'chatlist_joined_limit';
type LimitKey = `${Limit}_${LimitType}`;
type LimitsConfig = Record<LimitKey, number>;
@ -99,6 +99,8 @@ export function buildAppConfig(json: GramJs.TypeJSONValue, hash: number): ApiApp
channels: getLimit(appConfig, 'channels_limit', 'channels'),
channelsPublic: getLimit(appConfig, 'channels_public_limit', 'channelsPublic'),
aboutLength: getLimit(appConfig, 'about_length_limit', 'aboutLength'),
chatlistInvites: getLimit(appConfig, 'chatlist_invites_limit', 'chatlistInvites'),
chatlistJoined: getLimit(appConfig, 'chatlist_joined_limit', 'chatlistJoined'),
},
hash,
};

View File

@ -14,6 +14,8 @@ import type {
ApiTopic,
ApiSendAsPeerId,
ApiChatReactions,
ApiChatlistInvite,
ApiChatlistExportedInvite,
} from '../../types';
import { pick, pickTruthy } from '../../../util/iteratees';
import {
@ -363,7 +365,20 @@ export function buildChatTypingStatus(
};
}
export function buildApiChatFolder(filter: GramJs.DialogFilter): ApiChatFolder {
export function buildApiChatFolder(filter: GramJs.DialogFilter | GramJs.DialogFilterChatlist): ApiChatFolder {
if (filter instanceof GramJs.DialogFilterChatlist) {
return {
...pickTruthy(filter, [
'id', 'title', 'emoticon',
]),
excludedChatIds: [],
includedChatIds: filter.includePeers.map(getApiChatIdFromMtpPeer).filter(Boolean),
pinnedChatIds: filter.pinnedPeers.map(getApiChatIdFromMtpPeer).filter(Boolean),
hasMyInvites: filter.hasMyInvites,
isChatList: true,
};
}
return {
...pickTruthy(filter, [
'id', 'title', 'emoticon', 'contacts', 'nonContacts', 'groups', 'bots',
@ -382,7 +397,7 @@ export function buildApiChatFolderFromSuggested({
filter: GramJs.TypeDialogFilter;
description: string;
}): ApiChatFolder | undefined {
if (!(filter instanceof GramJs.DialogFilter)) return undefined;
if (!(filter instanceof GramJs.DialogFilter || filter instanceof GramJs.DialogFilterChatlist)) return undefined;
return {
...buildApiChatFolder(filter),
description,
@ -441,12 +456,14 @@ export function buildChatInviteImporter(importer: GramJs.ChatInviteImporter): Ap
date,
about,
requested,
viaChatlist,
} = importer;
return {
userId: buildApiPeerId(userId, 'user'),
date,
about,
isRequested: requested,
isFromChatList: viaChatlist,
};
}
@ -534,3 +551,45 @@ export function buildApiTopic(forumTopic: GramJs.TypeForumTopic): ApiTopic | und
isMuted: silent || (muteUntil !== undefined ? muteUntil > 0 : undefined),
};
}
export function buildApiChatlistInvite(
invite: GramJs.chatlists.TypeChatlistInvite | undefined, slug: string,
): ApiChatlistInvite | undefined {
if (invite instanceof GramJs.chatlists.ChatlistInvite) {
return {
slug,
title: invite.title,
emoticon: invite.emoticon,
peerIds: invite.peers.map(getApiChatIdFromMtpPeer).filter(Boolean),
};
}
if (invite instanceof GramJs.chatlists.ChatlistInviteAlready) {
return {
slug,
folderId: invite.filterId,
missingPeerIds: invite.missingPeers.map(getApiChatIdFromMtpPeer).filter(Boolean),
alreadyPeerIds: invite.alreadyPeers.map(getApiChatIdFromMtpPeer).filter(Boolean),
};
}
return undefined;
}
export function buildApiChatlistExportedInvite(
invite: GramJs.TypeExportedChatlistInvite | undefined,
): ApiChatlistExportedInvite | undefined {
if (!(invite instanceof GramJs.ExportedChatlistInvite)) return undefined;
const {
title,
url,
peers,
} = invite;
return {
title,
url,
peerIds: peers.map(getApiChatIdFromMtpPeer).filter(Boolean),
};
}

View File

@ -225,7 +225,7 @@ export function buildInputPollFromExisting(poll: ApiPoll, shouldClose = false) {
});
}
export function buildFilterFromApiFolder(folder: ApiChatFolder): GramJs.DialogFilter {
export function buildFilterFromApiFolder(folder: ApiChatFolder): GramJs.DialogFilter | GramJs.DialogFilterChatlist {
const {
emoticon,
contacts,
@ -253,6 +253,17 @@ export function buildFilterFromApiFolder(folder: ApiChatFolder): GramJs.DialogFi
? excludedChatIds.map(buildInputPeerFromLocalDb).filter(Boolean)
: [];
if (folder.isChatList) {
return new GramJs.DialogFilterChatlist({
id: folder.id,
title: folder.title,
emoticon: emoticon || undefined,
pinnedPeers,
includePeers,
hasMyInvites: folder.hasMyInvites,
});
}
return new GramJs.DialogFilter({
id: folder.id,
title: folder.title,

View File

@ -24,6 +24,12 @@ export function resolveMessageApiChatId(mtpMessage: GramJs.TypeMessage) {
return getApiChatIdFromMtpPeer(mtpMessage.peerId);
}
export function isChatFolder(
filter?: GramJs.TypeDialogFilter,
): filter is GramJs.DialogFilter | GramJs.DialogFilterChatlist {
return filter instanceof GramJs.DialogFilter || filter instanceof GramJs.DialogFilterChatlist;
}
export function addMessageToLocalDb(message: GramJs.Message | GramJs.MessageService) {
const messageFullId = `${resolveMessageApiChatId(message)}-${message.id}`;

View File

@ -40,6 +40,7 @@ import {
buildApiChatSettings,
buildApiChatReactions,
buildApiTopic,
buildApiChatlistInvite, buildApiChatlistExportedInvite,
} from '../apiBuilders/chats';
import { buildApiMessage, buildMessageDraft } from '../apiBuilders/messages';
import { buildApiUser, buildApiUsersAndStatuses } from '../apiBuilders/users';
@ -56,7 +57,9 @@ import {
buildInputPhoto,
generateRandomBigInt,
} from '../gramjsBuilders';
import { addEntitiesWithPhotosToLocalDb, addMessageToLocalDb, addPhotoToLocalDb } from '../helpers';
import {
addEntitiesWithPhotosToLocalDb, addMessageToLocalDb, addPhotoToLocalDb, isChatFolder,
} from '../helpers';
import { buildApiPeerId, getApiChatIdFromMtpPeer } from '../apiBuilders/peers';
import { buildApiPhoto } from '../apiBuilders/common';
import { buildStickerSet } from '../apiBuilders/symbols';
@ -816,7 +819,7 @@ export async function fetchChatFolders() {
}
const defaultFolderPosition = result.findIndex((folder) => folder instanceof GramJs.DialogFilterDefault);
const dialogFilters = result.filter((df): df is GramJs.DialogFilter => df instanceof GramJs.DialogFilter);
const dialogFilters = result.filter(isChatFolder);
const orderedIds = dialogFilters.map(({ id }) => id);
if (defaultFolderPosition !== -1) {
orderedIds.splice(defaultFolderPosition, 0, ALL_FOLDER_ID);
@ -1559,3 +1562,149 @@ export function editTopic({
hidden: isHidden,
}), true);
}
export async function checkChatlistInvite({
slug,
}: {
slug: string;
}) {
const result = await invokeRequest(new GramJs.chatlists.CheckChatlistInvite({
slug,
}));
const invite = buildApiChatlistInvite(result, slug);
if (!result || !invite) return undefined;
updateLocalDb(result);
return {
invite,
users: result.users.map(buildApiUser).filter(Boolean),
chats: result.chats.map((c) => buildApiChatFromPreview(c)).filter(Boolean),
};
}
export function joinChatlistInvite({
slug,
peers,
}: {
slug: string;
peers: ApiChat[];
}) {
return invokeRequest(new GramJs.chatlists.JoinChatlistInvite({
slug,
peers: peers.map((peer) => buildInputPeer(peer.id, peer.accessHash)),
}), true, true);
}
export async function fetchLeaveChatlistSuggestions({
folderId,
}: {
folderId: number;
}) {
const result = await invokeRequest(new GramJs.chatlists.GetLeaveChatlistSuggestions({
chatlist: new GramJs.InputChatlistDialogFilter({
filterId: folderId,
}),
}));
if (!result) return undefined;
return result.map(getApiChatIdFromMtpPeer);
}
export function leaveChatlist({
folderId,
peers,
}: {
folderId: number;
peers: ApiChat[];
}) {
return invokeRequest(new GramJs.chatlists.LeaveChatlist({
chatlist: new GramJs.InputChatlistDialogFilter({
filterId: folderId,
}),
peers: peers.map((peer) => buildInputPeer(peer.id, peer.accessHash)),
}), true);
}
export async function createChalistInvite({
folderId, title, peers,
}: {
folderId: number;
title?: string;
peers: (ApiChat | ApiUser)[];
}) {
const result = await invokeRequest(new GramJs.chatlists.ExportChatlistInvite({
chatlist: new GramJs.InputChatlistDialogFilter({
filterId: folderId,
}),
title: title || '',
peers: peers.map((peer) => buildInputPeer(peer.id, peer.accessHash)),
}), undefined, true);
if (!result || result.filter instanceof GramJs.DialogFilterDefault) return undefined;
return {
filter: buildApiChatFolder(result.filter),
invite: buildApiChatlistExportedInvite(result.invite),
};
}
export function deleteChatlistInvite({
folderId, slug,
}: {
folderId: number;
slug: string;
}) {
return invokeRequest(new GramJs.chatlists.DeleteExportedInvite({
chatlist: new GramJs.InputChatlistDialogFilter({
filterId: folderId,
}),
slug,
}));
}
export async function editChatlistInvite({
folderId, slug, title, peers,
}: {
folderId: number;
slug: string;
title?: string;
peers: (ApiChat | ApiUser)[];
}) {
const result = await invokeRequest(new GramJs.chatlists.EditExportedInvite({
chatlist: new GramJs.InputChatlistDialogFilter({
filterId: folderId,
}),
slug,
title,
peers: peers.map((peer) => buildInputPeer(peer.id, peer.accessHash)),
}));
if (!result) return undefined;
return buildApiChatlistExportedInvite(result);
}
export async function fetchChatlistInvites({
folderId,
}: {
folderId: number;
}) {
const result = await invokeRequest(new GramJs.chatlists.GetExportedInvites({
chatlist: new GramJs.InputChatlistDialogFilter({
filterId: folderId,
}),
}));
if (!result) return undefined;
updateLocalDb(result);
return {
invites: result.invites.map(buildApiChatlistExportedInvite).filter(Boolean),
users: result.users.map(buildApiUser).filter(Boolean),
chats: result.chats.map((c) => buildApiChatFromPreview(c)).filter(Boolean),
};
}

View File

@ -20,7 +20,9 @@ export {
updateChatTitle, updateChatAbout, toggleSignatures, updateChatAdmin, fetchGroupsForDiscussion, setDiscussionGroup,
migrateChat, openChatByInvite, fetchMembers, importChatInvite, addChatMembers, deleteChatMember, toggleIsProtected,
getChatByPhoneNumber, toggleJoinToSend, toggleJoinRequest, fetchTopics, deleteTopic, togglePinnedTopic,
editTopic, toggleForum, fetchTopicById, createTopic, toggleParticipantsHidden,
editTopic, toggleForum, fetchTopicById, createTopic, toggleParticipantsHidden, checkChatlistInvite,
joinChatlistInvite, createChalistInvite, editChatlistInvite, deleteChatlistInvite, fetchChatlistInvites,
fetchLeaveChatlistSuggestions, leaveChatlist,
} from './chats';
export {

View File

@ -48,6 +48,7 @@ import {
serializeBytes,
log,
swapLocalInvoiceMedia,
isChatFolder,
} from './helpers';
import {
buildApiNotifyException,
@ -579,7 +580,7 @@ export function updater(update: Update) {
});
} else if (update instanceof GramJs.UpdateDialogFilter) {
const { id, filter } = update;
const folder = filter instanceof GramJs.DialogFilter ? buildApiChatFolder(filter) : undefined;
const folder = isChatFolder(filter) ? buildApiChatFolder(filter) : undefined;
onUpdate({
'@type': 'updateChatFolder',

View File

@ -187,6 +187,8 @@ export interface ApiChatFolder {
pinnedChatIds?: string[];
includedChatIds: string[];
excludedChatIds: string[];
isChatList?: true;
hasMyInvites?: true;
}
export interface ApiChatSettings {
@ -222,3 +224,25 @@ export interface ApiTopic {
isMuted?: boolean;
}
export interface ApiChatlistInviteNew {
title: string;
emoticon?: string;
peerIds: string[];
slug: string;
}
export interface ApiChatlistInviteAlready {
folderId: number;
missingPeerIds: string[];
alreadyPeerIds: string[];
slug: string;
}
export type ApiChatlistInvite = ApiChatlistInviteNew | ApiChatlistInviteAlready;
export interface ApiChatlistExportedInvite {
title: string;
url: string;
peerIds: string[];
}

View File

@ -153,6 +153,7 @@ export type ApiChatInviteImporter = {
date: number;
isRequested?: boolean;
about?: string;
isFromChatList?: boolean;
};
export interface ApiCountry {

Binary file not shown.

View File

@ -18,6 +18,8 @@ export { default as GiftPremiumModal } from '../components/main/premium/GiftPrem
export { default as PremiumLimitReachedModal } from '../components/main/premium/common/PremiumLimitReachedModal';
export { default as StatusPickerMenu } from '../components/left/main/StatusPickerMenu';
export { default as ChatlistModal } from '../components/modals/chatlist/ChatlistModal';
export { default as AboutAdsModal } from '../components/common/AboutAdsModal';
export { default as CalendarModal } from '../components/common/CalendarModal';
export { default as DeleteMessageModal } from '../components/common/DeleteMessageModal';

View File

@ -0,0 +1,27 @@
.primaryLink {
position: relative;
}
.input {
cursor: var(--custom-cursor, pointer);
margin-bottom: 1rem;
padding-right: 3rem;
}
.moreMenu {
position: absolute;
right: 0.5rem;
top: 50%;
transform: translate(0, -50%);
z-index: 1;
}
.buttons {
display: flex;
gap: 1rem;
}
.button {
width: auto;
flex: 1 0 auto;
}

View File

@ -0,0 +1,99 @@
import React, { memo, useCallback, useMemo } from '../../lib/teact/teact';
import { getActions } from '../../global';
import type { FC } from '../../lib/teact/teact';
import { copyTextToClipboard } from '../../util/clipboard';
import buildClassName from '../../util/buildClassName';
import useLang from '../../hooks/useLang';
import useAppLayout from '../../hooks/useAppLayout';
import DropdownMenu from '../ui/DropdownMenu';
import MenuItem from '../ui/MenuItem';
import Button from '../ui/Button';
import styles from './InviteLink.module.scss';
type OwnProps = {
title?: string;
inviteLink: string;
onRevoke?: VoidFunction;
};
const InviteLink: FC<OwnProps> = ({
title,
inviteLink,
onRevoke,
}) => {
const lang = useLang();
const { showNotification, openChatWithDraft } = getActions();
const { isMobile } = useAppLayout();
const copyLink = useCallback((link: string) => {
copyTextToClipboard(link);
showNotification({
message: lang('LinkCopied'),
});
}, [lang]);
const handleCopyPrimaryClicked = useCallback(() => {
copyLink(inviteLink);
}, [copyLink, inviteLink]);
const handleShare = useCallback(() => {
openChatWithDraft({ text: inviteLink });
}, [inviteLink]);
const PrimaryLinkMenuButton: FC<{ onTrigger: () => void; isOpen?: boolean }> = useMemo(() => {
return ({ onTrigger, isOpen }) => (
<Button
round
ripple={!isMobile}
size="smaller"
color="translucent"
className={isOpen ? 'active' : ''}
onClick={onTrigger}
ariaLabel="Actions"
>
<i className="icon icon-more" />
</Button>
);
}, [isMobile]);
return (
<div className="settings-item">
<p className="text-muted">
{lang(title || 'InviteLink.InviteLink')}
</p>
<div className={styles.primaryLink}>
<input
className={buildClassName('form-control', styles.input)}
value={inviteLink}
readOnly
onClick={handleCopyPrimaryClicked}
/>
<DropdownMenu
className={styles.moreMenu}
trigger={PrimaryLinkMenuButton}
positionX="right"
>
<MenuItem icon="copy" onClick={handleCopyPrimaryClicked}>{lang('Copy')}</MenuItem>
{onRevoke && (
<MenuItem icon="delete" onClick={onRevoke} destructive>{lang('RevokeButton')}</MenuItem>
)}
</DropdownMenu>
</div>
<div className={styles.buttons}>
<Button onClick={handleCopyPrimaryClicked} className={styles.button}>
{lang('FolderLinkScreen.LinkActionCopy')}
</Button>
<Button onClick={handleShare} className={styles.button}>
{lang('FolderLinkScreen.LinkActionShare')}
</Button>
</div>
</div>
);
};
export default memo(InviteLink);

View File

@ -1,10 +1,14 @@
import type { FC } from '../../lib/teact/teact';
import React, {
useCallback, useRef, useEffect, memo,
useCallback, useRef, useEffect, memo, useMemo,
} from '../../lib/teact/teact';
import { requestMutation } from '../../lib/fasterdom/fasterdom';
import type { FC } from '../../lib/teact/teact';
import { isUserId } from '../../global/helpers';
import { MEMO_EMPTY_ARRAY } from '../../util/memo';
import useInfiniteScroll from '../../hooks/useInfiniteScroll';
import useLang from '../../hooks/useLang';
import InfiniteScroll from '../ui/InfiniteScroll';
import Checkbox from '../ui/Checkbox';
@ -13,8 +17,6 @@ import ListItem from '../ui/ListItem';
import PrivateChatInfo from './PrivateChatInfo';
import GroupChatInfo from './GroupChatInfo';
import PickerSelectedItem from './PickerSelectedItem';
import useInfiniteScroll from '../../hooks/useInfiniteScroll';
import useLang from '../../hooks/useLang';
import Loading from '../ui/Loading';
@ -29,8 +31,11 @@ type OwnProps = {
searchInputId?: string;
isLoading?: boolean;
noScrollRestore?: boolean;
onSelectedIdsChange: (ids: string[]) => void;
onFilterChange: (value: string) => void;
isSearchable?: boolean;
lockedIds?: string[];
onSelectedIdsChange?: (ids: string[]) => void;
onFilterChange?: (value: string) => void;
onDisabledClick?: (id: string) => void;
onLoadMore?: () => void;
};
@ -49,8 +54,11 @@ const Picker: FC<OwnProps> = ({
searchInputId,
isLoading,
noScrollRestore,
isSearchable,
lockedIds,
onSelectedIdsChange,
onFilterChange,
onDisabledClick,
onLoadMore,
}) => {
// eslint-disable-next-line no-null/no-null
@ -58,53 +66,93 @@ const Picker: FC<OwnProps> = ({
const shouldMinimize = selectedIds.length > MAX_FULL_ITEMS;
useEffect(() => {
if (!isSearchable) return;
setTimeout(() => {
requestMutation(() => {
inputRef.current!.focus();
});
}, FOCUS_DELAY_MS);
}, []);
}, [isSearchable]);
const [lockedSelectedIds, unlockedSelectedIds] = useMemo(() => {
if (!lockedIds?.length) return [MEMO_EMPTY_ARRAY, selectedIds];
const unlockedIds = selectedIds.filter((id) => !lockedIds.includes(id));
return [lockedIds, unlockedIds];
}, [selectedIds, lockedIds]);
const lockedIdsSet = useMemo(() => new Set(lockedIds), [lockedIds]);
const sortedItemIds = useMemo(() => {
return itemIds.sort((a, b) => {
const aIsLocked = lockedIdsSet.has(a);
const bIsLocked = lockedIdsSet.has(b);
if (aIsLocked && !bIsLocked) {
return -1;
}
if (!aIsLocked && bIsLocked) {
return 1;
}
return 0;
});
}, [itemIds, lockedIdsSet]);
const handleItemClick = useCallback((id: string) => {
const newSelectedIds = [...selectedIds];
if (lockedIdsSet.has(id)) {
onDisabledClick?.(id);
return;
}
const newSelectedIds = selectedIds.slice();
if (newSelectedIds.includes(id)) {
newSelectedIds.splice(newSelectedIds.indexOf(id), 1);
} else {
newSelectedIds.push(id);
}
onSelectedIdsChange(newSelectedIds);
onFilterChange('');
}, [selectedIds, onSelectedIdsChange, onFilterChange]);
onSelectedIdsChange?.(newSelectedIds);
onFilterChange?.('');
}, [lockedIdsSet, selectedIds, onSelectedIdsChange, onFilterChange, onDisabledClick]);
const handleFilterChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const { value } = e.currentTarget;
onFilterChange(value);
onFilterChange?.(value);
}, [onFilterChange]);
const [viewportIds, getMore] = useInfiniteScroll(onLoadMore, itemIds, Boolean(filterValue));
const [viewportIds, getMore] = useInfiniteScroll(onLoadMore, sortedItemIds, Boolean(filterValue));
const lang = useLang();
return (
<div className="Picker">
<div className="picker-header custom-scroll" dir={lang.isRtl ? 'rtl' : undefined}>
{selectedIds.map((id, i) => (
<PickerSelectedItem
chatOrUserId={id}
isMinimized={shouldMinimize && i < selectedIds.length - ALWAYS_FULL_ITEMS_COUNT}
canClose
onClick={handleItemClick}
clickArg={id}
{isSearchable && (
<div className="picker-header custom-scroll" dir={lang.isRtl ? 'rtl' : undefined}>
{lockedSelectedIds.map((id, i) => (
<PickerSelectedItem
chatOrUserId={id}
isMinimized={shouldMinimize && i < selectedIds.length - ALWAYS_FULL_ITEMS_COUNT}
onClick={handleItemClick}
clickArg={id}
/>
))}
{unlockedSelectedIds.map((id, i) => (
<PickerSelectedItem
chatOrUserId={id}
isMinimized={
shouldMinimize && i + lockedSelectedIds.length < selectedIds.length - ALWAYS_FULL_ITEMS_COUNT
}
canClose
onClick={handleItemClick}
clickArg={id}
/>
))}
<InputText
id={searchInputId}
ref={inputRef}
value={filterValue}
onChange={handleFilterChange}
placeholder={filterPlaceholder || lang('SelectChat')}
/>
))}
<InputText
id={searchInputId}
ref={inputRef}
value={filterValue}
onChange={handleFilterChange}
placeholder={filterPlaceholder || lang('SelectChat')}
/>
</div>
</div>
)}
{viewportIds?.length ? (
<InfiniteScroll
@ -117,11 +165,13 @@ const Picker: FC<OwnProps> = ({
<ListItem
key={id}
className="chat-item-clickable picker-list-item"
disabled={lockedIdsSet.has(id)}
allowDisabledClick={Boolean(onDisabledClick)}
// eslint-disable-next-line react/jsx-no-bind
onClick={() => handleItemClick(id)}
ripple
>
<Checkbox label="" checked={selectedIds.includes(id)} />
<Checkbox label="" disabled={lockedIdsSet.has(id)} checked={selectedIds.includes(id)} />
{isUserId(id) ? (
<PrivateChatInfo userId={id} />
) : (

View File

@ -5,6 +5,7 @@ import MonkeyPeek from '../../../assets/tgs/monkeys/TwoFactorSetupMonkeyPeek.tgs
import FoldersAll from '../../../assets/tgs/settings/FoldersAll.tgs';
import FoldersNew from '../../../assets/tgs/settings/FoldersNew.tgs';
import FoldersShare from '../../../assets/tgs/settings/FoldersShare.tgs';
import DiscussionGroups from '../../../assets/tgs/settings/DiscussionGroupsDucks.tgs';
import Lock from '../../../assets/tgs/settings/Lock.tgs';
import Congratulations from '../../../assets/tgs/settings/Congratulations.tgs';
@ -31,6 +32,7 @@ export const LOCAL_TGS_URLS = {
MonkeyPeek,
FoldersAll,
FoldersNew,
FoldersShare,
DiscussionGroups,
Lock,
CameraFlip,

View File

@ -6,10 +6,12 @@ import { getActions, withGlobal } from '../../global';
import type { FC } from '../../lib/teact/teact';
import type { GlobalState } from '../../global/types';
import { LeftColumnContent, SettingsScreens } from '../../types';
import type { ReducerAction } from '../../hooks/useReducer';
import type { FoldersActions } from '../../hooks/reducers/useFoldersReducer';
import { IS_MAC_OS, IS_PWA, LAYERS_ANIMATION_NAME } from '../../util/windowEnvironment';
import captureEscKeyListener from '../../util/captureEscKeyListener';
import { selectTabState, selectCurrentChat, selectIsForumPanelOpen } from '../../global/selectors';
import { selectCurrentChat, selectIsForumPanelOpen, selectTabState } from '../../global/selectors';
import useFoldersReducer from '../../hooks/reducers/useFoldersReducer';
import { useResize } from '../../hooks/useResize';
import { useHotkeys } from '../../hooks/useHotkeys';
@ -32,6 +34,7 @@ type StateProps = {
currentUserId?: string;
hasPasscode?: boolean;
nextSettingsScreen?: SettingsScreens;
nextFoldersAction?: ReducerAction<FoldersActions>;
isChatOpen: boolean;
isUpdateAvailable?: boolean;
isForumPanelOpen?: boolean;
@ -63,6 +66,7 @@ const LeftColumn: FC<StateProps> = ({
currentUserId,
hasPasscode,
nextSettingsScreen,
nextFoldersAction,
isChatOpen,
isUpdateAvailable,
isForumPanelOpen,
@ -115,6 +119,7 @@ const LeftColumn: FC<StateProps> = ({
const handleReset = useCallback((forceReturnToChatList?: true | Event) => {
function fullReset() {
setContent(LeftColumnContent.ChatList);
setSettingsScreen(SettingsScreens.Main);
setContactsFilter('');
setGlobalSearchClosing({ isClosing: true });
resetChatCreation();
@ -285,12 +290,17 @@ const LeftColumn: FC<StateProps> = ({
setSettingsScreen(SettingsScreens.Folders);
return;
case SettingsScreens.FoldersShare:
setSettingsScreen(SettingsScreens.FoldersEditFolder);
return;
case SettingsScreens.FoldersIncludedChatsFromChatList:
case SettingsScreens.FoldersExcludedChatsFromChatList:
setSettingsScreen(SettingsScreens.FoldersEditFolderFromChatList);
return;
case SettingsScreens.FoldersEditFolderFromChatList:
case SettingsScreens.FoldersEditFolderInvites:
setContent(LeftColumnContent.ChatList);
setSettingsScreen(SettingsScreens.Main);
return;
@ -394,7 +404,11 @@ const LeftColumn: FC<StateProps> = ({
setSettingsScreen(nextSettingsScreen);
requestNextSettingsScreen({ screen: undefined });
}
}, [nextSettingsScreen, requestNextSettingsScreen]);
if (nextFoldersAction) {
foldersDispatch(nextFoldersAction);
}
}, [foldersDispatch, nextFoldersAction, nextSettingsScreen, requestNextSettingsScreen]);
const {
initResize, resetResize, handleMouseUp,
@ -513,6 +527,7 @@ export default memo(withGlobal(
shouldSkipHistoryAnimations,
activeChatFolder,
nextSettingsScreen,
nextFoldersAction,
} = tabState;
const {
leftColumnWidth,
@ -538,6 +553,7 @@ export default memo(withGlobal(
currentUserId,
hasPasscode,
nextSettingsScreen,
nextFoldersAction,
isChatOpen,
isUpdateAvailable,
isForumPanelOpen,

View File

@ -12,7 +12,7 @@ import {
selectIsForumPanelOpen,
} from '../../../global/selectors';
import Badge from './Badge';
import ChatBadge from './ChatBadge';
type OwnProps = {
chatId: string;
@ -31,7 +31,7 @@ const AvatarBadge: FC<OwnProps & StateProps> = ({
}) => {
return chat && (
<div className="avatar-badge-wrapper">
<Badge chat={chat} isMuted={isMuted} shouldShowOnlyMostImportant forceHidden={!isForumPanelActive} />
<ChatBadge chat={chat} isMuted={isMuted} shouldShowOnlyMostImportant forceHidden={!isForumPanelActive} />
</div>
);
};

View File

@ -110,21 +110,21 @@
color: var(--color-white) !important;
}
.Badge:not(.pinned):not(.muted) {
.ChatBadge:not(.pinned):not(.muted) {
color: var(--color-chat-active);
background: var(--color-white);
}
.Badge:not(.pinned).muted {
.ChatBadge:not(.pinned).muted {
color: var(--color-white);
background: #FFFFFF33;
}
.avatar-badge-wrapper .Badge:not(.pinned) {
.avatar-badge-wrapper .ChatBadge:not(.pinned) {
--outline-color: transparent;
}
.avatar-badge-wrapper .Badge:not(.pinned).muted {
.avatar-badge-wrapper .ChatBadge:not(.pinned).muted {
background: var(--color-gray);
}
}
@ -204,11 +204,11 @@
--outline-color: var(--color-background);
.Badge {
.ChatBadge {
box-shadow: 0 0 0 2px var(--outline-color);
}
.Badge-transition {
.ChatBadge-transition {
transition: opacity var(--layer-transition), transform var(--layer-transition);
body.no-page-transitions & {

View File

@ -55,7 +55,7 @@ import ReportModal from '../../common/ReportModal';
import FullNameTitle from '../../common/FullNameTitle';
import ChatFolderModal from '../ChatFolderModal.async';
import ChatCallStatus from './ChatCallStatus';
import Badge from './Badge';
import ChatBadge from './ChatBadge';
import AvatarBadge from './AvatarBadge';
import './Chat.scss';
@ -264,7 +264,7 @@ const Chat: FC<OwnProps & StateProps> = ({
</div>
<div className="subtitle">
{renderSubtitle()}
<Badge chat={chat} isPinned={isPinned} isMuted={isMuted} />
<ChatBadge chat={chat} isPinned={isPinned} isMuted={isMuted} />
</div>
</div>
{shouldRenderDeleteModal && (

View File

@ -1,4 +1,4 @@
.Badge-transition {
.ChatBadge-transition {
opacity: 1;
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
@ -16,15 +16,15 @@
}
}
.Badge-wrapper {
.ChatBadge-wrapper {
display: flex;
.Badge {
.ChatBadge {
margin-inline-start: 0.5rem;
}
}
.Badge {
.ChatBadge {
min-width: 1.5rem;
height: 1.5rem;
background: var(--color-gray);

View File

@ -9,7 +9,7 @@ import buildClassName from '../../../util/buildClassName';
import ShowTransition from '../../ui/ShowTransition';
import AnimatedCounter from '../../common/AnimatedCounter';
import './Badge.scss';
import './ChatBadge.scss';
type OwnProps = {
chat: ApiChat;
@ -21,7 +21,7 @@ type OwnProps = {
forceHidden?: boolean;
};
const Badge: FC<OwnProps> = ({
const ChatBadge: FC<OwnProps> = ({
topic, chat, isPinned, isMuted, shouldShowOnlyMostImportant, wasTopicOpened, forceHidden,
}) => {
const {
@ -58,7 +58,7 @@ const Badge: FC<OwnProps> = ({
const isUnread = Boolean(unreadCount || hasUnreadMark);
const className = buildClassName(
'Badge',
'ChatBadge',
shouldBeMuted && 'muted',
!isUnread && isPinned && 'pinned',
isUnread && 'unread',
@ -66,19 +66,19 @@ const Badge: FC<OwnProps> = ({
function renderContent() {
const unreadReactionsElement = unreadReactionsCount && (
<div className={buildClassName('Badge reaction', shouldBeMuted && 'muted')}>
<div className={buildClassName('ChatBadge reaction', shouldBeMuted && 'muted')}>
<i className="icon icon-heart" />
</div>
);
const unreadMentionsElement = unreadMentionsCount && (
<div className="Badge mention">
<div className="ChatBadge mention">
<i className="icon icon-mention" />
</div>
);
const unopenedTopicElement = isTopicUnopened && (
<div className={buildClassName('Badge unopened', shouldBeMuted && 'muted')} />
<div className={buildClassName('ChatBadge unopened', shouldBeMuted && 'muted')} />
);
const unreadCountElement = (hasUnreadMark || unreadCount) ? (
@ -109,17 +109,17 @@ const Badge: FC<OwnProps> = ({
}
return (
<div className="Badge-wrapper">
<div className="ChatBadge-wrapper">
{elements}
</div>
);
}
return (
<ShowTransition isCustom className="Badge-transition" isOpen={isShown}>
<ShowTransition isCustom className="ChatBadge-transition" isOpen={isShown}>
{renderContent()}
</ShowTransition>
);
};
export default memo(Badge);
export default memo(ChatBadge);

View File

@ -2,20 +2,22 @@ import type { FC } from '../../../lib/teact/teact';
import React, {
memo, useCallback, useEffect, useMemo, useRef,
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import { getActions, getGlobal, withGlobal } from '../../../global';
import type { ApiChatFolder } from '../../../api/types';
import type { LeftColumnContent, SettingsScreens } from '../../../types';
import type { ApiChatFolder, ApiChatlistExportedInvite } from '../../../api/types';
import type { SettingsScreens, LeftColumnContent } from '../../../types';
import type { FolderEditDispatch } from '../../../hooks/reducers/useFoldersReducer';
import type { GlobalState } from '../../../global/types';
import type { TabWithProperties } from '../../ui/TabList';
import { ALL_FOLDER_ID } from '../../../config';
import { IS_TOUCH_ENV } from '../../../util/windowEnvironment';
import { MEMO_EMPTY_ARRAY } from '../../../util/memo';
import { captureEvents, SwipeDirection } from '../../../util/captureEvents';
import buildClassName from '../../../util/buildClassName';
import captureEscKeyListener from '../../../util/captureEscKeyListener';
import { selectCurrentLimit } from '../../../global/selectors/limits';
import { selectTabState } from '../../../global/selectors';
import { selectCanShareFolder, selectTabState } from '../../../global/selectors';
import useShowTransition from '../../../hooks/useShowTransition';
import useLang from '../../../hooks/useLang';
import useHistoryBack from '../../../hooks/useHistoryBack';
@ -35,12 +37,14 @@ type OwnProps = {
type StateProps = {
chatFoldersById: Record<number, ApiChatFolder>;
folderInvitesById: Record<number, ApiChatlistExportedInvite[]>;
orderedFolderIds?: number[];
activeChatFolder: number;
currentUserId?: string;
lastSyncTime?: number;
shouldSkipHistoryAnimations?: boolean;
maxFolders: number;
maxFolderInvites: number;
hasArchivedChats?: boolean;
archiveSettings: GlobalState['archiveSettings'];
};
@ -61,6 +65,8 @@ const ChatFolders: FC<OwnProps & StateProps> = ({
shouldSkipHistoryAnimations,
maxFolders,
shouldHideFolderTabs,
folderInvitesById,
maxFolderInvites,
hasArchivedChats,
archiveSettings,
}) => {
@ -68,6 +74,10 @@ const ChatFolders: FC<OwnProps & StateProps> = ({
loadChatFolders,
setActiveChatFolder,
openChat,
openShareChatFolderModal,
openDeleteChatFolderModal,
openEditChatFolder,
openLimitReachedModal,
} = getActions();
// eslint-disable-next-line no-null/no-null
@ -81,11 +91,13 @@ const ChatFolders: FC<OwnProps & StateProps> = ({
}
}, [lastSyncTime, loadChatFolders]);
const allChatsFolder = useMemo(() => {
const allChatsFolder: ApiChatFolder = useMemo(() => {
return {
id: ALL_FOLDER_ID,
title: orderedFolderIds?.[0] === ALL_FOLDER_ID ? lang('FilterAllChatsShort') : lang('FilterAllChats'),
};
includedChatIds: MEMO_EMPTY_ARRAY,
excludedChatIds: MEMO_EMPTY_ARRAY,
} satisfies ApiChatFolder;
}, [orderedFolderIds, lang]);
const displayedFolders = useMemo(() => {
@ -110,18 +122,63 @@ const ChatFolders: FC<OwnProps & StateProps> = ({
return undefined;
}
return displayedFolders.map(({ id, title }, i) => {
const isBlocked = id !== ALL_FOLDER_ID && i > maxFolders - 1;
const global = getGlobal();
return ({
return displayedFolders.map((folder, i) => {
const { id, title } = folder;
const isBlocked = id !== ALL_FOLDER_ID && i > maxFolders - 1;
const canShareFolder = selectCanShareFolder(global, id);
const contextActions = [];
if (canShareFolder) {
contextActions.push({
title: lang('ChatList.ContextMenuShare'),
icon: 'link',
handler: () => {
// Greater amount can be after premium downgrade
if (folderInvitesById[id]?.length >= maxFolderInvites) {
openLimitReachedModal({
limit: 'chatlistInvites',
});
} else {
openShareChatFolderModal({
folderId: id,
});
}
},
});
}
if (id !== ALL_FOLDER_ID) {
contextActions.push({
title: lang('FilterEdit'),
icon: 'edit',
handler: () => {
openEditChatFolder({ folderId: id });
},
});
contextActions.push({
title: lang('FilterDeleteItem'),
icon: 'delete',
destructive: true,
handler: () => {
openDeleteChatFolderModal({ folderId: id });
},
});
}
return {
id,
title,
badgeCount: folderCountersById[id]?.chatsCount,
isBadgeActive: Boolean(folderCountersById[id]?.notificationsCount),
isBlocked,
});
contextActions: contextActions?.length ? contextActions : undefined,
} satisfies TabWithProperties;
});
}, [displayedFolders, folderCountersById, maxFolders]);
}, [displayedFolders, folderCountersById, lang, maxFolders, folderInvitesById, maxFolderInvites]);
const handleSwitchTab = useCallback((index: number) => {
setActiveChatFolder({ activeChatFolder: index }, { forceOnHeavyAnimation: true });
@ -236,7 +293,13 @@ const ChatFolders: FC<OwnProps & StateProps> = ({
)}
>
{shouldRenderFolders ? (
<TabList tabs={folderTabs} activeTab={activeChatFolder} onSwitchTab={handleSwitchTab} areFolders />
<TabList
contextRootElementSelector="#LeftColumn"
tabs={folderTabs}
activeTab={activeChatFolder}
onSwitchTab={handleSwitchTab}
areFolders
/>
) : shouldRenderPlaceholder ? (
<div className={buildClassName('tabs-placeholder', transitionClassNames)} />
) : undefined}
@ -258,6 +321,7 @@ export default memo(withGlobal<OwnProps>(
chatFolders: {
byId: chatFoldersById,
orderedIds: orderedFolderIds,
invites: folderInvitesById,
},
chats: {
listIds: {
@ -272,6 +336,7 @@ export default memo(withGlobal<OwnProps>(
return {
chatFoldersById,
folderInvitesById,
orderedFolderIds,
activeChatFolder,
currentUserId,
@ -279,6 +344,7 @@ export default memo(withGlobal<OwnProps>(
shouldSkipHistoryAnimations,
hasArchivedChats: Boolean(archived?.length),
maxFolders: selectCurrentLimit(global, 'dialogFilters'),
maxFolderInvites: selectCurrentLimit(global, 'chatlistInvites'),
archiveSettings,
};
},

View File

@ -35,7 +35,7 @@ import useLang from '../../../hooks/useLang';
import ListItem from '../../ui/ListItem';
import LastMessageMeta from '../../common/LastMessageMeta';
import Badge from './Badge';
import ChatBadge from './ChatBadge';
import ConfirmDialog from '../../ui/ConfirmDialog';
import TopicIcon from '../../common/TopicIcon';
@ -179,7 +179,7 @@ const Topic: FC<OwnProps & StateProps> = ({
</div>
<div className="subtitle">
{renderSubtitle()}
<Badge
<ChatBadge
chat={chat}
isPinned={isPinned}
isMuted={isMuted}

View File

@ -112,6 +112,7 @@ const NewChatStep1: FC<OwnProps & StateProps> = ({
filterPlaceholder={lang('SendMessageTo')}
searchInputId="new-group-picker-search"
isLoading={isSearching}
isSearchable
onSelectedIdsChange={onSelectedMemberIdsChange}
onFilterChange={handleFilterChange}
/>

View File

@ -380,3 +380,11 @@
margin-inline-end: 2rem;
}
}
.settings-item-chatlist {
padding: 0;
}
.settings-item-chatlist .ListItem {
margin: inherit;
}

View File

@ -1,10 +1,12 @@
import type { FC } from '../../../lib/teact/teact';
import React, { memo, useCallback, useState } from '../../../lib/teact/teact';
import { getActions, getGlobal } from '../../../global';
import type { FC } from '../../../lib/teact/teact';
import { SettingsScreens } from '../../../types';
import type { FolderEditDispatch, FoldersState } from '../../../hooks/reducers/useFoldersReducer';
import { LAYERS_ANIMATION_NAME } from '../../../util/windowEnvironment';
import { selectTabState } from '../../../global/selectors';
import useTwoFaReducer from '../../../hooks/reducers/useTwoFaReducer';
import Transition from '../../ui/Transition';
@ -67,10 +69,12 @@ const FOLDERS_SCREENS = [
SettingsScreens.FoldersCreateFolder,
SettingsScreens.FoldersEditFolder,
SettingsScreens.FoldersEditFolderFromChatList,
SettingsScreens.FoldersEditFolderInvites,
SettingsScreens.FoldersIncludedChats,
SettingsScreens.FoldersIncludedChatsFromChatList,
SettingsScreens.FoldersExcludedChats,
SettingsScreens.FoldersExcludedChatsFromChatList,
SettingsScreens.FoldersShare,
];
const PRIVACY_SCREENS = [
@ -137,11 +141,18 @@ const Settings: FC<OwnProps> = ({
onReset,
shouldSkipTransition,
}) => {
const { closeShareChatFolderModal } = getActions();
const [twoFaState, twoFaDispatch] = useTwoFaReducer();
const [privacyPasscode, setPrivacyPasscode] = useState<string>('');
const handleReset = useCallback((forceReturnToChatList?: true | Event) => {
if (forceReturnToChatList === true) {
const isFromSettings = selectTabState(getGlobal()).shareFolderScreen?.isFromSettings;
if (currentScreen === SettingsScreens.FoldersShare) {
closeShareChatFolderModal();
}
if (forceReturnToChatList === true || (isFromSettings !== undefined && !isFromSettings)) {
onReset(true);
return;
}
@ -150,6 +161,7 @@ const Settings: FC<OwnProps> = ({
currentScreen === SettingsScreens.FoldersCreateFolder
|| currentScreen === SettingsScreens.FoldersEditFolder
|| currentScreen === SettingsScreens.FoldersEditFolderFromChatList
|| currentScreen === SettingsScreens.FoldersEditFolderInvites
) {
setTimeout(() => {
foldersDispatch({ type: 'reset' });
@ -361,10 +373,12 @@ const Settings: FC<OwnProps> = ({
case SettingsScreens.FoldersCreateFolder:
case SettingsScreens.FoldersEditFolder:
case SettingsScreens.FoldersEditFolderFromChatList:
case SettingsScreens.FoldersEditFolderInvites:
case SettingsScreens.FoldersIncludedChats:
case SettingsScreens.FoldersIncludedChatsFromChatList:
case SettingsScreens.FoldersExcludedChats:
case SettingsScreens.FoldersExcludedChatsFromChatList:
case SettingsScreens.FoldersShare:
return (
<SettingsFolders
currentScreen={currentScreen}

View File

@ -201,8 +201,11 @@ const SettingsHeader: FC<OwnProps> = ({
return <h3>{lang('Filters')}</h3>;
case SettingsScreens.FoldersCreateFolder:
return <h3>{lang('FilterNew')}</h3>;
case SettingsScreens.FoldersShare:
return <h3>{lang('FolderLinkScreen.Title')}</h3>;
case SettingsScreens.FoldersEditFolder:
case SettingsScreens.FoldersEditFolderFromChatList:
case SettingsScreens.FoldersEditFolderInvites:
return (
<div className="settings-main-header">
<h3>{lang('FilterEdit')}</h3>

View File

@ -107,6 +107,7 @@ const SettingsPrivacyVisibilityExceptionList: FC<OwnProps & StateProps> = ({
filterValue={searchQuery}
filterPlaceholder={isAllowList ? lang('AlwaysAllowPlaceholder') : lang('NeverAllowPlaceholder')}
searchInputId="new-group-picker-search"
isSearchable
onSelectedIdsChange={handleSelectedContactIdsChange}
onFilterChange={setSearchQuery}
/>

View File

@ -1,14 +1,15 @@
import type { FC } from '../../../../lib/teact/teact';
import React, { memo, useCallback } from '../../../../lib/teact/teact';
import { getActions } from '../../../../global';
import type { FC } from '../../../../lib/teact/teact';
import type { ApiChatFolder } from '../../../../api/types';
import { SettingsScreens } from '../../../../types';
import type { FolderEditDispatch, FoldersState } from '../../../../hooks/reducers/useFoldersReducer';
import SettingsFoldersMain from './SettingsFoldersMain';
import SettingsFoldersEdit from './SettingsFoldersEdit';
import SettingsFoldersChatFilters from './SettingsFoldersChatFilters';
import SettingsShareChatlist from './SettingsShareChatlist';
import './SettingsFolders.scss';
@ -33,11 +34,14 @@ const SettingsFolders: FC<OwnProps> = ({
onScreenSelect,
onReset,
}) => {
const { openShareChatFolderModal } = getActions();
const handleReset = useCallback(() => {
if (
currentScreen === SettingsScreens.FoldersCreateFolder
|| currentScreen === SettingsScreens.FoldersEditFolder
|| currentScreen === SettingsScreens.FoldersEditFolderFromChatList
|| currentScreen === SettingsScreens.FoldersEditFolderInvites
) {
setTimeout(() => {
dispatch({ type: 'reset' });
@ -86,6 +90,16 @@ const SettingsFolders: FC<OwnProps> = ({
: SettingsScreens.FoldersExcludedChats);
}, [currentScreen, dispatch, onScreenSelect]);
const handleShareFolder = useCallback(() => {
openShareChatFolderModal({ folderId: state.folderId!, noRequestNextScreen: true });
onScreenSelect(SettingsScreens.FoldersShare);
}, [onScreenSelect, state.folderId]);
const handleOpenInvite = useCallback((url: string) => {
openShareChatFolderModal({ folderId: state.folderId!, url, noRequestNextScreen: true });
onScreenSelect(SettingsScreens.FoldersShare);
}, [onScreenSelect, state.folderId]);
switch (currentScreen) {
case SettingsScreens.Folders:
return (
@ -104,17 +118,21 @@ const SettingsFolders: FC<OwnProps> = ({
case SettingsScreens.FoldersCreateFolder:
case SettingsScreens.FoldersEditFolder:
case SettingsScreens.FoldersEditFolderFromChatList:
case SettingsScreens.FoldersEditFolderInvites:
return (
<SettingsFoldersEdit
state={state}
dispatch={dispatch}
onAddIncludedChats={handleAddIncludedChats}
onAddExcludedChats={handleAddExcludedChats}
onShareFolder={handleShareFolder}
onOpenInvite={handleOpenInvite}
onReset={handleReset}
isActive={isActive || [
SettingsScreens.FoldersIncludedChats,
SettingsScreens.FoldersExcludedChats,
].includes(shownScreen)}
isOnlyInvites={currentScreen === SettingsScreens.FoldersEditFolderInvites}
onBack={onReset}
/>
);
@ -141,6 +159,14 @@ const SettingsFolders: FC<OwnProps> = ({
/>
);
case SettingsScreens.FoldersShare:
return (
<SettingsShareChatlist
isActive={isActive}
onReset={handleReset}
/>
);
default:
return undefined;
}

View File

@ -43,6 +43,8 @@ const SettingsFoldersChatFilters: FC<OwnProps> = ({
const folderAllOrderedIds = useFolderManagerForOrderedIds(ALL_FOLDER_ID);
const folderArchivedOrderedIds = useFolderManagerForOrderedIds(ARCHIVED_FOLDER_ID);
const shouldHideChatTypes = state.folder.isChatList;
const displayedIds = useMemo(() => {
// No need for expensive global updates on chats, so we avoid them
const chatsById = getGlobal().chats.byId;
@ -116,6 +118,7 @@ const SettingsFoldersChatFilters: FC<OwnProps> = ({
selectedIds={selectedChatIds}
selectedChatTypes={selectedChatTypes}
filterValue={chatFilter}
shouldHideChatTypes={shouldHideChatTypes}
onSelectedIdsChange={handleSelectedIdsChange}
onSelectedChatTypesChange={handleSelectedChatTypesChange}
onFilterChange={handleFilterChange}

View File

@ -34,6 +34,7 @@ type OwnProps = {
selectedIds: string[];
selectedChatTypes: string[];
filterValue?: string;
shouldHideChatTypes?: boolean;
onSelectedIdsChange: (ids: string[]) => void;
onSelectedChatTypesChange: (types: string[]) => void;
onFilterChange: (value: string) => void;
@ -55,6 +56,7 @@ const SettingsFoldersChatsPicker: FC<OwnProps & StateProps> = ({
selectedIds,
selectedChatTypes,
filterValue,
shouldHideChatTypes,
onSelectedIdsChange,
onSelectedChatTypesChange,
onFilterChange,
@ -200,11 +202,15 @@ const SettingsFoldersChatsPicker: FC<OwnProps & StateProps> = ({
>
{(!viewportIds || !viewportIds.length || viewportIds.includes(chatIds[0])) && (
<div key="header">
<h4 className="settings-item-header" dir={lang.isRtl ? 'rtl' : undefined}>
{lang('FilterChatTypes')}
</h4>
{chatTypes.map(renderChatType)}
<div className="picker-list-divider" />
{!shouldHideChatTypes && (
<>
<h4 className="settings-item-header" dir={lang.isRtl ? 'rtl' : undefined}>
{lang('FilterChatTypes')}
</h4>
{chatTypes.map(renderChatType)}
<div className="picker-list-divider" />
</>
)}
<h4 className="settings-item-header" dir={lang.isRtl ? 'rtl' : undefined}>
{lang('FilterChats')}
</h4>

View File

@ -2,12 +2,13 @@ import type { FC } from '../../../../lib/teact/teact';
import React, {
memo, useCallback, useEffect, useMemo, useState,
} from '../../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../../global';
import { getActions, getGlobal, withGlobal } from '../../../../global';
import type { ApiChatFolder } from '../../../../api/types';
import type { ApiChatFolder, ApiChatlistExportedInvite } from '../../../../api/types';
import { STICKER_SIZE_FOLDER_SETTINGS } from '../../../../config';
import { LOCAL_TGS_URLS } from '../../../common/helpers/animatedAssets';
import { MEMO_EMPTY_ARRAY } from '../../../../util/memo';
import { findIntersectionWithSet } from '../../../../util/iteratees';
import { isUserId } from '../../../../global/helpers';
import type {
@ -19,6 +20,8 @@ import {
INCLUDED_CHAT_TYPES,
selectChatFilters,
} from '../../../../hooks/reducers/useFoldersReducer';
import { selectCanShareFolder } from '../../../../global/selectors';
import { selectCurrentLimit } from '../../../../global/selectors/limits';
import useLang from '../../../../hooks/useLang';
import useHistoryBack from '../../../../hooks/useHistoryBack';
@ -34,9 +37,12 @@ import AnimatedIcon from '../../../common/AnimatedIcon';
type OwnProps = {
state: FoldersState;
dispatch: FolderEditDispatch;
onAddIncludedChats: () => void;
onAddExcludedChats: () => void;
onAddIncludedChats: VoidFunction;
onAddExcludedChats: VoidFunction;
onShareFolder: VoidFunction;
onOpenInvite: (url: string) => void;
isActive?: boolean;
isOnlyInvites?: boolean;
onReset: () => void;
onBack: () => void;
};
@ -44,7 +50,9 @@ type OwnProps = {
type StateProps = {
loadedActiveChatIds?: string[];
loadedArchivedChatIds?: string[];
invites?: ApiChatlistExportedInvite[];
isRemoved?: boolean;
maxInviteLinks: number;
};
const SUBMIT_TIMEOUT = 500;
@ -59,18 +67,29 @@ const SettingsFoldersEdit: FC<OwnProps & StateProps> = ({
dispatch,
onAddIncludedChats,
onAddExcludedChats,
onShareFolder,
onOpenInvite,
isActive,
onReset,
isRemoved,
onBack,
loadedActiveChatIds,
isOnlyInvites,
loadedArchivedChatIds,
invites,
maxInviteLinks,
}) => {
const {
editChatFolder,
addChatFolder,
loadChatlistInvites,
openLimitReachedModal,
showNotification,
} = getActions();
const isCreating = state.mode === 'create';
const isEditingChatList = state.folder.isChatList;
const [isIncludedChatsListExpanded, setIsIncludedChatsListExpanded] = useState(false);
const [isExcludedChatsListExpanded, setIsExcludedChatsListExpanded] = useState(false);
@ -80,6 +99,12 @@ const SettingsFoldersEdit: FC<OwnProps & StateProps> = ({
}
}, [isRemoved, onReset]);
useEffect(() => {
if (isActive && state.folderId && state.folder.isChatList) {
loadChatlistInvites({ folderId: state.folderId });
}
}, [isActive, state.folder.isChatList, state.folderId]);
const {
selectedChatIds: includedChatIds,
selectedChatTypes: includedChatTypes,
@ -129,7 +154,7 @@ const SettingsFoldersEdit: FC<OwnProps & StateProps> = ({
dispatch({ type: 'setTitle', payload: currentTarget.value.trim() });
}, [dispatch]);
const handleSubmit = useCallback(() => {
const handleSaveFolder = useCallback((cb?: NoneToVoidFunction) => {
const { title } = state.folder;
if (!title) {
@ -142,17 +167,52 @@ const SettingsFoldersEdit: FC<OwnProps & StateProps> = ({
return;
}
dispatch({ type: 'setIsLoading', payload: true });
if (state.mode === 'edit') {
if (!isCreating) {
editChatFolder({ id: state.folderId!, folderUpdate: state.folder });
} else {
addChatFolder({ folder: state.folder as ApiChatFolder });
}
cb?.();
}, [dispatch, includedChatIds.length, includedChatTypes, isCreating, state.folder, state.folderId]);
const handleSubmit = useCallback(() => {
handleSaveFolder();
dispatch({ type: 'setIsLoading', payload: true });
setTimeout(() => {
onReset();
}, SUBMIT_TIMEOUT);
}, [addChatFolder, dispatch, editChatFolder, includedChatIds.length, includedChatTypes, onReset, state]);
}, [dispatch, handleSaveFolder, onReset]);
const handleCreateInviteClick = useCallback(() => {
if (!invites) return;
// Ignoring global updates is a known drawback here
if (!selectCanShareFolder(getGlobal(), state.folderId!)) {
showNotification({ message: lang('ChatList.Filter.InviteLink.IncludeExcludeError') });
return;
}
if (invites.length < maxInviteLinks) {
if (state.isTouched) {
handleSaveFolder(onShareFolder);
} else {
onShareFolder();
}
} else {
openLimitReachedModal({
limit: 'chatlistInvites',
});
}
}, [handleSaveFolder, invites, lang, maxInviteLinks, onShareFolder, state.folderId, state.isTouched]);
const handleEditInviteClick = useCallback((e: React.MouseEvent<HTMLElement>, url: string) => {
if (state.isTouched) {
handleSaveFolder(() => onOpenInvite(url));
} else {
onOpenInvite(url);
}
}, [handleSaveFolder, onOpenInvite, state.isTouched]);
function renderChatType(key: string, mode: 'included' | 'excluded') {
const chatType = mode === 'included'
@ -226,7 +286,7 @@ const SettingsFoldersEdit: FC<OwnProps & StateProps> = ({
className="settings-content-icon"
/>
{state.mode === 'create' && (
{isCreating && (
<p className="settings-item-description mb-3" dir={lang.isRtl ? 'rtl' : undefined}>
{lang('FilterIncludeInfo')}
</p>
@ -241,39 +301,76 @@ const SettingsFoldersEdit: FC<OwnProps & StateProps> = ({
/>
</div>
<div className="settings-item no-border pt-3">
{state.error && state.error === ERROR_NO_CHATS && (
<p className="settings-item-description color-danger mb-2" dir={lang.isRtl ? 'rtl' : undefined}>
{lang(state.error)}
</p>
)}
{!isOnlyInvites && (
<div className="settings-item pt-3">
{state.error && state.error === ERROR_NO_CHATS && (
<p className="settings-item-description color-danger mb-2" dir={lang.isRtl ? 'rtl' : undefined}>
{lang(state.error)}
</p>
)}
<h4 className="settings-item-header mb-3" dir={lang.isRtl ? 'rtl' : undefined}>{lang('FilterInclude')}</h4>
<h4 className="settings-item-header mb-3" dir={lang.isRtl ? 'rtl' : undefined}>{lang('FilterInclude')}</h4>
<ListItem
className="settings-folders-list-item color-primary mb-0"
icon="add"
onClick={onAddIncludedChats}
>
{lang('FilterAddChats')}
</ListItem>
<ListItem
className="settings-folders-list-item color-primary mb-0"
icon="add"
onClick={onAddIncludedChats}
>
{lang('FilterAddChats')}
</ListItem>
{renderChats('included')}
</div>
{renderChats('included')}
</div>
)}
<div className="settings-item pt-3">
<h4 className="settings-item-header mb-3" dir={lang.isRtl ? 'rtl' : undefined}>{lang('FilterExclude')}</h4>
{!isOnlyInvites && !isEditingChatList && (
<div className="settings-item pt-3">
<h4 className="settings-item-header mb-3" dir={lang.isRtl ? 'rtl' : undefined}>{lang('FilterExclude')}</h4>
<ListItem
className="settings-folders-list-item color-primary mb-0"
icon="add"
onClick={onAddExcludedChats}
>
{lang('FilterAddChats')}
</ListItem>
<ListItem
className="settings-folders-list-item color-primary mb-0"
icon="add"
onClick={onAddExcludedChats}
>
{lang('FilterAddChats')}
</ListItem>
{renderChats('excluded')}
</div>
{renderChats('excluded')}
</div>
)}
{!isCreating && (
<div className="settings-item pt-3">
<h4 className="settings-item-header mb-3" dir={lang.isRtl ? 'rtl' : undefined}>
{lang('FolderLinkScreen.Title')}
</h4>
<ListItem
className="settings-folders-list-item color-primary mb-0"
icon="add"
onClick={handleCreateInviteClick}
>
{lang('ChatListFilter.CreateLinkNew')}
</ListItem>
{invites?.map((invite) => (
<ListItem
className="settings-folders-list-item mb-0"
icon="link"
multiline
// eslint-disable-next-line react/jsx-no-bind
onClick={handleEditInviteClick}
clickArg={invite.url}
>
<span className="title" dir="auto">{invite.title || invite.url}</span>
<span className="subtitle">
{lang('ChatListFilter.LinkLabelChatCount', invite.peerIds.length, 'i')}
</span>
</ListItem>
))}
</div>
)}
</div>
<FloatingActionButton
@ -295,12 +392,14 @@ const SettingsFoldersEdit: FC<OwnProps & StateProps> = ({
export default memo(withGlobal<OwnProps>(
(global, { state }): StateProps => {
const { listIds } = global.chats;
const { byId } = global.chatFolders;
const { byId, invites } = global.chatFolders;
return {
loadedActiveChatIds: listIds.active,
loadedArchivedChatIds: listIds.archived,
invites: state.folderId ? (invites[state.folderId] || MEMO_EMPTY_ARRAY) : undefined,
isRemoved: state.folderId !== undefined && !byId[state.folderId],
maxInviteLinks: selectCurrentLimit(global, 'chatlistInvites'),
};
},
)(SettingsFoldersEdit));

View File

@ -139,6 +139,7 @@ const SettingsFoldersMain: FC<OwnProps & StateProps> = ({
id: folder.id,
title: folder.title,
subtitle: getFolderDescriptionText(lang, folder, chatsCountByFolderId[folder.id]),
isChatList: folder.isChatList,
};
});
}, [folderIds, foldersById, lang, chatsCountByFolderId]);
@ -296,7 +297,10 @@ const SettingsFoldersMain: FC<OwnProps & StateProps> = ({
{renderText(folder.title, ['emoji'])}
{isBlocked && <i className="icon icon-lock-badge settings-folders-blocked-icon" />}
</span>
<span className="subtitle">{folder.subtitle}</span>
<span className="subtitle">
{folder.isChatList && <i className="icon icon-link mr-1" />}
{folder.subtitle}
</span>
</ListItem>
</Draggable>
);

View File

@ -0,0 +1,201 @@
import React, {
memo, useCallback, useEffect, useMemo, useState,
} from '../../../../lib/teact/teact';
import { getActions, getGlobal, withGlobal } from '../../../../global';
import type { FC } from '../../../../lib/teact/teact';
import { STICKER_SIZE_FOLDER_SETTINGS } from '../../../../config';
import { LOCAL_TGS_URLS } from '../../../common/helpers/animatedAssets';
import renderText from '../../../common/helpers/renderText';
import { partition } from '../../../../util/iteratees';
import {
selectCanInviteToChat, selectChat,
selectChatFolder,
selectTabState, selectUser,
} from '../../../../global/selectors';
import { isChatChannel, isUserBot } from '../../../../global/helpers';
import useLang from '../../../../hooks/useLang';
import useHistoryBack from '../../../../hooks/useHistoryBack';
import useEffectWithPrevDeps from '../../../../hooks/useEffectWithPrevDeps';
import AnimatedIcon from '../../../common/AnimatedIcon';
import InviteLink from '../../../common/InviteLink';
import Picker from '../../../common/Picker';
import Spinner from '../../../ui/Spinner';
import FloatingActionButton from '../../../ui/FloatingActionButton';
type OwnProps = {
isActive?: boolean;
onReset: VoidFunction;
};
type StateProps = {
folderId?: number;
title?: string;
includedChatIds?: string[];
pinnedChatIds?: string[];
peerIds?: string[];
url?: string;
isLoading?: boolean;
};
const SettingsShareChatlist: FC<OwnProps & StateProps> = ({
isActive,
onReset,
folderId,
title,
includedChatIds,
pinnedChatIds,
peerIds,
url,
isLoading,
}) => {
const {
createChatlistInvite, deleteChatlistInvite, editChatlistInvite, showNotification,
} = getActions();
const lang = useLang();
const [isTouched, setIsTouched] = useState(false);
useHistoryBack({
isActive,
onBack: onReset,
});
useEffect(() => {
if (!isLoading) {
setIsTouched(false);
}
}, [isLoading]);
useEffect(() => {
if (!url && folderId) {
createChatlistInvite({ folderId });
}
}, [folderId, url]);
const handleRevoke = useCallback(() => {
if (!url || !folderId) return;
deleteChatlistInvite({ folderId, url });
onReset();
}, [folderId, onReset, url]);
const itemIds = useMemo(() => {
return (includedChatIds || []).concat(pinnedChatIds || []);
}, [includedChatIds, pinnedChatIds]);
const [unlockedIds, lockedIds] = useMemo(() => {
const global = getGlobal();
return partition(itemIds, (id) => selectCanInviteToChat(global, id));
}, [itemIds]);
const [selectedIds, setSelectedIds] = useState<string[]>(peerIds || []);
useEffectWithPrevDeps(([prevIsLoading]) => {
if (isLoading && !prevIsLoading) {
setSelectedIds(unlockedIds);
} else if (peerIds) {
setSelectedIds(peerIds);
}
}, [isLoading, unlockedIds, peerIds]);
const handleClickDisabled = useCallback((id: string) => {
const global = getGlobal();
const user = selectUser(global, id);
const chat = selectChat(global, id);
if (user && isUserBot(user)) {
showNotification({
message: lang('FolderLinkScreen.AlertTextUnavailableBot'),
});
} else if (user) {
showNotification({
message: lang('FolderLinkScreen.AlertTextUnavailableUser'),
});
} else if (chat && isChatChannel(chat)) {
showNotification({
message: lang('FolderLinkScreen.AlertTextUnavailablePublicChannel'),
});
} else {
showNotification({
message: lang('FolderLinkScreen.AlertTextUnavailablePublicGroup'),
});
}
}, [lang]);
const handleSelectedIdsChange = useCallback((ids: string[]) => {
setSelectedIds(ids);
setIsTouched(true);
}, []);
const handleSubmit = useCallback(() => {
if (!folderId || !url) return;
editChatlistInvite({ folderId, peerIds: selectedIds, url });
}, [folderId, selectedIds, url]);
const chatsCount = selectedIds.length;
return (
<div className="settings-content no-border custom-scroll">
<div className="settings-content-header">
<AnimatedIcon
size={STICKER_SIZE_FOLDER_SETTINGS}
tgsUrl={LOCAL_TGS_URLS.FoldersShare}
className="settings-content-icon"
/>
<p className="settings-item-description mb-3" dir="auto">
{renderText(lang('FolderLinkScreen.TitleDescriptionSelected', [title, chatsCount]),
['simple_markdown'])}
</p>
</div>
<InviteLink
inviteLink={isLoading ? lang('Loading') : url!}
onRevoke={handleRevoke}
/>
<div className="settings-item settings-item-chatlist">
<Picker
itemIds={itemIds}
lockedIds={lockedIds}
onSelectedIdsChange={handleSelectedIdsChange}
selectedIds={selectedIds}
onDisabledClick={handleClickDisabled}
/>
</div>
<FloatingActionButton
isShown={isLoading || isTouched}
disabled={isLoading}
onClick={handleSubmit}
ariaLabel="Save changes"
>
{isLoading ? (
<Spinner color="white" />
) : (
<i className="icon icon-check" />
)}
</FloatingActionButton>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global): StateProps => {
const { folderId, url, isLoading } = selectTabState(global).shareFolderScreen || {};
const folder = folderId ? selectChatFolder(global, folderId) : undefined;
const invite = folderId ? global.chatFolders.invites[folderId]?.find((i) => i.url === url) : undefined;
return {
folderId,
title: folder?.title,
includedChatIds: folder?.includedChatIds,
pinnedChatIds: folder?.pinnedChatIds,
url,
isLoading,
peerIds: invite?.peerIds,
};
},
)(SettingsShareChatlist));

View File

@ -7,8 +7,8 @@ import type { OwnProps } from './DeleteFolderDialog';
import useModuleLoader from '../../hooks/useModuleLoader';
const DeleteFolderDialogAsync: FC<OwnProps> = (props) => {
const { deleteFolderDialogId } = props;
const DeleteFolderDialog = useModuleLoader(Bundles.Extra, 'DeleteFolderDialog', !deleteFolderDialogId);
const { folder } = props;
const DeleteFolderDialog = useModuleLoader(Bundles.Extra, 'DeleteFolderDialog', !folder);
// eslint-disable-next-line react/jsx-props-no-spreading
return DeleteFolderDialog ? <DeleteFolderDialog {...props} /> : undefined;

View File

@ -1,31 +1,43 @@
import type { FC } from '../../lib/teact/teact';
import React, { memo, useCallback } from '../../lib/teact/teact';
import { getActions } from '../../global';
import type { FC } from '../../lib/teact/teact';
import type { ApiChatFolder } from '../../api/types';
import usePrevious from '../../hooks/usePrevious';
import useLang from '../../hooks/useLang';
import ConfirmDialog from '../ui/ConfirmDialog';
export type OwnProps = {
deleteFolderDialogId?: number;
folder?: ApiChatFolder;
};
const DeleteFolderDialog: FC<OwnProps> = ({
deleteFolderDialogId,
folder,
}) => {
const { closeDeleteChatFolderModal, deleteChatFolder } = getActions();
const { closeDeleteChatFolderModal, deleteChatFolder, openDeleteChatFolderModal } = getActions();
const lang = useLang();
const isOpen = Boolean(folder);
const renderingFolder = usePrevious(folder) || folder;
const isMyChatlist = renderingFolder?.hasMyInvites;
const handleDeleteFolderMessage = useCallback(() => {
closeDeleteChatFolderModal();
deleteChatFolder({ id: deleteFolderDialogId! });
}, [closeDeleteChatFolderModal, deleteChatFolder, deleteFolderDialogId]);
if (isMyChatlist) {
openDeleteChatFolderModal({ folderId: renderingFolder!.id, isConfirmedForChatlist: true });
} else {
deleteChatFolder({ id: renderingFolder!.id });
}
}, [isMyChatlist, renderingFolder]);
return (
<ConfirmDialog
isOpen={deleteFolderDialogId !== undefined}
isOpen={isOpen}
onClose={closeDeleteChatFolderModal}
text={lang('FilterDeleteAlert')}
text={isMyChatlist ? lang('FilterDeleteAlertLinks') : lang('FilterDeleteAlert')}
confirmLabel={lang('Delete')}
confirmHandler={handleDeleteFolderMessage}
confirmIsDestructive

View File

@ -9,7 +9,10 @@ import { getActions, getGlobal, withGlobal } from '../../global';
import type { LangCode } from '../../types';
import type {
ApiAttachBot,
ApiChat, ApiMessage, ApiUser,
ApiChat,
ApiChatFolder,
ApiMessage,
ApiUser,
} from '../../api/types';
import type { ApiLimitTypeWithModal, TabState } from '../../global/types';
@ -31,6 +34,7 @@ import {
selectIsReactionPickerOpen,
selectPerformanceSettingsValue,
selectCanAnimateInterface,
selectChatFolder,
} from '../../global/selectors';
import buildClassName from '../../util/buildClassName';
import { waitForTransitionEnd } from '../../util/cssAnimationEndListeners';
@ -86,6 +90,7 @@ import CustomEmojiSetsModal from '../common/CustomEmojiSetsModal.async';
import DraftRecipientPicker from './DraftRecipientPicker.async';
import AttachBotRecipientPicker from './AttachBotRecipientPicker.async';
import ReactionPicker from '../middle/message/ReactionPicker.async';
import ChatlistModal from '../modals/chatlist/ChatlistModal.async';
import './Main.scss';
@ -132,11 +137,12 @@ type StateProps = {
currentUser?: ApiUser;
urlAuth?: TabState['urlAuth'];
limitReached?: ApiLimitTypeWithModal;
deleteFolderDialogId?: number;
deleteFolderDialog?: ApiChatFolder;
isPaymentModalOpen?: boolean;
isReceiptModalOpen?: boolean;
isReactionPickerOpen: boolean;
isCurrentUserPremium?: boolean;
chatlistModal?: TabState['chatlistModal'];
noRightColumnAnimation?: boolean;
withInterfaceAnimations?: boolean;
};
@ -191,8 +197,9 @@ const Main: FC<OwnProps & StateProps> = ({
isReceiptModalOpen,
isReactionPickerOpen,
isCurrentUserPremium,
deleteFolderDialogId,
deleteFolderDialog,
isMasterTab,
chatlistModal,
noRightColumnAnimation,
}) => {
const {
@ -509,6 +516,7 @@ const Main: FC<OwnProps & StateProps> = ({
userId={newContactUserId}
isByPhoneNumber={newContactByPhoneNumber}
/>
<ChatlistModal info={chatlistModal} />
<GameModal openedGame={openedGame} gameTitle={gameTitle} />
<WebAppModal webApp={webApp} />
<DownloadManager />
@ -528,7 +536,7 @@ const Main: FC<OwnProps & StateProps> = ({
<PremiumLimitReachedModal limit={limitReached} />
<PaymentModal isOpen={isPaymentModalOpen} onClose={closePaymentModal} />
<ReceiptModal isOpen={isReceiptModalOpen} onClose={clearReceipt} />
<DeleteFolderDialog deleteFolderDialogId={deleteFolderDialogId} />
<DeleteFolderDialog folder={deleteFolderDialog} />
<ReactionPicker isOpen={isReactionPickerOpen} shouldLoad={shouldLoadReactionPicker} />
</div>
);
@ -569,6 +577,7 @@ export default memo(withGlobal<OwnProps>(
payment,
limitReachedModal,
deleteFolderDialogModal,
chatlistModal,
} = selectTabState(global);
const { chatId: audioChatId, messageId: audioMessageId } = audioPlayer;
@ -582,6 +591,8 @@ export default memo(withGlobal<OwnProps>(
const noRightColumnAnimation = !selectPerformanceSettingsValue(global, 'rightColumnAnimations')
|| !selectCanAnimateInterface(global);
const deleteFolderDialog = deleteFolderDialogModal ? selectChatFolder(global, deleteFolderDialogModal) : undefined;
return {
lastSyncTime,
isLeftColumnOpen: isLeftColumnShown,
@ -623,9 +634,10 @@ export default memo(withGlobal<OwnProps>(
limitReached: limitReachedModal?.limit,
isPaymentModalOpen: payment.isPaymentModalOpen,
isReceiptModalOpen: Boolean(payment.receipt),
deleteFolderDialogId: deleteFolderDialogModal,
deleteFolderDialog,
isMasterTab,
requestedDraft,
chatlistModal,
noRightColumnAnimation,
};
},

View File

@ -76,7 +76,7 @@ const PREMIUM_BOTTOM_VIDEOS: string[] = [
'emoji_status',
];
type ApiLimitTypeWithoutUpload = Exclude<ApiLimitType, 'uploadMaxFileparts'>;
type ApiLimitTypeWithoutUpload = Exclude<ApiLimitType, 'uploadMaxFileparts' | 'chatlistInvites' | 'chatlistJoined'>;
const LIMITS_ORDER: ApiLimitTypeWithoutUpload[] = [
'channels',

View File

@ -26,6 +26,8 @@ const LIMIT_DESCRIPTION: Record<ApiLimitTypeWithModal, string> = {
dialogFolderPinned: 'LimitReachedPinDialogs',
channelsPublic: 'LimitReachedPublicLinks',
channels: 'LimitReachedCommunities',
chatlistInvites: 'LimitReachedFolderLinks',
chatlistJoined: 'LimitReachedSharedFolders',
};
const LIMIT_DESCRIPTION_BLOCKED: Record<ApiLimitTypeWithModal, string> = {
@ -35,6 +37,8 @@ const LIMIT_DESCRIPTION_BLOCKED: Record<ApiLimitTypeWithModal, string> = {
dialogFolderPinned: 'LimitReachedPinDialogsLocked',
channelsPublic: 'LimitReachedPublicLinksLocked',
channels: 'LimitReachedCommunitiesLocked',
chatlistInvites: 'LimitReachedFolderLinksLocked',
chatlistJoined: 'LimitReachedSharedFoldersLocked',
};
const LIMIT_DESCRIPTION_PREMIUM: Record<ApiLimitTypeWithModal, string> = {
@ -44,6 +48,8 @@ const LIMIT_DESCRIPTION_PREMIUM: Record<ApiLimitTypeWithModal, string> = {
dialogFolderPinned: 'LimitReachedPinDialogsPremium',
channelsPublic: 'LimitReachedPublicLinksPremium',
channels: 'LimitReachedCommunitiesPremium',
chatlistInvites: 'LimitReachedFolderLinksPremium',
chatlistJoined: 'LimitReachedSharedFoldersPremium',
};
const LIMIT_ICON: Record<ApiLimitTypeWithModal, string> = {
@ -53,6 +59,8 @@ const LIMIT_ICON: Record<ApiLimitTypeWithModal, string> = {
dialogFolderPinned: 'icon-pin-badge',
channelsPublic: 'icon-link-badge',
channels: 'icon-chats-badge',
chatlistInvites: 'icon-link-badge',
chatlistJoined: 'icon-folder-badge',
};
const LIMIT_VALUE_FORMATTER: Partial<Record<ApiLimitTypeWithModal, (...args: any[]) => string>> = {

View File

@ -0,0 +1,111 @@
import React, { memo, useCallback, useState } from '../../../lib/teact/teact';
import { getActions } from '../../../global';
import type { FC } from '../../../lib/teact/teact';
import type { ApiChatFolder, ApiChatlistInviteAlready } from '../../../api/types';
import renderText from '../../common/helpers/renderText';
import buildClassName from '../../../util/buildClassName';
import useLang from '../../../hooks/useLang';
import Button from '../../ui/Button';
import Picker from '../../common/Picker';
import Badge from '../../ui/Badge';
import styles from './ChatlistModal.module.scss';
type OwnProps = {
invite: ApiChatlistInviteAlready;
folder: ApiChatFolder;
};
const ChatlistAlready: FC<OwnProps> = ({ invite, folder }) => {
const { closeChatlistModal, joinChatlistInvite } = getActions();
const lang = useLang();
const [selectedPeerIds, setSelectedPeerIds] = useState<string[]>(invite.missingPeerIds);
const hasChatsToAdd = Boolean(invite.missingPeerIds.length);
const newChatsCount = hasChatsToAdd ? invite.missingPeerIds.length : 0;
const badgeText = selectedPeerIds.length ? selectedPeerIds.length.toString() : undefined;
const descriptionText = hasChatsToAdd
? lang('FolderLinkSubtitleChats', [newChatsCount, folder.title], undefined, newChatsCount)
: lang('FolderLinkSubtitleAlready', folder.title);
const handleButtonClick = useCallback(() => {
closeChatlistModal();
if (!selectedPeerIds.length) return;
joinChatlistInvite({
invite,
peerIds: selectedPeerIds,
});
}, [invite, selectedPeerIds]);
const handleSelectionToggle = useCallback(() => {
const areAllSelected = selectedPeerIds.length === invite.missingPeerIds.length;
setSelectedPeerIds(areAllSelected ? [] : invite.missingPeerIds);
}, [invite.missingPeerIds, selectedPeerIds.length]);
return (
<div className={styles.content}>
<div className={styles.description}>
{renderText(descriptionText, ['simple_markdown', 'emoji'])}
</div>
<div className={buildClassName(styles.pickerWrapper, 'custom-scroll')}>
{Boolean(invite.missingPeerIds.length) && (
<>
<div className={styles.pickerHeader}>
<div className={styles.pickerHeaderInfo}>
{lang('FolderLinkHeaderChatsJoin', selectedPeerIds.length, 'i')}
</div>
<div
className={styles.selectionToggle}
role="button"
tabIndex={0}
onClick={handleSelectionToggle}
>
{selectedPeerIds.length === invite.missingPeerIds.length ? lang('DeselectAll') : lang('SelectAll')}
</div>
</div>
<Picker
itemIds={invite.missingPeerIds}
onSelectedIdsChange={setSelectedPeerIds}
selectedIds={selectedPeerIds}
/>
</>
)}
<div className={styles.pickerHeader}>
<div className={styles.pickerHeaderInfo}>
{lang('FolderLinkHeaderAlready')}
</div>
</div>
<Picker
itemIds={invite.alreadyPeerIds}
lockedIds={invite.alreadyPeerIds}
selectedIds={invite.alreadyPeerIds}
/>
</div>
<Button
size="smaller"
onClick={handleButtonClick}
>
<div className={styles.buttonText}>
{!selectedPeerIds.length && lang('OK')}
{Boolean(selectedPeerIds.length) && (
<>
{lang('FolderLinkButtonJoinPlural')}
<Badge className={styles.buttonBadge} text={badgeText} isAlternateColor />
</>
)}
</div>
</Button>
</div>
);
};
export default memo(ChatlistAlready);

View File

@ -0,0 +1,93 @@
import React, { memo, useCallback, useState } from '../../../lib/teact/teact';
import { getActions } from '../../../global';
import type { FC } from '../../../lib/teact/teact';
import type { ApiChatFolder } from '../../../api/types';
import renderText from '../../common/helpers/renderText';
import { MEMO_EMPTY_ARRAY } from '../../../util/memo';
import buildClassName from '../../../util/buildClassName';
import useLang from '../../../hooks/useLang';
import Button from '../../ui/Button';
import Badge from '../../ui/Badge';
import Picker from '../../common/Picker';
import styles from './ChatlistModal.module.scss';
type OwnProps = {
folder: ApiChatFolder;
suggestedPeerIds?: string[];
};
const ChatlistDelete: FC<OwnProps> = ({
folder,
suggestedPeerIds = MEMO_EMPTY_ARRAY,
}) => {
const { closeChatlistModal, leaveChatlist } = getActions();
const lang = useLang();
const [selectedPeerIds, setSelectedPeerIds] = useState<string[]>(suggestedPeerIds);
const badgeText = selectedPeerIds.length ? selectedPeerIds.length.toString() : undefined;
const handleSelectionToggle = useCallback(() => {
const areAllSelected = selectedPeerIds.length === suggestedPeerIds.length;
setSelectedPeerIds(areAllSelected ? [] : suggestedPeerIds);
}, [suggestedPeerIds, selectedPeerIds.length]);
const handleButtonClick = useCallback(() => {
closeChatlistModal();
leaveChatlist({ folderId: folder.id, peerIds: selectedPeerIds });
}, [folder.id, selectedPeerIds]);
return (
<div className={styles.content}>
{Boolean(suggestedPeerIds?.length) && (
<>
<div className={styles.description}>
{renderText(lang('FolderLinkSubtitleRemove'), ['simple_markdown', 'emoji'])}
</div>
<div className={buildClassName(styles.pickerWrapper, 'custom-scroll')}>
<div className={styles.pickerHeader}>
<div className={styles.pickerHeaderInfo}>
{lang('FolderLinkHeaderChatsQuit', selectedPeerIds.length, 'i')}
</div>
<div
className={styles.selectionToggle}
role="button"
tabIndex={0}
onClick={handleSelectionToggle}
>
{selectedPeerIds.length === suggestedPeerIds.length ? lang('DeselectAll') : lang('SelectAll')}
</div>
</div>
<Picker
itemIds={suggestedPeerIds}
onSelectedIdsChange={setSelectedPeerIds}
selectedIds={selectedPeerIds}
/>
</div>
</>
)}
<Button
size="smaller"
onClick={handleButtonClick}
>
<div className={styles.buttonText}>
{!selectedPeerIds.length && lang('FolderLinkButtonRemove')}
{Boolean(selectedPeerIds.length) && (
<>
{lang('FolderLinkButtonRemoveChats')}
<Badge className={styles.buttonBadge} text={badgeText} isAlternateColor />
</>
)}
</div>
</Button>
</div>
);
};
export default memo(ChatlistDelete);

View File

@ -0,0 +1,17 @@
import type { FC } from '../../../lib/teact/teact';
import React, { memo } from '../../../lib/teact/teact';
import { Bundles } from '../../../util/moduleLoader';
import type { OwnProps } from './ChatlistModal';
import useModuleLoader from '../../../hooks/useModuleLoader';
const ChatlistModalAsync: FC<OwnProps> = (props) => {
const { info } = props;
const ChatlistModal = useModuleLoader(Bundles.Extra, 'ChatlistModal', !info);
// eslint-disable-next-line react/jsx-props-no-spreading
return ChatlistModal ? <ChatlistModal {...props} /> : undefined;
};
export default memo(ChatlistModalAsync);

View File

@ -0,0 +1,71 @@
.description {
text-align: center;
color: var(--color-text-secondary);
margin-bottom: 1rem;
}
.picker-wrapper {
overflow: auto;
max-height: 18rem;
}
.picker-header {
display: flex;
justify-content: space-between;
}
.picker-header-info {
font-weight: 500;
color: var(--color-text-secondary);
}
.selection-toggle {
color: var(--color-links);
cursor: var(--custom-cursor, pointer);
}
.foldersWrapper {
display: flex;
justify-content: center;
}
.folders {
display: flex;
justify-content: center;
gap: 0.5rem;
position: relative;
margin-bottom: 1rem;
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: linear-gradient(
to right,
var(--color-background) 0%,
rgba(0, 0, 0, 0) 40%,
rgba(0, 0, 0, 0) 60%,
var(--color-background) 100%
);
}
}
.folder {
flex-grow: 0;
}
.button-text {
position: relative;
}
.button-badge {
position: absolute;
right: -0.25rem;
top: 50%;
transform: translate(100%, -50%);
}

View File

@ -0,0 +1,123 @@
import React, { memo, useCallback, useMemo } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { FC } from '../../../lib/teact/teact';
import type { TabState } from '../../../global/types';
import type { ApiChatFolder } from '../../../api/types';
import { selectChatFolder } from '../../../global/selectors';
import usePrevious from '../../../hooks/usePrevious';
import useLang from '../../../hooks/useLang';
import ChatlistNew from './ChatlistNew';
import ChatlistAlready from './ChatlistAlready';
import ChatlistDelete from './ChatlistDelete';
import Modal from '../../ui/Modal';
import Tab from '../../ui/Tab';
import styles from './ChatlistModal.module.scss';
export type OwnProps = {
info?: TabState['chatlistModal'];
};
type StateProps = {
folder?: ApiChatFolder;
};
const ChatlistInviteModal: FC<OwnProps & StateProps> = ({
info,
folder,
}) => {
const { closeChatlistModal } = getActions();
const lang = useLang();
const isOpen = Boolean(info);
const renderingInfo = usePrevious(info) || info;
const renderingFolder = usePrevious(folder) || folder;
const title = useMemo(() => {
if (!renderingInfo) return undefined;
if (renderingInfo.invite) {
const invite = renderingInfo.invite;
if ('alreadyPeerIds' in invite) {
return invite.missingPeerIds.length ? lang('FolderLinkTitleAddChats') : lang('FolderLinkTitleAlready');
}
return lang('FolderLinkTitleAdd');
}
if (renderingInfo.removal) {
return lang('FolderLinkTitleRemove');
}
return undefined;
}, [lang, renderingInfo]);
const renderingFolderTitle = useMemo(() => {
if (renderingFolder) return renderingFolder.title;
if (renderingInfo?.invite && 'title' in renderingInfo.invite) return renderingInfo.invite.title;
return undefined;
}, [renderingFolder, renderingInfo]);
const folderTabNumber = useMemo(() => {
if (!renderingInfo?.invite) return undefined;
if ('missingPeerIds' in renderingInfo.invite) return renderingInfo.invite.missingPeerIds.length;
return undefined;
}, [renderingInfo]);
function renderFolders(folderTitle: string) {
return (
<div className={styles.foldersWrapper}>
<div className={styles.folders}>
<Tab className={styles.folder} title={lang('FolderLinkPreviewLeft')} />
<Tab className={styles.folder} isActive badgeCount={folderTabNumber} isBadgeActive title={folderTitle} />
<Tab className={styles.folder} title={lang('FolderLinkPreviewRight')} />
</div>
</div>
);
}
const renderContent = useCallback(() => {
if (!renderingInfo) return undefined;
if (renderingInfo.invite) {
const invite = renderingInfo.invite;
if ('alreadyPeerIds' in invite) {
return <ChatlistAlready invite={invite} folder={renderingFolder!} />;
}
return <ChatlistNew invite={invite} />;
}
if (renderingInfo.removal) {
return <ChatlistDelete folder={renderingFolder!} suggestedPeerIds={renderingInfo.removal.suggestedPeerIds} />;
}
return undefined;
}, [renderingFolder, renderingInfo]);
return (
<Modal
isOpen={isOpen}
title={title}
onClose={closeChatlistModal}
isSlim
hasCloseButton
>
{renderingFolderTitle && renderFolders(renderingFolderTitle)}
{renderContent()}
</Modal>
);
};
export default memo(withGlobal<OwnProps>(
(global, { info }): StateProps => {
const { invite, removal } = info || {};
const folderId = removal?.folderId || (invite && 'folderId' in invite ? invite.folderId : undefined);
const folder = folderId ? selectChatFolder(global, folderId) : undefined;
return {
folder,
};
},
)(ChatlistInviteModal));

View File

@ -0,0 +1,93 @@
import React, {
memo, useCallback, useMemo, useState,
} from '../../../lib/teact/teact';
import { getActions, getGlobal } from '../../../global';
import type { FC } from '../../../lib/teact/teact';
import type { ApiChatlistInviteNew } from '../../../api/types';
import renderText from '../../common/helpers/renderText';
import buildClassName from '../../../util/buildClassName';
import useLang from '../../../hooks/useLang';
import Button from '../../ui/Button';
import Picker from '../../common/Picker';
import Badge from '../../ui/Badge';
import styles from './ChatlistModal.module.scss';
type OwnProps = {
invite: ApiChatlistInviteNew;
};
const ChatlistNew: FC<OwnProps> = ({ invite }) => {
const { closeChatlistModal, joinChatlistInvite } = getActions();
const lang = useLang();
const [selectedPeerIds, setSelectedPeerIds] = useState<string[]>(invite.peerIds);
const joinedIds = useMemo(() => {
const chatsById = getGlobal().chats.byId;
return invite.peerIds.filter((id) => !chatsById[id].isNotJoined);
}, [invite.peerIds]);
const selectedCount = selectedPeerIds.length - joinedIds.length;
const badgeText = selectedCount ? selectedCount.toString() : undefined;
const handleButtonClick = useCallback(() => {
closeChatlistModal();
joinChatlistInvite({
invite,
peerIds: selectedPeerIds,
});
}, [invite, selectedPeerIds]);
const handleSelectionToggle = useCallback(() => {
const areAllSelected = selectedPeerIds.length === invite.peerIds.length;
setSelectedPeerIds(areAllSelected ? joinedIds : invite.peerIds);
}, [invite.peerIds, joinedIds, selectedPeerIds.length]);
return (
<div className={styles.content}>
<div className={styles.description}>
{renderText(lang('FolderLinkSubtitle', invite.title), ['simple_markdown', 'emoji'])}
</div>
<div className={buildClassName(styles.pickerWrapper, 'custom-scroll')}>
<div className={styles.pickerHeader}>
<div className={styles.pickerHeaderInfo}>
{lang('FolderLinkHeaderChatsJoin', selectedCount, 'i')}
</div>
<div
className={styles.selectionToggle}
role="button"
tabIndex={0}
onClick={handleSelectionToggle}
>
{selectedPeerIds.length === invite.peerIds.length ? lang('DeselectAll') : lang('SelectAll')}
</div>
</div>
<Picker
itemIds={invite.peerIds}
lockedIds={joinedIds}
onSelectedIdsChange={setSelectedPeerIds}
selectedIds={selectedPeerIds}
/>
</div>
<Button
onClick={handleButtonClick}
size="smaller"
disabled={!selectedPeerIds.length}
>
<div className={styles.buttonText}>
{lang('FolderLinkButtonAdd', invite.title)}
<Badge className={styles.buttonBadge} text={badgeText} isAlternateColor />
</div>
</Button>
</div>
);
};
export default memo(ChatlistNew);

View File

@ -128,6 +128,7 @@ const AddChatMembers: FC<OwnProps & StateProps> = ({
isLoading={isSearching}
onSelectedIdsChange={setSelectedMemberIds}
onFilterChange={handleFilterChange}
isSearchable
noScrollRestore={noPickerScrollRestore}
/>

View File

@ -31,6 +31,8 @@ type StateProps = {
isChannel?: boolean;
};
const BULLET = '\u2022';
const ManageInviteInfo: FC<OwnProps & StateProps> = ({
chatId,
invite,
@ -83,19 +85,23 @@ const ManageInviteInfo: FC<OwnProps & StateProps> = ({
{!importers.length && (
usageLimit ? lang('PeopleCanJoinViaLinkCount', usageLimit - usage) : lang('NoOneJoinedYet')
)}
{importers.map((importer) => (
<ListItem
className="chat-item-clickable scroll-item small-icon"
// eslint-disable-next-line react/jsx-no-bind
onClick={() => openChat({ id: importer.userId })}
>
<PrivateChatInfo
userId={importer.userId}
status={formatMediaDateTime(lang, importer.date * 1000, true)}
forceShowSelf
/>
</ListItem>
))}
{importers.map((importer) => {
const joinTime = formatMediaDateTime(lang, importer.date * 1000, true);
const status = importer.isFromChatList ? `${joinTime} ${BULLET} ${lang('JoinedViaFolder')}` : joinTime;
return (
<ListItem
className="chat-item-clickable scroll-item small-icon"
// eslint-disable-next-line react/jsx-no-bind
onClick={() => openChat({ id: importer.userId })}
>
<PrivateChatInfo
userId={importer.userId}
status={status}
forceShowSelf
/>
</ListItem>
);
})}
</p>
</div>
);

View File

@ -19,15 +19,13 @@ import useLang from '../../../hooks/useLang';
import useInterval from '../../../hooks/useInterval';
import useForceUpdate from '../../../hooks/useForceUpdate';
import useFlag from '../../../hooks/useFlag';
import useAppLayout from '../../../hooks/useAppLayout';
import ListItem from '../../ui/ListItem';
import NothingFound from '../../common/NothingFound';
import Button from '../../ui/Button';
import DropdownMenu from '../../ui/DropdownMenu';
import MenuItem from '../../ui/MenuItem';
import ConfirmDialog from '../../ui/ConfirmDialog';
import AnimatedIcon from '../../common/AnimatedIcon';
import InviteLink from '../../common/InviteLink';
type OwnProps = {
chatId: string;
@ -79,7 +77,6 @@ const ManageInvites: FC<OwnProps & StateProps> = ({
const [revokingInvite, setRevokingInvite] = useState<ApiExportedInvite | undefined>();
const [isDeleteDialogOpen, openDeleteDialog, closeDeleteDialog] = useFlag();
const [deletingInvite, setDeletingInvite] = useState<ApiExportedInvite | undefined>();
const { isMobile } = useAppLayout();
useHistoryBack({
isActive,
@ -181,10 +178,6 @@ const ManageInvites: FC<OwnProps & StateProps> = ({
});
}, [lang, showNotification]);
const handleCopyPrimaryClicked = useCallback(() => {
copyLink(primaryInviteLink!);
}, [copyLink, primaryInviteLink]);
const prepareUsageText = (invite: ApiExportedInvite) => {
const {
usage = 0, usageLimit, expireDate, isPermanent, requested, isRevoked,
@ -277,22 +270,6 @@ const ManageInvites: FC<OwnProps & StateProps> = ({
return actions;
};
const PrimaryLinkMenuButton: FC<{ onTrigger: () => void; isOpen?: boolean }> = useMemo(() => {
return ({ onTrigger, isOpen }) => (
<Button
round
ripple={!isMobile}
size="smaller"
color="translucent"
className={isOpen ? 'active' : ''}
onClick={onTrigger}
ariaLabel="Actions"
>
<i className="icon icon-more" />
</Button>
);
}, [isMobile]);
return (
<div className="Management ManageInvites">
<div className="custom-scroll">
@ -305,30 +282,11 @@ const ManageInvites: FC<OwnProps & StateProps> = ({
<p className="text-muted">{isChannel ? lang('PrimaryLinkHelpChannel') : lang('PrimaryLinkHelp')}</p>
</div>
{primaryInviteLink && (
<div className="section">
<p className="text-muted">
{chat?.usernames ? lang('PublicLink') : lang('lng_create_permanent_link_title')}
</p>
<div className="primary-link">
<input
className="form-control primary-link-input"
value={primaryInviteLink}
readOnly
onClick={handleCopyPrimaryClicked}
/>
<DropdownMenu
className="primary-link-more-menu"
trigger={PrimaryLinkMenuButton}
positionX="right"
>
<MenuItem icon="copy" onClick={handleCopyPrimaryClicked}>{lang('Copy')}</MenuItem>
{!chat?.usernames && (
<MenuItem icon="delete" onClick={handlePrimaryRevoke} destructive>{lang('RevokeButton')}</MenuItem>
)}
</DropdownMenu>
</div>
<Button onClick={handleCopyPrimaryClicked}>{lang('CopyLink')}</Button>
</div>
<InviteLink
inviteLink={primaryInviteLink}
onRevoke={!chat?.usernames ? handlePrimaryRevoke : undefined}
title={chat?.usernames ? lang('PublicLink') : lang('lng_create_permanent_link_title')}
/>
)}
<div className="section" teactFastList>
<Button isText key="create" className="create-link" onClick={handleCreateNewClick}>

View File

@ -200,24 +200,6 @@
}
.ManageInvites {
.primary-link {
position: relative;
}
.primary-link-input {
cursor: var(--custom-cursor, pointer);
margin-bottom: 1rem;
padding-right: 3rem;
}
.primary-link-more-menu {
position: absolute;
right: 0.5rem;
top: 50%;
transform: translate(0, -50%);
z-index: 1;
}
.create-link {
margin-bottom: 0.5rem;
}

View File

@ -0,0 +1,21 @@
.root {
min-width: 1.5rem;
height: 1.5rem;
border-radius: 0.75rem;
padding: 0 0.4375rem;
font-size: 0.875rem;
line-height: 1.5625rem;
font-weight: 500;
text-align: center;
flex-shrink: 0;
}
.default {
background: var(--color-gray);
color: var(--color-white);
}
.alternate {
color: var(--color-chat-active);
background: var(--color-white);
}

View File

@ -0,0 +1,33 @@
import React, { memo } from '../../lib/teact/teact';
import type { FC } from '../../lib/teact/teact';
import buildClassName from '../../util/buildClassName';
import AnimatedCounter from '../common/AnimatedCounter';
import ShowTransition from './ShowTransition';
import styles from './Badge.module.scss';
type OwnProps = {
text?: string;
className?: string;
isAlternateColor?: boolean;
};
const Badge: FC<OwnProps> = ({
text,
className,
isAlternateColor,
}) => {
return (
<ShowTransition
className={buildClassName(styles.root, isAlternateColor ? styles.alternate : styles.default, className)}
isOpen={Boolean(text)}
>
{text && <AnimatedCounter text={text} />}
</ShowTransition>
);
};
export default memo(Badge);

View File

@ -10,7 +10,6 @@
padding: 0.625rem 0.25rem;
font-weight: 500;
color: var(--color-text-secondary);
cursor: var(--custom-cursor, pointer);
border-top-left-radius: var(--border-radius-messages-small);
border-top-right-radius: var(--border-radius-messages-small);
@ -20,6 +19,22 @@
outline: none;
}
&--interactive {
cursor: var(--custom-cursor, pointer);
@media (hover: hover) {
&:not(&--active):hover {
background: var(--color-interactive-element-hover);
}
}
@media (max-width: 600px) {
&:not(&--active):active {
background: var(--color-interactive-element-hover);
}
}
}
&--active {
cursor: var(--custom-cursor, default);
color: var(--color-primary);
@ -29,18 +44,6 @@
}
}
@media (hover: hover) {
&:not(&--active):hover {
background: var(--color-interactive-element-hover);
}
}
@media (max-width: 600px) {
&:not(&--active):active {
background: var(--color-interactive-element-hover);
}
}
> span {
position: relative;
display: flex;
@ -102,3 +105,7 @@
}
}
}
.Tab-context-menu {
position: absolute;
}

View File

@ -1,4 +1,3 @@
import type { FC } from '../../lib/teact/teact';
import React, {
useRef,
memo,
@ -7,10 +6,19 @@ import React, {
} from '../../lib/teact/teact';
import { requestForcedReflow, requestMutation } from '../../lib/fasterdom/fasterdom';
import type { FC } from '../../lib/teact/teact';
import type { MenuItemContextAction } from './ListItem';
import { IS_TOUCH_ENV, MouseButton } from '../../util/windowEnvironment';
import forceReflow from '../../util/forceReflow';
import buildClassName from '../../util/buildClassName';
import renderText from '../common/helpers/renderText';
import useMenuPosition from '../../hooks/useMenuPosition';
import useContextMenuHandlers from '../../hooks/useContextMenuHandlers';
import Menu from './Menu';
import MenuItem from './MenuItem';
import MenuSeparator from './MenuSeparator';
import './Tab.scss';
@ -22,8 +30,10 @@ type OwnProps = {
badgeCount?: number;
isBadgeActive?: boolean;
previousActiveTab?: number;
onClick: (arg: number) => void;
clickArg: number;
onClick?: (arg: number) => void;
clickArg?: number;
contextActions?: MenuItemContextAction[];
contextRootElementSelector?: string;
};
const classNames = {
@ -41,6 +51,8 @@ const Tab: FC<OwnProps> = ({
previousActiveTab,
onClick,
clickArg,
contextActions,
contextRootElementSelector,
}) => {
// eslint-disable-next-line no-null/no-null
const tabRef = useRef<HTMLDivElement>(null);
@ -95,19 +107,59 @@ const Tab: FC<OwnProps> = ({
});
}, [isActive, previousActiveTab]);
const {
contextMenuPosition, handleContextMenu, handleBeforeContextMenu, handleContextMenuClose,
handleContextMenuHide, isContextMenuOpen,
} = useContextMenuHandlers(tabRef, !contextActions);
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (contextActions && (e.button === MouseButton.Secondary || !onClick)) {
handleBeforeContextMenu(e);
}
if (e.type === 'mousedown' && e.button !== MouseButton.Main) {
return;
}
onClick(clickArg);
}, [clickArg, onClick]);
onClick?.(clickArg!);
}, [clickArg, contextActions, handleBeforeContextMenu, onClick]);
const getTriggerElement = useCallback(() => tabRef.current, []);
const getRootElement = useCallback(
() => (contextRootElementSelector
? tabRef.current!.closest(contextRootElementSelector)
: document.body),
[contextRootElementSelector],
);
const getMenuElement = useCallback(
() => document.querySelector('#portals')!
.querySelector('.Tab-context-menu .bubble'),
[],
);
const getLayout = useCallback(
() => ({ withPortal: true }),
[],
);
const {
positionX, positionY, transformOriginX, transformOriginY, style: menuStyle,
} = useMenuPosition(
contextMenuPosition,
getTriggerElement,
getRootElement,
getMenuElement,
getLayout,
);
return (
<div
className={buildClassName('Tab', className)}
className={buildClassName('Tab', onClick && 'Tab--interactive', className)}
onClick={IS_TOUCH_ENV ? handleClick : undefined}
onMouseDown={!IS_TOUCH_ENV ? handleClick : undefined}
onContextMenu={handleContextMenu}
ref={tabRef}
>
<span className="Tab_inner">
@ -118,6 +170,38 @@ const Tab: FC<OwnProps> = ({
{isBlocked && <i className="icon icon-lock-badge blocked" />}
<i className="platform" />
</span>
{contextActions && contextMenuPosition !== undefined && (
<Menu
isOpen={isContextMenuOpen}
transformOriginX={transformOriginX}
transformOriginY={transformOriginY}
positionX={positionX}
positionY={positionY}
style={menuStyle}
className="Tab-context-menu"
autoClose
onClose={handleContextMenuClose}
onCloseAnimationEnd={handleContextMenuHide}
withPortal
>
{contextActions.map((action) => (
('isSeparator' in action) ? (
<MenuSeparator key={action.key || 'separator'} />
) : (
<MenuItem
key={action.title}
icon={action.icon}
destructive={action.destructive}
disabled={!action.handler}
onClick={action.handler}
>
{action.title}
</MenuItem>
)
))}
</Menu>
)}
</div>
);
};

View File

@ -1,6 +1,8 @@
import type { FC } from '../../lib/teact/teact';
import React, { memo, useRef, useEffect } from '../../lib/teact/teact';
import type { FC } from '../../lib/teact/teact';
import type { MenuItemContextAction } from './ListItem';
import { ALL_FOLDER_ID } from '../../config';
import { IS_ANDROID, IS_IOS } from '../../util/windowEnvironment';
import animateHorizontalScroll from '../../util/animateHorizontalScroll';
@ -19,6 +21,7 @@ export type TabWithProperties = {
badgeCount?: number;
isBlocked?: boolean;
isBadgeActive?: boolean;
contextActions?: MenuItemContextAction[];
};
type OwnProps = {
@ -27,6 +30,7 @@ type OwnProps = {
activeTab: number;
big?: boolean;
onSwitchTab: (index: number) => void;
contextRootElementSelector?: string;
};
const TAB_SCROLL_THRESHOLD_PX = 16;
@ -35,6 +39,7 @@ const SCROLL_DURATION = IS_IOS ? 450 : IS_ANDROID ? 400 : 300;
const TabList: FC<OwnProps> = ({
tabs, areFolders, activeTab, big, onSwitchTab,
contextRootElementSelector,
}) => {
// eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(null);
@ -86,6 +91,8 @@ const TabList: FC<OwnProps> = ({
previousActiveTab={previousActiveTab}
onClick={onSwitchTab}
clickArg={i}
contextActions={tab.contextActions}
contextRootElementSelector={contextRootElementSelector}
/>
))}
</div>

View File

@ -304,4 +304,6 @@ export const DEFAULT_LIMITS: Record<ApiLimitType, readonly [number, number]> = {
channels: [500, 1000],
channelsPublic: [10, 20],
aboutLength: [70, 140],
chatlistInvites: [3, 100],
chatlistJoined: [2, 20],
};

View File

@ -4,10 +4,15 @@ import {
} from '../../index';
import type {
ApiChat, ApiUser, ApiError, ApiChatMember,
ApiChat, ApiUser, ApiError, ApiChatMember, ApiChatFolder, ApiChatlistExportedInvite,
} from '../../../api/types';
import { MAIN_THREAD_ID } from '../../../api/types';
import { NewChatMembersProgress, ChatCreationProgress, ManagementProgress } from '../../../types';
import {
ChatCreationProgress,
ManagementProgress,
NewChatMembersProgress,
SettingsScreens,
} from '../../../types';
import type {
GlobalState, ActionReturnType, TabArgs,
} from '../../types';
@ -89,6 +94,11 @@ const SERVICE_NOTIFICATIONS_USER_MOCK: ApiUser = {
isMin: true,
phoneNumber: '',
};
const CHATLIST_LIMIT_ERROR_LIST = new Set([
'FILTERS_TOO_MUCH',
'CHATLISTS_TOO_MUCH',
'INVITES_TOO_MUCH',
]);
const runThrottledForLoadTopChats = throttle((cb) => cb(), 3000, true);
const runDebouncedForLoadFullChat = debounce((cb) => cb(), 500, false, true);
@ -896,6 +906,7 @@ addActionHandler('openTelegramLink', (global, actions, payload): ActionReturnTyp
focusMessage,
openInvoice,
processAttachBotParameters,
checkChatlistInvite,
openChatByUsername: openChatByUsernameAction,
} = actions;
@ -954,14 +965,23 @@ addActionHandler('openTelegramLink', (global, actions, payload): ActionReturnTyp
return;
}
if (part1 === 'share') {
const text = formatShareText(params.url, params.text);
openChatWithDraft({ text, tabId });
return;
}
if (part1 === 'addlist') {
const slug = part2;
checkChatlistInvite({ slug, tabId });
return;
}
const chatOrChannelPostId = part2 || undefined;
const messageId = part3 ? Number(part3) : undefined;
const commentId = params.comment ? Number(params.comment) : undefined;
if (part1 === 'share') {
const text = formatShareText(params.url, params.text);
openChatWithDraft({ text, tabId });
} else if (params.hasOwnProperty('voicechat') || params.hasOwnProperty('livestream')) {
if (params.hasOwnProperty('voicechat') || params.hasOwnProperty('livestream')) {
joinVoiceChatByLink({
username: part1,
inviteHash: params.voicechat || params.livestream,
@ -1814,6 +1834,269 @@ addActionHandler('toggleTopicPinned', (global, actions, payload): ActionReturnTy
void callApi('togglePinnedTopic', { chat, topicId, isPinned });
});
addActionHandler('checkChatlistInvite', async (global, actions, payload): Promise<void> => {
const { slug, tabId = getCurrentTabId() } = payload;
const result = await callApi('checkChatlistInvite', { slug });
if (!result) {
actions.showNotification({
message: langProvider.translate('lng_group_invite_bad_link'),
tabId,
});
return;
}
global = getGlobal();
global = addUsers(global, buildCollectionByKey(result.users, 'id'));
global = addChats(global, buildCollectionByKey(result.chats, 'id'));
global = updateTabState(global, {
chatlistModal: {
invite: result.invite,
},
}, tabId);
setGlobal(global);
});
addActionHandler('joinChatlistInvite', async (global, actions, payload): Promise<void> => {
const { invite, peerIds, tabId = getCurrentTabId() } = payload;
const peers = peerIds.map((peerId) => selectChat(global, peerId)).filter(Boolean);
const notJoinedCount = peers.filter((peer) => peer.isNotJoined).length;
const folder = 'folderId' in invite ? selectChatFolder(global, invite.folderId) : undefined;
const folderTitle = 'title' in invite ? invite.title : folder?.title;
try {
const result = await callApi('joinChatlistInvite', { slug: invite.slug, peers });
if (!result) return;
actions.showNotification({
title: langProvider.translate(folder ? 'FolderLinkUpdatedTitle' : 'FolderLinkAddedTitle', folderTitle),
message: langProvider.translate('FolderLinkAddedSubtitle', notJoinedCount, 'i'),
tabId,
});
} catch (error) {
if ((error as ApiError).message === 'CHATLISTS_TOO_MUCH') {
actions.openLimitReachedModal({ limit: 'chatlistJoined', tabId });
} else {
actions.showDialog({ data: { ...(error as ApiError), hasErrorKey: true }, tabId });
}
}
});
addActionHandler('leaveChatlist', async (global, actions, payload): Promise<void> => {
const { folderId, peerIds, tabId = getCurrentTabId() } = payload;
const folder = selectChatFolder(global, folderId);
const peers = peerIds?.map((peerId) => selectChat(global, peerId)).filter(Boolean) || [];
const result = await callApi('leaveChatlist', { folderId, peers });
if (!result) return;
actions.showNotification({
title: langProvider.translate('FolderLinkDeletedTitle', folder.title),
message: langProvider.translate('FolderLinkDeletedSubtitle', peers.length, 'i'),
tabId,
});
});
addActionHandler('loadChatlistInvites', async (global, actions, payload): Promise<void> => {
const { folderId } = payload;
const result = await callApi('fetchChatlistInvites', { folderId });
if (!result) return;
global = getGlobal();
global = addUsers(global, buildCollectionByKey(result.users, 'id'));
global = addChats(global, buildCollectionByKey(result.chats, 'id'));
global = {
...global,
chatFolders: {
...global.chatFolders,
invites: {
...global.chatFolders.invites,
[folderId]: result.invites,
},
},
};
setGlobal(global);
});
addActionHandler('createChatlistInvite', async (global, actions, payload): Promise<void> => {
const { folderId, tabId = getCurrentTabId() } = payload;
const folder = selectChatFolder(global, folderId);
if (!folder) return;
global = updateTabState(global, {
shareFolderScreen: {
...selectTabState(global, tabId).shareFolderScreen!,
isLoading: true,
},
}, tabId);
setGlobal(global);
let result: { filter: ApiChatFolder; invite: ApiChatlistExportedInvite | undefined } | undefined;
try {
result = await callApi('createChalistInvite', {
folderId,
peers: folder.includedChatIds.concat(folder.pinnedChatIds || [])
.map((chatId) => selectChat(global, chatId) || selectUser(global, chatId)).filter(Boolean),
});
} catch (error) {
if (CHATLIST_LIMIT_ERROR_LIST.has((error as ApiError).message)) {
actions.openLimitReachedModal({ limit: 'chatlistInvites', tabId });
actions.requestNextSettingsScreen({ screen: SettingsScreens.Folders, tabId });
} else {
actions.showDialog({ data: { ...(error as ApiError), hasErrorKey: true }, tabId });
}
}
if (!result || !result.invite) return;
const { shareFolderScreen } = selectTabState(global, tabId);
if (!shareFolderScreen) return;
global = getGlobal();
global = {
...global,
chatFolders: {
...global.chatFolders,
byId: {
...global.chatFolders.byId,
[folderId]: {
...global.chatFolders.byId[folderId],
...result.filter,
},
},
invites: {
...global.chatFolders.invites,
[folderId]: [
...(global.chatFolders.invites[folderId] || []),
result.invite,
],
},
},
};
global = updateTabState(global, {
shareFolderScreen: {
...shareFolderScreen,
url: result.invite.url,
isLoading: false,
},
}, tabId);
setGlobal(global);
});
addActionHandler('editChatlistInvite', async (global, actions, payload): Promise<void> => {
const {
folderId, peerIds, url, tabId = getCurrentTabId(),
} = payload;
const slug = url.split('/').pop();
if (!slug) return;
const peers = peerIds
.map((chatId) => selectChat(global, chatId) || selectUser(global, chatId)).filter(Boolean);
global = updateTabState(global, {
shareFolderScreen: {
...selectTabState(global, tabId).shareFolderScreen!,
isLoading: true,
},
}, tabId);
setGlobal(global);
const result = await callApi('editChatlistInvite', { folderId, slug, peers });
if (!result) return;
global = getGlobal();
global = {
...global,
chatFolders: {
...global.chatFolders,
invites: {
...global.chatFolders.invites,
[folderId]: global.chatFolders.invites[folderId]?.map((invite) => {
if (invite.url === url) {
return result;
}
return invite;
}),
},
},
};
global = updateTabState(global, {
shareFolderScreen: {
...selectTabState(global, tabId).shareFolderScreen!,
isLoading: false,
},
}, tabId);
setGlobal(global);
});
addActionHandler('deleteChatlistInvite', async (global, actions, payload): Promise<void> => {
const { folderId, url } = payload;
const slug = url.split('/').pop();
if (!slug) return;
const result = await callApi('deleteChatlistInvite', { folderId, slug });
if (!result) return;
global = getGlobal();
global = {
...global,
chatFolders: {
...global.chatFolders,
invites: {
...global.chatFolders.invites,
[folderId]: global.chatFolders.invites[folderId]?.filter((invite) => invite.url !== url),
},
},
};
setGlobal(global);
});
addActionHandler('openDeleteChatFolderModal', async (global, actions, payload): Promise<void> => {
const { folderId, isConfirmedForChatlist, tabId = getCurrentTabId() } = payload;
const folder = selectChatFolder(global, folderId);
if (!folder) return;
if (folder.isChatList && (!folder.hasMyInvites || isConfirmedForChatlist)) {
const suggestions = await callApi('fetchLeaveChatlistSuggestions', { folderId });
global = getGlobal();
global = updateTabState(global, {
chatlistModal: {
removal: {
folderId,
suggestedPeerIds: suggestions,
},
},
}, tabId);
setGlobal(global);
return;
}
global = updateTabState(global, {
deleteFolderDialogModal: folderId,
}, tabId);
setGlobal(global);
});
async function loadChats<T extends GlobalState>(
global: T,
listType: 'active' | 'archived',

View File

@ -5,7 +5,9 @@ import { MAIN_THREAD_ID } from '../../../api/types';
import {
exitMessageSelectMode, replaceTabThreadParam, updateCurrentMessageList,
} from '../../reducers';
import { selectChat, selectCurrentMessageList, selectTabState } from '../../selectors';
import {
selectChat, selectCurrentMessageList, selectTabState,
} from '../../selectors';
import { closeLocalTextSearch } from './localSearch';
import type { ActionReturnType } from '../../types';
import { updateTabState } from '../../reducers/tabs';
@ -153,16 +155,16 @@ addActionHandler('openNextChat', (global, actions, payload): ActionReturnType =>
actions.openChat({ id: nextId, shouldReplaceHistory: true, tabId });
});
addActionHandler('openDeleteChatFolderModal', (global, actions, payload): ActionReturnType => {
const { folderId, tabId = getCurrentTabId() } = payload;
return updateTabState(global, {
deleteFolderDialogModal: folderId,
}, tabId);
});
addActionHandler('closeDeleteChatFolderModal', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
return updateTabState(global, {
deleteFolderDialogModal: undefined,
}, tabId);
});
addActionHandler('closeChatlistModal', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
return updateTabState(global, {
chatlistModal: undefined,
}, tabId);
});

View File

@ -1,15 +1,18 @@
import { addCallback } from '../../../lib/teact/teactn';
import { requestMutation } from '../../../lib/fasterdom/fasterdom';
import { addActionHandler, getActions } from '../../index';
import { SettingsScreens } from '../../../types';
import type { ActionReturnType, GlobalState } from '../../types';
import { replaceSettings, replaceThemeSettings } from '../../reducers';
import switchTheme from '../../../util/switchTheme';
import { setLanguage, setTimeFormat } from '../../../util/langProvider';
import { IS_IOS } from '../../../util/windowEnvironment';
import type { ActionReturnType, GlobalState } from '../../types';
import { updateTabState } from '../../reducers/tabs';
import { addCallback } from '../../../lib/teact/teactn';
import { getCurrentTabId } from '../../../util/establishMultitabRole';
import { requestMutation } from '../../../lib/fasterdom/fasterdom';
import { applyPerformanceSettings } from '../../../util/perfomanceSettings';
import { selectCanAnimateInterface } from '../../selectors';
import { selectCanAnimateInterface, selectChatFolder } from '../../selectors';
let prevGlobal: GlobalState | undefined;
@ -87,8 +90,58 @@ addActionHandler('setThemeSettings', (global, actions, payload): ActionReturnTyp
});
addActionHandler('requestNextSettingsScreen', (global, actions, payload): ActionReturnType => {
const { screen, tabId = getCurrentTabId() } = payload;
const { screen, foldersAction, tabId = getCurrentTabId() } = payload;
return updateTabState(global, {
nextSettingsScreen: screen,
nextFoldersAction: foldersAction,
}, tabId);
});
addActionHandler('openEditChatFolder', (global, actions, payload): ActionReturnType => {
const { folderId, isOnlyInvites, tabId = getCurrentTabId() } = payload;
const chatFolder = selectChatFolder(global, folderId);
if (!chatFolder) return;
actions.requestNextSettingsScreen({
screen: isOnlyInvites ? SettingsScreens.FoldersEditFolderInvites : SettingsScreens.FoldersEditFolderFromChatList,
foldersAction: {
type: 'editFolder',
payload: chatFolder,
},
tabId,
});
});
addActionHandler('openShareChatFolderModal', (global, actions, payload): ActionReturnType => {
const {
folderId, url, noRequestNextScreen, tabId = getCurrentTabId(),
} = payload;
const chatFolder = selectChatFolder(global, folderId);
const isChatList = chatFolder?.isChatList;
if (isChatList && !noRequestNextScreen) {
actions.openEditChatFolder({ folderId, isOnlyInvites: true, tabId });
return undefined;
}
if (!noRequestNextScreen) actions.requestNextSettingsScreen({ screen: SettingsScreens.FoldersShare, tabId });
return updateTabState(global, {
shareFolderScreen: {
folderId,
isFromSettings: Boolean(noRequestNextScreen),
url,
},
}, tabId);
});
addActionHandler('closeShareChatFolderModal', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
actions.requestNextSettingsScreen({ screen: undefined, tabId });
return updateTabState(global, {
shareFolderScreen: undefined,
}, tabId);
});

View File

@ -323,12 +323,12 @@ export function getCanDeleteChat(chat: ApiChat) {
export function getFolderDescriptionText(lang: LangFn, folder: ApiChatFolder, chatsCount?: number) {
const {
id, title, emoticon, description, pinnedChatIds,
excludedChatIds, includedChatIds,
excludeArchived, excludeMuted, excludeRead,
...filters
bots, groups, contacts, nonContacts, channels,
} = folder;
const filters = [bots, groups, contacts, nonContacts, channels];
// If folder has multiple additive filters or uses include/exclude lists,
// we display folder chats count
if (
@ -341,15 +341,15 @@ export function getFolderDescriptionText(lang: LangFn, folder: ApiChatFolder, ch
}
// Otherwise, we return a short description of a single filter
if (filters.bots) {
if (bots) {
return lang('FilterBots');
} else if (filters.groups) {
} else if (groups) {
return lang('FilterGroups');
} else if (filters.channels) {
} else if (channels) {
return lang('FilterChannels');
} else if (filters.contacts) {
} else if (contacts) {
return lang('FilterContacts');
} else if (filters.nonContacts) {
} else if (nonContacts) {
return lang('FilterNonContacts');
} else {
return undefined;

View File

@ -123,6 +123,7 @@ export const INITIAL_GLOBAL_STATE: GlobalState = {
chatFolders: {
byId: {},
invites: {},
},
fileUploads: {

View File

@ -3,7 +3,14 @@ import { MAIN_THREAD_ID } from '../../api/types';
import type { GlobalState, TabArgs } from '../types';
import {
getPrivateChatUserId, isChatChannel, isUserId, isHistoryClearMessage, isUserBot, isUserOnline,
getPrivateChatUserId,
isChatChannel,
isUserId,
isHistoryClearMessage,
isUserBot,
isUserOnline,
getHasAdminRight,
isChatSuperGroup,
} from '../helpers';
import { selectBot, selectUser } from './users';
import {
@ -239,3 +246,31 @@ export function filterChatIdsByType<T extends GlobalState>(
return filter.includes(type);
});
}
export function selectCanInviteToChat<T extends GlobalState>(global: T, chatId: string) {
const chat = selectChat(global, chatId);
if (!chat) return false;
// https://github.com/TelegramMessenger/Telegram-iOS/blob/5126be83b3b9578fb014eb52ca553da9e7a8b83a/submodules/TelegramCore/Sources/TelegramEngine/Peers/Communities.swift#L6
return Boolean(!isUserId(chatId) && ((isChatChannel(chat) || isChatSuperGroup(chat)) ? (
chat.isCreator || getHasAdminRight(chat, 'inviteUsers')
|| (chat.usernames?.length && !chat.isJoinRequest)
) : (chat.isCreator || getHasAdminRight(chat, 'inviteUsers'))));
}
export function selectCanShareFolder<T extends GlobalState>(global: T, folderId: number) {
const folder = selectChatFolder(global, folderId);
if (!folder) return false;
const {
bots, groups, channels, contacts, nonContacts, includedChatIds, pinnedChatIds,
excludeArchived, excludeMuted, excludeRead, excludedChatIds,
} = folder;
return !bots && !groups && !channels && !contacts && !nonContacts
&& !excludeArchived && !excludeMuted && !excludeRead && !excludedChatIds?.length
&& (pinnedChatIds?.length || includedChatIds.length)
&& folder.includedChatIds.concat(folder.pinnedChatIds || []).some((chatId) => {
return selectCanInviteToChat(global, chatId);
});
}

View File

@ -11,7 +11,8 @@ export function selectCurrentLimit<T extends GlobalState>(global: T, limit: ApiL
const isPremium = selectIsCurrentUserPremium(global);
const { limits } = appConfig;
const value = limits[limit][isPremium ? 1 : 0] ?? DEFAULT_LIMITS[limit][isPremium ? 1 : 0];
// When there are new limits when updating a layer, until we get a new configuration, we must use the default values
const value = limits[limit]?.[isPremium ? 1 : 0] ?? DEFAULT_LIMITS[limit][isPremium ? 1 : 0];
if (limit === 'dialogFilters') return value + 1; // Server does not count "All" as folder, but we need to
return value;
}

View File

@ -60,6 +60,8 @@ import type {
ApiWebSession,
ApiUserFullInfo,
ApiChatFullInfo,
ApiChatlistInvite,
ApiChatlistExportedInvite,
} from '../api/types';
import type {
ApiInvoiceContainer,
@ -93,6 +95,8 @@ import type {
} from '../types';
import type { P2pMessage } from '../lib/secret-sauce';
import type { ApiCredentials } from '../components/payment/PaymentModal';
import type { FoldersActions } from '../hooks/reducers/useFoldersReducer';
import type { ReducerAction } from '../hooks/useReducer';
export type MessageListType =
'thread'
@ -157,7 +161,7 @@ export interface ServiceNotification {
export type ApiLimitType = (
'uploadMaxFileparts' | 'stickersFaved' | 'savedGifs' | 'dialogFiltersChats' | 'dialogFilters' | 'dialogFolderPinned' |
'captionLength' | 'channels' | 'channelsPublic' | 'aboutLength'
'captionLength' | 'channels' | 'channelsPublic' | 'aboutLength' | 'chatlistInvites' | 'chatlistJoined'
);
export type ApiLimitTypeWithModal = Exclude<ApiLimitType, (
@ -206,6 +210,13 @@ export type TabState = {
};
nextSettingsScreen?: SettingsScreens;
nextFoldersAction?: ReducerAction<FoldersActions>;
shareFolderScreen?: {
folderId: number;
isFromSettings?: boolean;
url?: string;
isLoading?: boolean;
};
isCallPanelVisible?: boolean;
multitabNextAction?: CallbackAction;
@ -553,6 +564,14 @@ export type TabState = {
messageId: number;
activeLanguage?: string;
};
chatlistModal?: {
invite?: ApiChatlistInvite;
removal?: {
folderId: number;
suggestedPeerIds?: string[];
};
};
};
export type GlobalState = {
@ -690,6 +709,7 @@ export type GlobalState = {
chatFolders: {
orderedIds?: number[];
byId: Record<number, ApiChatFolder>;
invites: Record<number, ApiChatlistExportedInvite[]>;
recommended?: ApiChatFolder[];
};
@ -1673,6 +1693,34 @@ export interface ActionPayloads {
isEnabled: boolean;
};
checkChatlistInvite: {
slug: string;
} & WithTabId;
joinChatlistInvite: {
invite: ApiChatlistInvite;
peerIds: string[];
} & WithTabId;
leaveChatlist: {
folderId: number;
peerIds?: string[];
} & WithTabId;
closeChatlistModal: WithTabId | undefined;
loadChatlistInvites: {
folderId: number;
};
createChatlistInvite: {
folderId: number;
} & WithTabId;
editChatlistInvite: {
folderId: number;
url: string;
peerIds: string[];
} & WithTabId;
deleteChatlistInvite: {
folderId: number;
url: string;
} & WithTabId;
// Messages
setEditingDraft: {
text?: ApiFormattedText;
@ -2300,10 +2348,24 @@ export interface ActionPayloads {
} | undefined;
requestNextSettingsScreen: {
screen?: SettingsScreens;
foldersAction?: ReducerAction<FoldersActions>;
} & WithTabId;
sortChatFolders: { folderIds: number[] };
closeDeleteChatFolderModal: WithTabId | undefined;
openDeleteChatFolderModal: { folderId: number } & WithTabId;
openDeleteChatFolderModal: {
folderId: number;
isConfirmedForChatlist?: boolean;
} & WithTabId;
openShareChatFolderModal: {
folderId: number;
url?: string;
noRequestNextScreen?: boolean;
} & WithTabId;
openEditChatFolder: {
folderId: number;
isOnlyInvites?: boolean;
} & WithTabId;
closeShareChatFolderModal: undefined | WithTabId;
loadGlobalPrivacySettings: undefined;
updateGlobalPrivacySettings: { shouldArchiveAndMuteNewNonContact: boolean };

View File

@ -1395,4 +1395,12 @@ stats.getBroadcastStats#ab42441a flags:# dark:flags.0?true channel:InputChannel
stats.loadAsyncGraph#621d5fa0 flags:# token:string x:flags.0?long = StatsGraph;
stats.getMegagroupStats#dcdf8607 flags:# dark:flags.0?true channel:InputChannel = stats.MegagroupStats;
stats.getMessagePublicForwards#5630281b channel:InputChannel msg_id:int offset_rate:int offset_peer:InputPeer offset_id:int limit:int = messages.Messages;
stats.getMessageStats#b6e0a3f5 flags:# dark:flags.0?true channel:InputChannel msg_id:int = stats.MessageStats;`;
stats.getMessageStats#b6e0a3f5 flags:# dark:flags.0?true channel:InputChannel msg_id:int = stats.MessageStats;
chatlists.exportChatlistInvite#8472478e chatlist:InputChatlist title:string peers:Vector<InputPeer> = chatlists.ExportedChatlistInvite;
chatlists.deleteExportedInvite#719c5c5e chatlist:InputChatlist slug:string = Bool;
chatlists.editExportedInvite#653db63d flags:# chatlist:InputChatlist slug:string title:flags.1?string peers:flags.2?Vector<InputPeer> = ExportedChatlistInvite;
chatlists.getExportedInvites#ce03da83 chatlist:InputChatlist = chatlists.ExportedInvites;
chatlists.checkChatlistInvite#41c10fff slug:string = chatlists.ChatlistInvite;
chatlists.joinChatlistInvite#a6b1e39a slug:string peers:Vector<InputPeer> = Updates;
chatlists.getLeaveChatlistSuggestions#fdbcd714 chatlist:InputChatlist = Vector<Peer>;
chatlists.leaveChatlist#74fae13a chatlist:InputChatlist peers:Vector<InputPeer> = Updates;`;

View File

@ -281,5 +281,13 @@
"channels.deleteTopicHistory",
"channels.toggleParticipantsHidden",
"photos.uploadContactProfilePhoto",
"messages.getMessagesViews"
"messages.getMessagesViews",
"chatlists.exportChatlistInvite",
"chatlists.deleteExportedInvite",
"chatlists.editExportedInvite",
"chatlists.getExportedInvites",
"chatlists.checkChatlistInvite",
"chatlists.joinChatlistInvite",
"chatlists.getLeaveChatlistSuggestions",
"chatlists.leaveChatlist"
]

View File

@ -193,6 +193,7 @@ export enum SettingsScreens {
FoldersCreateFolder,
FoldersEditFolder,
FoldersEditFolderFromChatList,
FoldersEditFolderInvites,
FoldersIncludedChats,
FoldersIncludedChatsFromChatList,
FoldersExcludedChats,
@ -228,6 +229,7 @@ export enum SettingsScreens {
QuickReaction,
CustomEmoji,
DoNotTranslate,
FoldersShare,
}
export type StickerSetOrReactionsSetOrRecent = Pick<ApiStickerSet, (

View File

@ -7,7 +7,7 @@ import { IS_SAFARI } from './windowEnvironment';
type DeepLinkMethod = 'resolve' | 'login' | 'passport' | 'settings' | 'join' | 'addstickers' | 'addemoji' |
'setlanguage' | 'addtheme' | 'confirmphone' | 'socks' | 'proxy' | 'privatepost' | 'bg' | 'share' | 'msg' | 'msg_url' |
'invoice';
'invoice' | 'addlist';
export const processDeepLink = (url: string) => {
const {
@ -26,6 +26,7 @@ export const processDeepLink = (url: string) => {
openInvoice,
processAttachBotParameters,
openChatWithDraft,
checkChatlistInvite,
} = getActions();
// Safari thinks the path in tg://path links is hostname for some reason
@ -118,6 +119,11 @@ export const processDeepLink = (url: string) => {
openChatWithDraft({ text: formatShareText(urlParam, text) });
break;
}
case 'addlist': {
checkChatlistInvite({ slug: params.slug });
break;
}
case 'login': {
// const { code, token } = params;
break;