From ce9e5b03d852f7655594029a90d7b523e4280627 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Wed, 26 Apr 2023 21:14:39 +0400 Subject: [PATCH] Introduce Chat Lists (#3046) --- src/api/gramjs/apiBuilders/appConfig.ts | 4 +- src/api/gramjs/apiBuilders/chats.ts | 63 +++- src/api/gramjs/gramjsBuilders/index.ts | 13 +- src/api/gramjs/helpers.ts | 6 + src/api/gramjs/methods/chats.ts | 153 ++++++++- src/api/gramjs/methods/index.ts | 4 +- src/api/gramjs/updater.ts | 3 +- src/api/types/chats.ts | 24 ++ src/api/types/misc.ts | 1 + src/assets/tgs/settings/FoldersShare.tgs | Bin 0 -> 9372 bytes src/bundles/extra.ts | 2 + src/components/common/InviteLink.module.scss | 27 ++ src/components/common/InviteLink.tsx | 99 ++++++ src/components/common/Picker.tsx | 112 +++++-- .../common/helpers/animatedAssets.ts | 2 + src/components/left/LeftColumn.tsx | 20 +- src/components/left/main/AvatarBadge.tsx | 4 +- src/components/left/main/Chat.scss | 12 +- src/components/left/main/Chat.tsx | 4 +- .../left/main/{Badge.scss => ChatBadge.scss} | 8 +- .../left/main/{Badge.tsx => ChatBadge.tsx} | 18 +- src/components/left/main/ChatFolders.tsx | 90 +++++- src/components/left/main/Topic.tsx | 4 +- src/components/left/newChat/NewChatStep1.tsx | 1 + src/components/left/settings/Settings.scss | 8 + src/components/left/settings/Settings.tsx | 18 +- .../left/settings/SettingsHeader.tsx | 3 + ...SettingsPrivacyVisibilityExceptionList.tsx | 1 + .../left/settings/folders/SettingsFolders.tsx | 30 +- .../folders/SettingsFoldersChatFilters.tsx | 3 + .../folders/SettingsFoldersChatsPicker.tsx | 16 +- .../settings/folders/SettingsFoldersEdit.tsx | 173 +++++++--- .../settings/folders/SettingsFoldersMain.tsx | 6 +- .../folders/SettingsShareChatlist.tsx | 201 ++++++++++++ .../main/DeleteFolderDialog.async.tsx | 4 +- src/components/main/DeleteFolderDialog.tsx | 28 +- src/components/main/Main.tsx | 22 +- .../main/premium/PremiumFeatureModal.tsx | 2 +- .../common/PremiumLimitReachedModal.tsx | 8 + .../modals/chatlist/ChatlistAlready.tsx | 111 +++++++ .../modals/chatlist/ChatlistDelete.tsx | 93 ++++++ .../modals/chatlist/ChatlistModal.async.tsx | 17 + .../modals/chatlist/ChatlistModal.module.scss | 71 +++++ .../modals/chatlist/ChatlistModal.tsx | 123 ++++++++ .../modals/chatlist/ChatlistNew.tsx | 93 ++++++ src/components/right/AddChatMembers.tsx | 1 + .../right/management/ManageInviteInfo.tsx | 32 +- .../right/management/ManageInvites.tsx | 54 +--- .../right/management/Management.scss | 18 -- src/components/ui/Badge.module.scss | 21 ++ src/components/ui/Badge.tsx | 33 ++ src/components/ui/Tab.scss | 33 +- src/components/ui/Tab.tsx | 96 +++++- src/components/ui/TabList.tsx | 9 +- src/config.ts | 2 + src/global/actions/api/chats.ts | 295 +++++++++++++++++- src/global/actions/ui/chats.ts | 18 +- src/global/actions/ui/settings.ts | 63 +++- src/global/helpers/chats.ts | 16 +- src/global/initialState.ts | 1 + src/global/selectors/chats.ts | 37 ++- src/global/selectors/limits.ts | 3 +- src/global/types.ts | 66 +++- src/lib/gramjs/tl/apiTl.js | 10 +- src/lib/gramjs/tl/static/api.json | 10 +- src/types/index.ts | 2 + src/util/deeplink.ts | 8 +- 67 files changed, 2259 insertions(+), 274 deletions(-) create mode 100644 src/assets/tgs/settings/FoldersShare.tgs create mode 100644 src/components/common/InviteLink.module.scss create mode 100644 src/components/common/InviteLink.tsx rename src/components/left/main/{Badge.scss => ChatBadge.scss} (95%) rename src/components/left/main/{Badge.tsx => ChatBadge.tsx} (88%) create mode 100644 src/components/left/settings/folders/SettingsShareChatlist.tsx create mode 100644 src/components/modals/chatlist/ChatlistAlready.tsx create mode 100644 src/components/modals/chatlist/ChatlistDelete.tsx create mode 100644 src/components/modals/chatlist/ChatlistModal.async.tsx create mode 100644 src/components/modals/chatlist/ChatlistModal.module.scss create mode 100644 src/components/modals/chatlist/ChatlistModal.tsx create mode 100644 src/components/modals/chatlist/ChatlistNew.tsx create mode 100644 src/components/ui/Badge.module.scss create mode 100644 src/components/ui/Badge.tsx diff --git a/src/api/gramjs/apiBuilders/appConfig.ts b/src/api/gramjs/apiBuilders/appConfig.ts index 5f4ec769e..31eb71993 100644 --- a/src/api/gramjs/apiBuilders/appConfig.ts +++ b/src/api/gramjs/apiBuilders/appConfig.ts @@ -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; @@ -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, }; diff --git a/src/api/gramjs/apiBuilders/chats.ts b/src/api/gramjs/apiBuilders/chats.ts index 7f379cad3..43da25285 100644 --- a/src/api/gramjs/apiBuilders/chats.ts +++ b/src/api/gramjs/apiBuilders/chats.ts @@ -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), + }; +} diff --git a/src/api/gramjs/gramjsBuilders/index.ts b/src/api/gramjs/gramjsBuilders/index.ts index 44e28230d..91ec522c3 100644 --- a/src/api/gramjs/gramjsBuilders/index.ts +++ b/src/api/gramjs/gramjsBuilders/index.ts @@ -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, diff --git a/src/api/gramjs/helpers.ts b/src/api/gramjs/helpers.ts index bec4f1549..f1d5fbdc9 100644 --- a/src/api/gramjs/helpers.ts +++ b/src/api/gramjs/helpers.ts @@ -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}`; diff --git a/src/api/gramjs/methods/chats.ts b/src/api/gramjs/methods/chats.ts index e47fe75e3..03f928c2d 100644 --- a/src/api/gramjs/methods/chats.ts +++ b/src/api/gramjs/methods/chats.ts @@ -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), + }; +} diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index 483bf5be6..467e25f75 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -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 { diff --git a/src/api/gramjs/updater.ts b/src/api/gramjs/updater.ts index d0773c6c1..90ada8ba3 100644 --- a/src/api/gramjs/updater.ts +++ b/src/api/gramjs/updater.ts @@ -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', diff --git a/src/api/types/chats.ts b/src/api/types/chats.ts index 05e9efe2f..7ea1c8253 100644 --- a/src/api/types/chats.ts +++ b/src/api/types/chats.ts @@ -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[]; +} diff --git a/src/api/types/misc.ts b/src/api/types/misc.ts index 22e2a4c4c..45139af24 100644 --- a/src/api/types/misc.ts +++ b/src/api/types/misc.ts @@ -153,6 +153,7 @@ export type ApiChatInviteImporter = { date: number; isRequested?: boolean; about?: string; + isFromChatList?: boolean; }; export interface ApiCountry { diff --git a/src/assets/tgs/settings/FoldersShare.tgs b/src/assets/tgs/settings/FoldersShare.tgs new file mode 100644 index 0000000000000000000000000000000000000000..3e71d25b4086c5c420c09f6373579e7775cdeeda GIT binary patch literal 9372 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 W&rP%L&{E;~um2Aa!?@kjyZ``a=M2^W literal 0 HcmV?d00001 diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts index 4f2f21d8e..cc1d468e7 100644 --- a/src/bundles/extra.ts +++ b/src/bundles/extra.ts @@ -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'; diff --git a/src/components/common/InviteLink.module.scss b/src/components/common/InviteLink.module.scss new file mode 100644 index 000000000..60810e6d1 --- /dev/null +++ b/src/components/common/InviteLink.module.scss @@ -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; +} diff --git a/src/components/common/InviteLink.tsx b/src/components/common/InviteLink.tsx new file mode 100644 index 000000000..c42ebc7a2 --- /dev/null +++ b/src/components/common/InviteLink.tsx @@ -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 = ({ + 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 }) => ( + + ); + }, [isMobile]); + + return ( +
+

+ {lang(title || 'InviteLink.InviteLink')} +

+
+ + + {lang('Copy')} + {onRevoke && ( + {lang('RevokeButton')} + )} + +
+
+ + +
+
+ ); +}; + +export default memo(InviteLink); diff --git a/src/components/common/Picker.tsx b/src/components/common/Picker.tsx index 58a8faae2..fff6ad814 100644 --- a/src/components/common/Picker.tsx +++ b/src/components/common/Picker.tsx @@ -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 = ({ searchInputId, isLoading, noScrollRestore, + isSearchable, + lockedIds, onSelectedIdsChange, onFilterChange, + onDisabledClick, onLoadMore, }) => { // eslint-disable-next-line no-null/no-null @@ -58,53 +66,93 @@ const Picker: FC = ({ 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) => { 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 (
-
- {selectedIds.map((id, i) => ( - + {lockedSelectedIds.map((id, i) => ( + + ))} + {unlockedSelectedIds.map((id, i) => ( + + ))} + - ))} - -
+
+ )} {viewportIds?.length ? ( = ({ handleItemClick(id)} ripple > - + {isUserId(id) ? ( ) : ( diff --git a/src/components/common/helpers/animatedAssets.ts b/src/components/common/helpers/animatedAssets.ts index 1fec154d2..ce8230212 100644 --- a/src/components/common/helpers/animatedAssets.ts +++ b/src/components/common/helpers/animatedAssets.ts @@ -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, diff --git a/src/components/left/LeftColumn.tsx b/src/components/left/LeftColumn.tsx index bb7138373..259ae98e2 100644 --- a/src/components/left/LeftColumn.tsx +++ b/src/components/left/LeftColumn.tsx @@ -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; isChatOpen: boolean; isUpdateAvailable?: boolean; isForumPanelOpen?: boolean; @@ -63,6 +66,7 @@ const LeftColumn: FC = ({ currentUserId, hasPasscode, nextSettingsScreen, + nextFoldersAction, isChatOpen, isUpdateAvailable, isForumPanelOpen, @@ -115,6 +119,7 @@ const LeftColumn: FC = ({ 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 = ({ 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 = ({ 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, diff --git a/src/components/left/main/AvatarBadge.tsx b/src/components/left/main/AvatarBadge.tsx index eee547322..81fd89424 100644 --- a/src/components/left/main/AvatarBadge.tsx +++ b/src/components/left/main/AvatarBadge.tsx @@ -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 = ({ }) => { return chat && (
- +
); }; diff --git a/src/components/left/main/Chat.scss b/src/components/left/main/Chat.scss index b3b78d11d..3ab2fddff 100644 --- a/src/components/left/main/Chat.scss +++ b/src/components/left/main/Chat.scss @@ -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 & { diff --git a/src/components/left/main/Chat.tsx b/src/components/left/main/Chat.tsx index 5815c9fa9..33300f1ea 100644 --- a/src/components/left/main/Chat.tsx +++ b/src/components/left/main/Chat.tsx @@ -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 = ({
{renderSubtitle()} - +
{shouldRenderDeleteModal && ( diff --git a/src/components/left/main/Badge.scss b/src/components/left/main/ChatBadge.scss similarity index 95% rename from src/components/left/main/Badge.scss rename to src/components/left/main/ChatBadge.scss index fb2de27fa..1201585df 100644 --- a/src/components/left/main/Badge.scss +++ b/src/components/left/main/ChatBadge.scss @@ -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); diff --git a/src/components/left/main/Badge.tsx b/src/components/left/main/ChatBadge.tsx similarity index 88% rename from src/components/left/main/Badge.tsx rename to src/components/left/main/ChatBadge.tsx index 523356b75..a7e8872e2 100644 --- a/src/components/left/main/Badge.tsx +++ b/src/components/left/main/ChatBadge.tsx @@ -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 = ({ +const ChatBadge: FC = ({ topic, chat, isPinned, isMuted, shouldShowOnlyMostImportant, wasTopicOpened, forceHidden, }) => { const { @@ -58,7 +58,7 @@ const Badge: FC = ({ const isUnread = Boolean(unreadCount || hasUnreadMark); const className = buildClassName( - 'Badge', + 'ChatBadge', shouldBeMuted && 'muted', !isUnread && isPinned && 'pinned', isUnread && 'unread', @@ -66,19 +66,19 @@ const Badge: FC = ({ function renderContent() { const unreadReactionsElement = unreadReactionsCount && ( -
+
); const unreadMentionsElement = unreadMentionsCount && ( -
+
); const unopenedTopicElement = isTopicUnopened && ( -
+
); const unreadCountElement = (hasUnreadMark || unreadCount) ? ( @@ -109,17 +109,17 @@ const Badge: FC = ({ } return ( -
+
{elements}
); } return ( - + {renderContent()} ); }; -export default memo(Badge); +export default memo(ChatBadge); diff --git a/src/components/left/main/ChatFolders.tsx b/src/components/left/main/ChatFolders.tsx index b7aa14452..be524ef68 100644 --- a/src/components/left/main/ChatFolders.tsx +++ b/src/components/left/main/ChatFolders.tsx @@ -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; + folderInvitesById: Record; 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 = ({ shouldSkipHistoryAnimations, maxFolders, shouldHideFolderTabs, + folderInvitesById, + maxFolderInvites, hasArchivedChats, archiveSettings, }) => { @@ -68,6 +74,10 @@ const ChatFolders: FC = ({ loadChatFolders, setActiveChatFolder, openChat, + openShareChatFolderModal, + openDeleteChatFolderModal, + openEditChatFolder, + openLimitReachedModal, } = getActions(); // eslint-disable-next-line no-null/no-null @@ -81,11 +91,13 @@ const ChatFolders: FC = ({ } }, [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 = ({ 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 = ({ )} > {shouldRenderFolders ? ( - + ) : shouldRenderPlaceholder ? (
) : undefined} @@ -258,6 +321,7 @@ export default memo(withGlobal( chatFolders: { byId: chatFoldersById, orderedIds: orderedFolderIds, + invites: folderInvitesById, }, chats: { listIds: { @@ -272,6 +336,7 @@ export default memo(withGlobal( return { chatFoldersById, + folderInvitesById, orderedFolderIds, activeChatFolder, currentUserId, @@ -279,6 +344,7 @@ export default memo(withGlobal( shouldSkipHistoryAnimations, hasArchivedChats: Boolean(archived?.length), maxFolders: selectCurrentLimit(global, 'dialogFilters'), + maxFolderInvites: selectCurrentLimit(global, 'chatlistInvites'), archiveSettings, }; }, diff --git a/src/components/left/main/Topic.tsx b/src/components/left/main/Topic.tsx index 2a6930486..b0a431f96 100644 --- a/src/components/left/main/Topic.tsx +++ b/src/components/left/main/Topic.tsx @@ -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 = ({
{renderSubtitle()} - = ({ filterPlaceholder={lang('SendMessageTo')} searchInputId="new-group-picker-search" isLoading={isSearching} + isSearchable onSelectedIdsChange={onSelectedMemberIdsChange} onFilterChange={handleFilterChange} /> diff --git a/src/components/left/settings/Settings.scss b/src/components/left/settings/Settings.scss index 03f226886..8b8e26856 100644 --- a/src/components/left/settings/Settings.scss +++ b/src/components/left/settings/Settings.scss @@ -380,3 +380,11 @@ margin-inline-end: 2rem; } } + +.settings-item-chatlist { + padding: 0; +} + +.settings-item-chatlist .ListItem { + margin: inherit; +} diff --git a/src/components/left/settings/Settings.tsx b/src/components/left/settings/Settings.tsx index 59bf05301..81b90f3bb 100644 --- a/src/components/left/settings/Settings.tsx +++ b/src/components/left/settings/Settings.tsx @@ -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 = ({ onReset, shouldSkipTransition, }) => { + const { closeShareChatFolderModal } = getActions(); const [twoFaState, twoFaDispatch] = useTwoFaReducer(); const [privacyPasscode, setPrivacyPasscode] = useState(''); 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 = ({ currentScreen === SettingsScreens.FoldersCreateFolder || currentScreen === SettingsScreens.FoldersEditFolder || currentScreen === SettingsScreens.FoldersEditFolderFromChatList + || currentScreen === SettingsScreens.FoldersEditFolderInvites ) { setTimeout(() => { foldersDispatch({ type: 'reset' }); @@ -361,10 +373,12 @@ const Settings: FC = ({ 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 ( = ({ return

{lang('Filters')}

; case SettingsScreens.FoldersCreateFolder: return

{lang('FilterNew')}

; + case SettingsScreens.FoldersShare: + return

{lang('FolderLinkScreen.Title')}

; case SettingsScreens.FoldersEditFolder: case SettingsScreens.FoldersEditFolderFromChatList: + case SettingsScreens.FoldersEditFolderInvites: return (

{lang('FilterEdit')}

diff --git a/src/components/left/settings/SettingsPrivacyVisibilityExceptionList.tsx b/src/components/left/settings/SettingsPrivacyVisibilityExceptionList.tsx index a3137ddcb..0adb33674 100644 --- a/src/components/left/settings/SettingsPrivacyVisibilityExceptionList.tsx +++ b/src/components/left/settings/SettingsPrivacyVisibilityExceptionList.tsx @@ -107,6 +107,7 @@ const SettingsPrivacyVisibilityExceptionList: FC = ({ filterValue={searchQuery} filterPlaceholder={isAllowList ? lang('AlwaysAllowPlaceholder') : lang('NeverAllowPlaceholder')} searchInputId="new-group-picker-search" + isSearchable onSelectedIdsChange={handleSelectedContactIdsChange} onFilterChange={setSearchQuery} /> diff --git a/src/components/left/settings/folders/SettingsFolders.tsx b/src/components/left/settings/folders/SettingsFolders.tsx index 02c856057..a285f93e7 100644 --- a/src/components/left/settings/folders/SettingsFolders.tsx +++ b/src/components/left/settings/folders/SettingsFolders.tsx @@ -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 = ({ 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 = ({ : 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 = ({ case SettingsScreens.FoldersCreateFolder: case SettingsScreens.FoldersEditFolder: case SettingsScreens.FoldersEditFolderFromChatList: + case SettingsScreens.FoldersEditFolderInvites: return ( ); @@ -141,6 +159,14 @@ const SettingsFolders: FC = ({ /> ); + case SettingsScreens.FoldersShare: + return ( + + ); + default: return undefined; } diff --git a/src/components/left/settings/folders/SettingsFoldersChatFilters.tsx b/src/components/left/settings/folders/SettingsFoldersChatFilters.tsx index d41fbd96e..32ce2ceba 100644 --- a/src/components/left/settings/folders/SettingsFoldersChatFilters.tsx +++ b/src/components/left/settings/folders/SettingsFoldersChatFilters.tsx @@ -43,6 +43,8 @@ const SettingsFoldersChatFilters: FC = ({ 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 = ({ selectedIds={selectedChatIds} selectedChatTypes={selectedChatTypes} filterValue={chatFilter} + shouldHideChatTypes={shouldHideChatTypes} onSelectedIdsChange={handleSelectedIdsChange} onSelectedChatTypesChange={handleSelectedChatTypesChange} onFilterChange={handleFilterChange} diff --git a/src/components/left/settings/folders/SettingsFoldersChatsPicker.tsx b/src/components/left/settings/folders/SettingsFoldersChatsPicker.tsx index 336f4dc30..131a91428 100644 --- a/src/components/left/settings/folders/SettingsFoldersChatsPicker.tsx +++ b/src/components/left/settings/folders/SettingsFoldersChatsPicker.tsx @@ -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 = ({ selectedIds, selectedChatTypes, filterValue, + shouldHideChatTypes, onSelectedIdsChange, onSelectedChatTypesChange, onFilterChange, @@ -200,11 +202,15 @@ const SettingsFoldersChatsPicker: FC = ({ > {(!viewportIds || !viewportIds.length || viewportIds.includes(chatIds[0])) && (
-

- {lang('FilterChatTypes')} -

- {chatTypes.map(renderChatType)} -
+ {!shouldHideChatTypes && ( + <> +

+ {lang('FilterChatTypes')} +

+ {chatTypes.map(renderChatType)} +
+ + )}

{lang('FilterChats')}

diff --git a/src/components/left/settings/folders/SettingsFoldersEdit.tsx b/src/components/left/settings/folders/SettingsFoldersEdit.tsx index 23c0b598a..7b88df0ec 100644 --- a/src/components/left/settings/folders/SettingsFoldersEdit.tsx +++ b/src/components/left/settings/folders/SettingsFoldersEdit.tsx @@ -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 = ({ 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 = ({ } }, [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 = ({ 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 = ({ 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, 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 = ({ className="settings-content-icon" /> - {state.mode === 'create' && ( + {isCreating && (

{lang('FilterIncludeInfo')}

@@ -241,39 +301,76 @@ const SettingsFoldersEdit: FC = ({ />
-
- {state.error && state.error === ERROR_NO_CHATS && ( -

- {lang(state.error)} -

- )} + {!isOnlyInvites && ( +
+ {state.error && state.error === ERROR_NO_CHATS && ( +

+ {lang(state.error)} +

+ )} -

{lang('FilterInclude')}

+

{lang('FilterInclude')}

- - {lang('FilterAddChats')} - + + {lang('FilterAddChats')} + - {renderChats('included')} -
+ {renderChats('included')} +
+ )} -
-

{lang('FilterExclude')}

+ {!isOnlyInvites && !isEditingChatList && ( +
+

{lang('FilterExclude')}

- - {lang('FilterAddChats')} - + + {lang('FilterAddChats')} + - {renderChats('excluded')} -
+ {renderChats('excluded')} +
+ )} + + {!isCreating && ( +
+

+ {lang('FolderLinkScreen.Title')} +

+ + + {lang('ChatListFilter.CreateLinkNew')} + + + {invites?.map((invite) => ( + + {invite.title || invite.url} + + {lang('ChatListFilter.LinkLabelChatCount', invite.peerIds.length, 'i')} + + + ))} + +
+ )}
= ({ export default memo(withGlobal( (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)); diff --git a/src/components/left/settings/folders/SettingsFoldersMain.tsx b/src/components/left/settings/folders/SettingsFoldersMain.tsx index 7f11c60e3..f1210ee59 100644 --- a/src/components/left/settings/folders/SettingsFoldersMain.tsx +++ b/src/components/left/settings/folders/SettingsFoldersMain.tsx @@ -139,6 +139,7 @@ const SettingsFoldersMain: FC = ({ 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 = ({ {renderText(folder.title, ['emoji'])} {isBlocked && } - {folder.subtitle} + + {folder.isChatList && } + {folder.subtitle} + ); diff --git a/src/components/left/settings/folders/SettingsShareChatlist.tsx b/src/components/left/settings/folders/SettingsShareChatlist.tsx new file mode 100644 index 000000000..6d5ba4ecd --- /dev/null +++ b/src/components/left/settings/folders/SettingsShareChatlist.tsx @@ -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 = ({ + 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(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 ( +
+
+ + +

+ {renderText(lang('FolderLinkScreen.TitleDescriptionSelected', [title, chatsCount]), + ['simple_markdown'])} +

+
+ + + +
+ +
+ + + {isLoading ? ( + + ) : ( + + )} + +
+ ); +}; + +export default memo(withGlobal( + (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)); diff --git a/src/components/main/DeleteFolderDialog.async.tsx b/src/components/main/DeleteFolderDialog.async.tsx index 062eba7a3..cf7038732 100644 --- a/src/components/main/DeleteFolderDialog.async.tsx +++ b/src/components/main/DeleteFolderDialog.async.tsx @@ -7,8 +7,8 @@ import type { OwnProps } from './DeleteFolderDialog'; import useModuleLoader from '../../hooks/useModuleLoader'; const DeleteFolderDialogAsync: FC = (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 ? : undefined; diff --git a/src/components/main/DeleteFolderDialog.tsx b/src/components/main/DeleteFolderDialog.tsx index 3b7d9178b..107608294 100644 --- a/src/components/main/DeleteFolderDialog.tsx +++ b/src/components/main/DeleteFolderDialog.tsx @@ -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 = ({ - 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 ( = ({ isReceiptModalOpen, isReactionPickerOpen, isCurrentUserPremium, - deleteFolderDialogId, + deleteFolderDialog, isMasterTab, + chatlistModal, noRightColumnAnimation, }) => { const { @@ -509,6 +516,7 @@ const Main: FC = ({ userId={newContactUserId} isByPhoneNumber={newContactByPhoneNumber} /> + @@ -528,7 +536,7 @@ const Main: FC = ({ - +
); @@ -569,6 +577,7 @@ export default memo(withGlobal( payment, limitReachedModal, deleteFolderDialogModal, + chatlistModal, } = selectTabState(global); const { chatId: audioChatId, messageId: audioMessageId } = audioPlayer; @@ -582,6 +591,8 @@ export default memo(withGlobal( 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( limitReached: limitReachedModal?.limit, isPaymentModalOpen: payment.isPaymentModalOpen, isReceiptModalOpen: Boolean(payment.receipt), - deleteFolderDialogId: deleteFolderDialogModal, + deleteFolderDialog, isMasterTab, requestedDraft, + chatlistModal, noRightColumnAnimation, }; }, diff --git a/src/components/main/premium/PremiumFeatureModal.tsx b/src/components/main/premium/PremiumFeatureModal.tsx index e24ee70f6..b584dbb3a 100644 --- a/src/components/main/premium/PremiumFeatureModal.tsx +++ b/src/components/main/premium/PremiumFeatureModal.tsx @@ -76,7 +76,7 @@ const PREMIUM_BOTTOM_VIDEOS: string[] = [ 'emoji_status', ]; -type ApiLimitTypeWithoutUpload = Exclude; +type ApiLimitTypeWithoutUpload = Exclude; const LIMITS_ORDER: ApiLimitTypeWithoutUpload[] = [ 'channels', diff --git a/src/components/main/premium/common/PremiumLimitReachedModal.tsx b/src/components/main/premium/common/PremiumLimitReachedModal.tsx index 27db1bda0..25867a359 100644 --- a/src/components/main/premium/common/PremiumLimitReachedModal.tsx +++ b/src/components/main/premium/common/PremiumLimitReachedModal.tsx @@ -26,6 +26,8 @@ const LIMIT_DESCRIPTION: Record = { dialogFolderPinned: 'LimitReachedPinDialogs', channelsPublic: 'LimitReachedPublicLinks', channels: 'LimitReachedCommunities', + chatlistInvites: 'LimitReachedFolderLinks', + chatlistJoined: 'LimitReachedSharedFolders', }; const LIMIT_DESCRIPTION_BLOCKED: Record = { @@ -35,6 +37,8 @@ const LIMIT_DESCRIPTION_BLOCKED: Record = { dialogFolderPinned: 'LimitReachedPinDialogsLocked', channelsPublic: 'LimitReachedPublicLinksLocked', channels: 'LimitReachedCommunitiesLocked', + chatlistInvites: 'LimitReachedFolderLinksLocked', + chatlistJoined: 'LimitReachedSharedFoldersLocked', }; const LIMIT_DESCRIPTION_PREMIUM: Record = { @@ -44,6 +48,8 @@ const LIMIT_DESCRIPTION_PREMIUM: Record = { dialogFolderPinned: 'LimitReachedPinDialogsPremium', channelsPublic: 'LimitReachedPublicLinksPremium', channels: 'LimitReachedCommunitiesPremium', + chatlistInvites: 'LimitReachedFolderLinksPremium', + chatlistJoined: 'LimitReachedSharedFoldersPremium', }; const LIMIT_ICON: Record = { @@ -53,6 +59,8 @@ const LIMIT_ICON: Record = { 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 string>> = { diff --git a/src/components/modals/chatlist/ChatlistAlready.tsx b/src/components/modals/chatlist/ChatlistAlready.tsx new file mode 100644 index 000000000..fe58b6336 --- /dev/null +++ b/src/components/modals/chatlist/ChatlistAlready.tsx @@ -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 = ({ invite, folder }) => { + const { closeChatlistModal, joinChatlistInvite } = getActions(); + + const lang = useLang(); + + const [selectedPeerIds, setSelectedPeerIds] = useState(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 ( +
+
+ {renderText(descriptionText, ['simple_markdown', 'emoji'])} +
+
+ {Boolean(invite.missingPeerIds.length) && ( + <> +
+
+ {lang('FolderLinkHeaderChatsJoin', selectedPeerIds.length, 'i')} +
+
+ {selectedPeerIds.length === invite.missingPeerIds.length ? lang('DeselectAll') : lang('SelectAll')} +
+
+ + + )} +
+
+ {lang('FolderLinkHeaderAlready')} +
+
+ +
+ +
+ ); +}; + +export default memo(ChatlistAlready); diff --git a/src/components/modals/chatlist/ChatlistDelete.tsx b/src/components/modals/chatlist/ChatlistDelete.tsx new file mode 100644 index 000000000..e184311fb --- /dev/null +++ b/src/components/modals/chatlist/ChatlistDelete.tsx @@ -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 = ({ + folder, + suggestedPeerIds = MEMO_EMPTY_ARRAY, +}) => { + const { closeChatlistModal, leaveChatlist } = getActions(); + + const lang = useLang(); + + const [selectedPeerIds, setSelectedPeerIds] = useState(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 ( +
+ {Boolean(suggestedPeerIds?.length) && ( + <> +
+ {renderText(lang('FolderLinkSubtitleRemove'), ['simple_markdown', 'emoji'])} +
+
+
+
+ {lang('FolderLinkHeaderChatsQuit', selectedPeerIds.length, 'i')} +
+
+ {selectedPeerIds.length === suggestedPeerIds.length ? lang('DeselectAll') : lang('SelectAll')} +
+
+ +
+ + )} + +
+ ); +}; + +export default memo(ChatlistDelete); diff --git a/src/components/modals/chatlist/ChatlistModal.async.tsx b/src/components/modals/chatlist/ChatlistModal.async.tsx new file mode 100644 index 000000000..90aa7ed77 --- /dev/null +++ b/src/components/modals/chatlist/ChatlistModal.async.tsx @@ -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 = (props) => { + const { info } = props; + const ChatlistModal = useModuleLoader(Bundles.Extra, 'ChatlistModal', !info); + + // eslint-disable-next-line react/jsx-props-no-spreading + return ChatlistModal ? : undefined; +}; + +export default memo(ChatlistModalAsync); diff --git a/src/components/modals/chatlist/ChatlistModal.module.scss b/src/components/modals/chatlist/ChatlistModal.module.scss new file mode 100644 index 000000000..14bd41763 --- /dev/null +++ b/src/components/modals/chatlist/ChatlistModal.module.scss @@ -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%); +} diff --git a/src/components/modals/chatlist/ChatlistModal.tsx b/src/components/modals/chatlist/ChatlistModal.tsx new file mode 100644 index 000000000..de643f913 --- /dev/null +++ b/src/components/modals/chatlist/ChatlistModal.tsx @@ -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 = ({ + 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 ( +
+
+ + + +
+
+ ); + } + + const renderContent = useCallback(() => { + if (!renderingInfo) return undefined; + if (renderingInfo.invite) { + const invite = renderingInfo.invite; + if ('alreadyPeerIds' in invite) { + return ; + } + + return ; + } + + if (renderingInfo.removal) { + return ; + } + + return undefined; + }, [renderingFolder, renderingInfo]); + + return ( + + {renderingFolderTitle && renderFolders(renderingFolderTitle)} + {renderContent()} + + ); +}; + +export default memo(withGlobal( + (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)); diff --git a/src/components/modals/chatlist/ChatlistNew.tsx b/src/components/modals/chatlist/ChatlistNew.tsx new file mode 100644 index 000000000..2c061688e --- /dev/null +++ b/src/components/modals/chatlist/ChatlistNew.tsx @@ -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 = ({ invite }) => { + const { closeChatlistModal, joinChatlistInvite } = getActions(); + + const lang = useLang(); + const [selectedPeerIds, setSelectedPeerIds] = useState(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 ( +
+
+ {renderText(lang('FolderLinkSubtitle', invite.title), ['simple_markdown', 'emoji'])} +
+
+
+
+ {lang('FolderLinkHeaderChatsJoin', selectedCount, 'i')} +
+
+ {selectedPeerIds.length === invite.peerIds.length ? lang('DeselectAll') : lang('SelectAll')} +
+
+ +
+ +
+ ); +}; + +export default memo(ChatlistNew); diff --git a/src/components/right/AddChatMembers.tsx b/src/components/right/AddChatMembers.tsx index 4be3e1d57..78ac6f612 100644 --- a/src/components/right/AddChatMembers.tsx +++ b/src/components/right/AddChatMembers.tsx @@ -128,6 +128,7 @@ const AddChatMembers: FC = ({ isLoading={isSearching} onSelectedIdsChange={setSelectedMemberIds} onFilterChange={handleFilterChange} + isSearchable noScrollRestore={noPickerScrollRestore} /> diff --git a/src/components/right/management/ManageInviteInfo.tsx b/src/components/right/management/ManageInviteInfo.tsx index 115cd9261..d1b2a160a 100644 --- a/src/components/right/management/ManageInviteInfo.tsx +++ b/src/components/right/management/ManageInviteInfo.tsx @@ -31,6 +31,8 @@ type StateProps = { isChannel?: boolean; }; +const BULLET = '\u2022'; + const ManageInviteInfo: FC = ({ chatId, invite, @@ -83,19 +85,23 @@ const ManageInviteInfo: FC = ({ {!importers.length && ( usageLimit ? lang('PeopleCanJoinViaLinkCount', usageLimit - usage) : lang('NoOneJoinedYet') )} - {importers.map((importer) => ( - openChat({ id: importer.userId })} - > - - - ))} + {importers.map((importer) => { + const joinTime = formatMediaDateTime(lang, importer.date * 1000, true); + const status = importer.isFromChatList ? `${joinTime} ${BULLET} ${lang('JoinedViaFolder')}` : joinTime; + return ( + openChat({ id: importer.userId })} + > + + + ); + })}

); diff --git a/src/components/right/management/ManageInvites.tsx b/src/components/right/management/ManageInvites.tsx index c53686534..52294b950 100644 --- a/src/components/right/management/ManageInvites.tsx +++ b/src/components/right/management/ManageInvites.tsx @@ -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 = ({ const [revokingInvite, setRevokingInvite] = useState(); const [isDeleteDialogOpen, openDeleteDialog, closeDeleteDialog] = useFlag(); const [deletingInvite, setDeletingInvite] = useState(); - const { isMobile } = useAppLayout(); useHistoryBack({ isActive, @@ -181,10 +178,6 @@ const ManageInvites: FC = ({ }); }, [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 = ({ return actions; }; - const PrimaryLinkMenuButton: FC<{ onTrigger: () => void; isOpen?: boolean }> = useMemo(() => { - return ({ onTrigger, isOpen }) => ( - - ); - }, [isMobile]); - return (
@@ -305,30 +282,11 @@ const ManageInvites: FC = ({

{isChannel ? lang('PrimaryLinkHelpChannel') : lang('PrimaryLinkHelp')}

{primaryInviteLink && ( -
-

- {chat?.usernames ? lang('PublicLink') : lang('lng_create_permanent_link_title')} -

-
- - - {lang('Copy')} - {!chat?.usernames && ( - {lang('RevokeButton')} - )} - -
- -
+ )}
diff --git a/src/config.ts b/src/config.ts index 9580843d2..770278f16 100644 --- a/src/config.ts +++ b/src/config.ts @@ -304,4 +304,6 @@ export const DEFAULT_LIMITS: Record = { channels: [500, 1000], channelsPublic: [10, 20], aboutLength: [70, 140], + chatlistInvites: [3, 100], + chatlistJoined: [2, 20], }; diff --git a/src/global/actions/api/chats.ts b/src/global/actions/api/chats.ts index 20559bbc5..10a151d42 100644 --- a/src/global/actions/api/chats.ts +++ b/src/global/actions/api/chats.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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( global: T, listType: 'active' | 'archived', diff --git a/src/global/actions/ui/chats.ts b/src/global/actions/ui/chats.ts index 93e76ccb2..4bdccfe30 100644 --- a/src/global/actions/ui/chats.ts +++ b/src/global/actions/ui/chats.ts @@ -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); +}); diff --git a/src/global/actions/ui/settings.ts b/src/global/actions/ui/settings.ts index c9c61acd6..fa3573afa 100644 --- a/src/global/actions/ui/settings.ts +++ b/src/global/actions/ui/settings.ts @@ -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); }); diff --git a/src/global/helpers/chats.ts b/src/global/helpers/chats.ts index 06d06879f..c25847224 100644 --- a/src/global/helpers/chats.ts +++ b/src/global/helpers/chats.ts @@ -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; diff --git a/src/global/initialState.ts b/src/global/initialState.ts index 89cc494d8..de00b02b5 100644 --- a/src/global/initialState.ts +++ b/src/global/initialState.ts @@ -123,6 +123,7 @@ export const INITIAL_GLOBAL_STATE: GlobalState = { chatFolders: { byId: {}, + invites: {}, }, fileUploads: { diff --git a/src/global/selectors/chats.ts b/src/global/selectors/chats.ts index e696302de..4ec79dad1 100644 --- a/src/global/selectors/chats.ts +++ b/src/global/selectors/chats.ts @@ -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( return filter.includes(type); }); } + +export function selectCanInviteToChat(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(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); + }); +} diff --git a/src/global/selectors/limits.ts b/src/global/selectors/limits.ts index 6da58fe9a..660536346 100644 --- a/src/global/selectors/limits.ts +++ b/src/global/selectors/limits.ts @@ -11,7 +11,8 @@ export function selectCurrentLimit(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; } diff --git a/src/global/types.ts b/src/global/types.ts index 73d1d9f06..fad9372d3 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -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; + 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; + invites: Record; 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; } & 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 }; diff --git a/src/lib/gramjs/tl/apiTl.js b/src/lib/gramjs/tl/apiTl.js index ff3dc53ac..549b3a4a0 100644 --- a/src/lib/gramjs/tl/apiTl.js +++ b/src/lib/gramjs/tl/apiTl.js @@ -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;`; \ No newline at end of file +stats.getMessageStats#b6e0a3f5 flags:# dark:flags.0?true channel:InputChannel msg_id:int = stats.MessageStats; +chatlists.exportChatlistInvite#8472478e chatlist:InputChatlist title:string peers:Vector = 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 = ExportedChatlistInvite; +chatlists.getExportedInvites#ce03da83 chatlist:InputChatlist = chatlists.ExportedInvites; +chatlists.checkChatlistInvite#41c10fff slug:string = chatlists.ChatlistInvite; +chatlists.joinChatlistInvite#a6b1e39a slug:string peers:Vector = Updates; +chatlists.getLeaveChatlistSuggestions#fdbcd714 chatlist:InputChatlist = Vector; +chatlists.leaveChatlist#74fae13a chatlist:InputChatlist peers:Vector = Updates;`; \ No newline at end of file diff --git a/src/lib/gramjs/tl/static/api.json b/src/lib/gramjs/tl/static/api.json index a5f052cd0..fb16f3458 100644 --- a/src/lib/gramjs/tl/static/api.json +++ b/src/lib/gramjs/tl/static/api.json @@ -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" ] diff --git a/src/types/index.ts b/src/types/index.ts index 1fbddfecc..7b863e231 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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 { 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;