Folders: Fix sharing (#3142)

This commit is contained in:
Alexander Zinchuk 2023-05-05 15:53:15 +04:00
parent d04177aea8
commit 4b96cc5bfe
15 changed files with 256 additions and 132 deletions

View File

@ -1681,7 +1681,7 @@ export async function editChatlistInvite({
slug,
title,
peers: peers.map((peer) => buildInputPeer(peer.id, peer.accessHash)),
}));
}), undefined, true);
if (!result) return undefined;
return buildApiChatlistExportedInvite(result);

View File

@ -6,6 +6,7 @@
cursor: var(--custom-cursor, pointer);
margin-bottom: 1rem;
padding-right: 3rem;
text-overflow: ellipsis;
}
.moreMenu {

View File

@ -18,12 +18,14 @@ type OwnProps = {
title?: string;
inviteLink: string;
onRevoke?: VoidFunction;
isDisabled?: boolean;
};
const InviteLink: FC<OwnProps> = ({
title,
inviteLink,
onRevoke,
isDisabled,
}) => {
const lang = useLang();
const { showNotification, openChatWithDraft } = getActions();
@ -85,10 +87,20 @@ const InviteLink: FC<OwnProps> = ({
</DropdownMenu>
</div>
<div className={styles.buttons}>
<Button onClick={handleCopyPrimaryClicked} className={styles.button}>
<Button
onClick={handleCopyPrimaryClicked}
className={styles.button}
size="smaller"
disabled={isDisabled}
>
{lang('FolderLinkScreen.LinkActionCopy')}
</Button>
<Button onClick={handleShare} className={styles.button}>
<Button
onClick={handleShare}
className={styles.button}
size="smaller"
disabled={isDisabled}
>
{lang('FolderLinkScreen.LinkActionShare')}
</Button>
</div>

View File

@ -6,6 +6,7 @@ import { requestMutation } from '../../lib/fasterdom/fasterdom';
import type { FC } from '../../lib/teact/teact';
import { isUserId } from '../../global/helpers';
import buildClassName from '../../util/buildClassName';
import { MEMO_EMPTY_ARRAY } from '../../util/memo';
import useInfiniteScroll from '../../hooks/useInfiniteScroll';
import useLang from '../../hooks/useLang';
@ -32,6 +33,7 @@ type OwnProps = {
isLoading?: boolean;
noScrollRestore?: boolean;
isSearchable?: boolean;
isRoundCheckbox?: boolean;
lockedIds?: string[];
onSelectedIdsChange?: (ids: string[]) => void;
onFilterChange?: (value: string) => void;
@ -55,6 +57,7 @@ const Picker: FC<OwnProps> = ({
isLoading,
noScrollRestore,
isSearchable,
isRoundCheckbox,
lockedIds,
onSelectedIdsChange,
onFilterChange,
@ -161,24 +164,37 @@ const Picker: FC<OwnProps> = ({
onLoadMore={getMore}
noScrollRestore={noScrollRestore}
>
{viewportIds.map((id) => (
<ListItem
key={id}
className="chat-item-clickable picker-list-item"
disabled={lockedIdsSet.has(id)}
allowDisabledClick={Boolean(onDisabledClick)}
// eslint-disable-next-line react/jsx-no-bind
onClick={() => handleItemClick(id)}
ripple
>
<Checkbox label="" disabled={lockedIdsSet.has(id)} checked={selectedIds.includes(id)} />
{isUserId(id) ? (
<PrivateChatInfo userId={id} />
) : (
<GroupChatInfo chatId={id} />
)}
</ListItem>
))}
{viewportIds.map((id) => {
const renderCheckbox = () => {
return (
<Checkbox
label=""
disabled={lockedIdsSet.has(id)}
checked={selectedIds.includes(id)}
round={isRoundCheckbox}
/>
);
};
return (
<ListItem
key={id}
className={buildClassName('chat-item-clickable picker-list-item', isRoundCheckbox && 'chat-item')}
disabled={lockedIdsSet.has(id)}
allowDisabledClick={Boolean(onDisabledClick)}
// eslint-disable-next-line react/jsx-no-bind
onClick={() => handleItemClick(id)}
ripple
>
{!isRoundCheckbox ? renderCheckbox() : undefined}
{isUserId(id) ? (
<PrivateChatInfo userId={id} />
) : (
<GroupChatInfo chatId={id} />
)}
{isRoundCheckbox ? renderCheckbox() : undefined}
</ListItem>
);
})}
</InfiniteScroll>
) : !isLoading && viewportIds && !viewportIds.length ? (
<p className="no-results">{notFoundText || 'Sorry, nothing found.'}</p>

View File

@ -6,8 +6,10 @@ import type { ApiChatFolder } from '../../../../api/types';
import { SettingsScreens } from '../../../../types';
import type { FolderEditDispatch, FoldersState } from '../../../../hooks/reducers/useFoldersReducer';
import { selectChatFilters } from '../../../../hooks/reducers/useFoldersReducer';
import SettingsFoldersMain from './SettingsFoldersMain';
import SettingsFoldersEdit from './SettingsFoldersEdit';
import SettingsFoldersEdit, { ERROR_NO_CHATS, ERROR_NO_TITLE } from './SettingsFoldersEdit';
import SettingsFoldersChatFilters from './SettingsFoldersChatFilters';
import SettingsShareChatlist from './SettingsShareChatlist';
@ -34,7 +36,11 @@ const SettingsFolders: FC<OwnProps> = ({
onScreenSelect,
onReset,
}) => {
const { openShareChatFolderModal } = getActions();
const {
openShareChatFolderModal,
editChatFolder,
addChatFolder,
} = getActions();
const handleReset = useCallback(() => {
if (
@ -66,10 +72,50 @@ const SettingsFolders: FC<OwnProps> = ({
currentScreen, onReset, onScreenSelect,
]);
const isCreating = state.mode === 'create';
const saveState = useCallback((newState: FoldersState) => {
const { title } = newState.folder;
if (!title) {
dispatch({ type: 'setError', payload: ERROR_NO_TITLE });
return false;
}
const {
selectedChatIds: includedChatIds,
selectedChatTypes: includedChatTypes,
} = selectChatFilters(newState, 'included');
if (!includedChatIds.length && !Object.keys(includedChatTypes).length) {
dispatch({ type: 'setError', payload: ERROR_NO_CHATS });
return false;
}
if (!isCreating) {
editChatFolder({ id: newState.folderId!, folderUpdate: newState.folder });
} else {
addChatFolder({ folder: newState.folder as ApiChatFolder });
}
dispatch({ type: 'setError', payload: undefined });
dispatch({ type: 'setIsTouched', payload: false });
return true;
}, [dispatch, isCreating]);
const handleSaveFolder = useCallback((cb?: NoneToVoidFunction) => {
if (!saveState(state)) {
return;
}
cb?.();
}, [saveState, state]);
const handleSaveFilter = useCallback(() => {
dispatch({ type: 'saveFilters' });
const newState = dispatch({ type: 'saveFilters' });
handleReset();
}, [dispatch, handleReset]);
saveState(newState);
}, [dispatch, handleReset, saveState]);
const handleCreateFolder = useCallback(() => {
dispatch({ type: 'reset' });
@ -97,8 +143,9 @@ const SettingsFolders: FC<OwnProps> = ({
const handleShareFolder = useCallback(() => {
openShareChatFolderModal({ folderId: state.folderId!, noRequestNextScreen: true });
dispatch({ type: 'setIsChatlist', payload: true });
onScreenSelect(SettingsScreens.FoldersShare);
}, [onScreenSelect, state.folderId]);
}, [dispatch, onScreenSelect, state.folderId]);
const handleOpenInvite = useCallback((url: string) => {
openShareChatFolderModal({ folderId: state.folderId!, url, noRequestNextScreen: true });
@ -139,6 +186,7 @@ const SettingsFolders: FC<OwnProps> = ({
].includes(shownScreen)}
isOnlyInvites={currentScreen === SettingsScreens.FoldersEditFolderInvites}
onBack={onReset}
onSaveFolder={handleSaveFolder}
/>
);
case SettingsScreens.FoldersIncludedChats:

View File

@ -4,7 +4,7 @@ import React, {
} from '../../../../lib/teact/teact';
import { getActions, getGlobal, withGlobal } from '../../../../global';
import type { ApiChatFolder, ApiChatlistExportedInvite } from '../../../../api/types';
import type { ApiChatlistExportedInvite } from '../../../../api/types';
import { STICKER_SIZE_FOLDER_SETTINGS } from '../../../../config';
import { LOCAL_TGS_URLS } from '../../../common/helpers/animatedAssets';
@ -45,6 +45,7 @@ type OwnProps = {
isOnlyInvites?: boolean;
onReset: () => void;
onBack: () => void;
onSaveFolder: (cb?: VoidFunction) => void;
};
type StateProps = {
@ -59,8 +60,8 @@ const SUBMIT_TIMEOUT = 500;
const INITIAL_CHATS_LIMIT = 5;
const ERROR_NO_TITLE = 'Please provide a title for this folder.';
const ERROR_NO_CHATS = 'ChatList.Filter.Error.Empty';
export const ERROR_NO_TITLE = 'Please provide a title for this folder.';
export const ERROR_NO_CHATS = 'ChatList.Filter.Error.Empty';
const SettingsFoldersEdit: FC<OwnProps & StateProps> = ({
state,
@ -78,10 +79,9 @@ const SettingsFoldersEdit: FC<OwnProps & StateProps> = ({
loadedArchivedChatIds,
invites,
maxInviteLinks,
onSaveFolder,
}) => {
const {
editChatFolder,
addChatFolder,
loadChatlistInvites,
openLimitReachedModal,
showNotification,
@ -154,38 +154,23 @@ const SettingsFoldersEdit: FC<OwnProps & StateProps> = ({
dispatch({ type: 'setTitle', payload: currentTarget.value.trim() });
}, [dispatch]);
const handleSaveFolder = useCallback((cb?: NoneToVoidFunction) => {
const { title } = state.folder;
if (!title) {
dispatch({ type: 'setError', payload: ERROR_NO_TITLE });
return;
}
if (!includedChatIds.length && !Object.keys(includedChatTypes).length) {
dispatch({ type: 'setError', payload: ERROR_NO_CHATS });
return;
}
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);
}, [dispatch, handleSaveFolder, onReset]);
onSaveFolder(() => {
setTimeout(() => {
onReset();
}, SUBMIT_TIMEOUT);
});
}, [dispatch, onSaveFolder, onReset]);
const handleCreateInviteClick = useCallback(() => {
if (!invites) return;
if (!invites) {
if (isCreating) {
onSaveFolder(onShareFolder);
}
return;
}
// Ignoring global updates is a known drawback here
if (!selectCanShareFolder(getGlobal(), state.folderId!)) {
@ -195,7 +180,7 @@ const SettingsFoldersEdit: FC<OwnProps & StateProps> = ({
if (invites.length < maxInviteLinks) {
if (state.isTouched) {
handleSaveFolder(onShareFolder);
onSaveFolder(onShareFolder);
} else {
onShareFolder();
}
@ -204,15 +189,17 @@ const SettingsFoldersEdit: FC<OwnProps & StateProps> = ({
limit: 'chatlistInvites',
});
}
}, [handleSaveFolder, invites, lang, maxInviteLinks, onShareFolder, state.folderId, state.isTouched]);
}, [
invites, state.folderId, state.isTouched, maxInviteLinks, isCreating, onSaveFolder, onShareFolder, lang,
]);
const handleEditInviteClick = useCallback((e: React.MouseEvent<HTMLElement>, url: string) => {
if (state.isTouched) {
handleSaveFolder(() => onOpenInvite(url));
onSaveFolder(() => onOpenInvite(url));
} else {
onOpenInvite(url);
}
}, [handleSaveFolder, onOpenInvite, state.isTouched]);
}, [onSaveFolder, onOpenInvite, state.isTouched]);
function renderChatType(key: string, mode: 'included' | 'excluded') {
const chatType = mode === 'included'
@ -339,38 +326,36 @@ const SettingsFoldersEdit: FC<OwnProps & StateProps> = ({
</div>
)}
{!isCreating && (
<div className="settings-item pt-3">
<h4 className="settings-item-header mb-3" dir={lang.isRtl ? 'rtl' : undefined}>
{lang('FolderLinkScreen.Title')}
</h4>
<div className="settings-item pt-3">
<h4 className="settings-item-header mb-3" dir={lang.isRtl ? 'rtl' : undefined}>
{lang('FolderLinkScreen.Title')}
</h4>
<ListItem
className="settings-folders-list-item color-primary mb-0"
icon="add"
onClick={handleCreateInviteClick}
>
{lang('ChatListFilter.CreateLinkNew')}
</ListItem>
{invites?.map((invite) => (
<ListItem
className="settings-folders-list-item color-primary mb-0"
icon="add"
onClick={handleCreateInviteClick}
className="settings-folders-list-item mb-0"
icon="link"
multiline
// eslint-disable-next-line react/jsx-no-bind
onClick={handleEditInviteClick}
clickArg={invite.url}
>
{lang('ChatListFilter.CreateLinkNew')}
<span className="title" dir="auto">{invite.title || invite.url}</span>
<span className="subtitle">
{lang('ChatListFilter.LinkLabelChatCount', invite.peerIds.length, 'i')}
</span>
</ListItem>
))}
{invites?.map((invite) => (
<ListItem
className="settings-folders-list-item mb-0"
icon="link"
multiline
// eslint-disable-next-line react/jsx-no-bind
onClick={handleEditInviteClick}
clickArg={invite.url}
>
<span className="title" dir="auto">{invite.title || invite.url}</span>
<span className="subtitle">
{lang('ChatListFilter.LinkLabelChatCount', invite.peerIds.length, 'i')}
</span>
</ListItem>
))}
</div>
)}
</div>
</div>
<FloatingActionButton

View File

@ -1,5 +1,5 @@
import React, {
memo, useCallback, useEffect, useMemo, useState,
memo, useCallback, useEffect, useMemo, useRef, useState,
} from '../../../../lib/teact/teact';
import { getActions, getGlobal, withGlobal } from '../../../../global';
@ -93,13 +93,19 @@ const SettingsShareChatlist: FC<OwnProps & StateProps> = ({
const [selectedIds, setSelectedIds] = useState<string[]>(peerIds || []);
useEffectWithPrevDeps(([prevIsLoading]) => {
if (isLoading && !prevIsLoading) {
const isFirstRenderRef = useRef(true);
useEffectWithPrevDeps(([prevUrl]) => {
if (prevUrl !== url) {
isFirstRenderRef.current = true;
}
if (!isFirstRenderRef.current) return;
isFirstRenderRef.current = false;
if (!url) {
setSelectedIds(unlockedIds);
} else if (peerIds) {
setSelectedIds(peerIds);
}
}, [isLoading, unlockedIds, peerIds]);
}, [url, unlockedIds, peerIds]);
const handleClickDisabled = useCallback((id: string) => {
const global = getGlobal();
@ -135,9 +141,10 @@ const SettingsShareChatlist: FC<OwnProps & StateProps> = ({
}, [folderId, selectedIds, url]);
const chatsCount = selectedIds.length;
const isDisabled = !chatsCount || isLoading;
return (
<div className="settings-content no-border custom-scroll">
<div className="settings-content no-border custom-scroll SettingsFoldersChatsPicker">
<div className="settings-content-header">
<AnimatedIcon
size={STICKER_SIZE_FOLDER_SETTINGS}
@ -154,6 +161,7 @@ const SettingsShareChatlist: FC<OwnProps & StateProps> = ({
<InviteLink
inviteLink={isLoading ? lang('Loading') : url!}
onRevoke={handleRevoke}
isDisabled={isDisabled}
/>
<div className="settings-item settings-item-chatlist">
@ -163,12 +171,13 @@ const SettingsShareChatlist: FC<OwnProps & StateProps> = ({
onSelectedIdsChange={handleSelectedIdsChange}
selectedIds={selectedIds}
onDisabledClick={handleClickDisabled}
isRoundCheckbox
/>
</div>
<FloatingActionButton
isShown={isLoading || isTouched}
disabled={isLoading}
disabled={isDisabled}
onClick={handleSubmit}
ariaLabel="Save changes"
>

View File

@ -764,12 +764,29 @@ addActionHandler('addChatFolder', async (global, actions, payload): Promise<void
// Clear fields from recommended folders
const { id: recommendedId, description, ...newFolder } = folder;
const newId = maxId + 1;
const folderUpdate = {
id: newId,
...newFolder,
};
await callApi('editChatFolder', {
id: maxId + 1,
folderUpdate: {
id: maxId + 1,
...newFolder,
id: newId,
folderUpdate,
});
// Update called from the above `callApi` is throttled, but we need to apply changes immediately
actions.apiUpdate({
'@type': 'updateChatFolder',
id: newId,
folder: folderUpdate,
});
actions.requestNextSettingsScreen({
foldersAction: {
type: 'setFolderId',
payload: maxId + 1,
},
tabId,
});
if (!description) {
@ -2019,33 +2036,43 @@ addActionHandler('editChatlistInvite', async (global, actions, payload): Promise
}, tabId);
setGlobal(global);
const result = await callApi('editChatlistInvite', { folderId, slug, peers });
try {
const result = await callApi('editChatlistInvite', { folderId, slug, peers });
if (!result) return;
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 = 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);
};
setGlobal(global);
} catch (error) {
actions.showDialog({ data: { ...(error as ApiError), hasErrorKey: true }, tabId });
} finally {
global = getGlobal();
global = updateTabState(global, {
shareFolderScreen: {
...selectTabState(global, tabId).shareFolderScreen!,
isLoading: false,
},
}, tabId);
setGlobal(global);
}
});
addActionHandler('deleteChatlistInvite', async (global, actions, payload): Promise<void> => {

View File

@ -4,7 +4,7 @@ import type { ApiUpdateChat } from '../../../api/types';
import type { ActionReturnType } from '../../types';
import { MAIN_THREAD_ID } from '../../../api/types';
import { ALL_FOLDER_ID, ARCHIVED_FOLDER_ID, MAX_ACTIVE_PINNED_CHATS } from '../../../config';
import { ARCHIVED_FOLDER_ID, MAX_ACTIVE_PINNED_CHATS } from '../../../config';
import { buildCollectionByKey, omit } from '../../../util/iteratees';
import { closeMessageNotifications, notifyAboutMessage } from '../../../util/notifications';
import {
@ -271,6 +271,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
...global.chatFolders,
byId: newChatFoldersById,
orderedIds: newOrderedIds,
invites: omit(global.chatFolders.invites, [id]),
},
};
}

View File

@ -252,7 +252,7 @@ export function selectCanInviteToChat<T extends GlobalState>(global: T, 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)) ? (
return !chat.migratedTo && Boolean(!isUserId(chatId) && ((isChatChannel(chat) || isChatSuperGroup(chat)) ? (
chat.isCreator || getHasAdminRight(chat, 'inviteUsers')
|| (chat.usernames?.length && !chat.isJoinRequest)
) : (chat.isCreator || getHasAdminRight(chat, 'inviteUsers'))));

View File

@ -122,9 +122,10 @@ export type FoldersState = {
};
export type FoldersActions = (
'setTitle' | 'saveFilters' | 'editFolder' | 'reset' | 'setChatFilter' | 'setIsLoading' | 'setError' |
'editIncludeFilters' | 'editExcludeFilters' | 'setIncludeFilters' | 'setExcludeFilters'
'editIncludeFilters' | 'editExcludeFilters' | 'setIncludeFilters' | 'setExcludeFilters' | 'setIsTouched' |
'setFolderId' | 'setIsChatlist'
);
export type FolderEditDispatch = Dispatch<FoldersActions>;
export type FolderEditDispatch = Dispatch<FoldersState, FoldersActions>;
const INITIAL_STATE: FoldersState = {
mode: 'create',
@ -150,6 +151,12 @@ const foldersReducer: StateReducer<FoldersState, FoldersActions> = (
},
isTouched: true,
};
case 'setFolderId':
return {
...state,
folderId: action.payload,
mode: 'edit',
};
case 'editIncludeFilters':
return {
...state,
@ -221,6 +228,12 @@ const foldersReducer: StateReducer<FoldersState, FoldersActions> = (
chatFilter: action.payload,
};
}
case 'setIsTouched': {
return {
...state,
isTouched: action.payload,
};
}
case 'setIsLoading': {
return {
...state,
@ -230,9 +243,18 @@ const foldersReducer: StateReducer<FoldersState, FoldersActions> = (
case 'setError': {
return {
...state,
isLoading: false,
error: action.payload,
};
}
case 'setIsChatlist':
return {
...state,
folder: {
...state.folder,
isChatList: action.payload,
},
};
case 'reset':
return INITIAL_STATE;
default:

View File

@ -32,7 +32,7 @@ export type FormActions = (
'changeBillingZip' | 'changeSaveInfo' | 'changeSaveCredentials' | 'setFormErrors' | 'resetState' | 'setTipAmount' |
'changeSavedCredentialId'
);
export type FormEditDispatch = Dispatch<FormActions>;
export type FormEditDispatch = Dispatch<FormState, FormActions>;
const INITIAL_STATE: FormState = {
streetLine1: '',

View File

@ -4,7 +4,7 @@ import useReducer from '../useReducer';
export type TwoFaActions = (
'setCurrentPassword' | 'setPassword' | 'setHint' | 'setEmail' | 'reset'
);
export type TwoFaDispatch = Dispatch<TwoFaActions>;
export type TwoFaDispatch = Dispatch<TwoFaState, TwoFaActions>;
export type TwoFaState = {
currentPassword: string;

View File

@ -4,7 +4,7 @@ import useForceUpdate from './useForceUpdate';
export type ReducerAction<Actions> = { type: Actions; payload?: any };
export type StateReducer<State, Actions> = (state: State, action: ReducerAction<Actions>) => State;
export type Dispatch<Actions> = (action: ReducerAction<Actions>) => void;
export type Dispatch<State, Actions> = (action: ReducerAction<Actions>) => State;
export default function useReducer<State, Actions>(
reducer: StateReducer<State, Actions>,
@ -17,10 +17,11 @@ export default function useReducer<State, Actions>(
const dispatch = useCallback((action: ReducerAction<Actions>) => {
state.current = reducerRef.current(state.current, action);
forceUpdate();
return state.current;
}, []);
return [
state.current,
dispatch,
] as [State, Dispatch<Actions>];
] as [State, Dispatch<State, Actions>];
}

View File

@ -69,6 +69,8 @@ const READABLE_ERROR_MESSAGES: Record<string, string> = {
FRESH_CHANGE_ADMINS_FORBIDDEN: 'You were just elected admin, you can\'t add or modify other admins yet',
INPUT_USER_DEACTIVATED: 'The specified user was deleted',
BOT_PRECHECKOUT_TIMEOUT: 'The request for payment has expired',
PEERS_LIST_EMPTY: 'No chats are added to the list',
};
if (DEBUG) {