Settings / Folders: Support re-ordering (#1973)

This commit is contained in:
Alexander Zinchuk 2022-08-05 19:23:03 +02:00
parent 3493f247eb
commit 6445847af0
16 changed files with 613 additions and 233 deletions

View File

@ -831,6 +831,12 @@ export async function deleteChatFolder(id: number) {
}
}
export function sortChatFolders(ids: number[]) {
return invokeRequest(new GramJs.messages.UpdateDialogFiltersOrder({
order: ids,
}));
}
export async function toggleDialogUnread({
chat, hasUnreadMark,
}: {

View File

@ -15,7 +15,7 @@ export {
saveDraft, clearDraft, fetchChat, updateChatMutedState,
createChannel, joinChannel, deleteChatUser, deleteChat, leaveChannel, deleteChannel, createGroupChat, editChatPhoto,
toggleChatPinned, toggleChatArchived, toggleDialogUnread, setChatEnabledReactions,
fetchChatFolders, editChatFolder, deleteChatFolder, fetchRecommendedChatFolders,
fetchChatFolders, editChatFolder, deleteChatFolder, sortChatFolders, fetchRecommendedChatFolders,
getChatByUsername, togglePreHistoryHidden, updateChatDefaultBannedRights, updateChatMemberBannedRights,
updateChatTitle, updateChatAbout, toggleSignatures, updateChatAdmin, fetchGroupsForDiscussion, setDiscussionGroup,
migrateChat, openChatByInvite, fetchMembers, importChatInvite, addChatMembers, deleteChatMember, toggleIsProtected,

Binary file not shown.

Binary file not shown.

View File

@ -76,3 +76,15 @@
width: calc(100% + 2rem);
padding-left: 1rem !important;
}
.settings-sortable-container {
position: relative;
}
.settings-sortable-container .draggable-knob {
margin-top: -0.25rem;
}
.settings-sortable-item .multiline-item {
padding-inline-end: 3rem;
}

View File

@ -1,6 +1,6 @@
import type { FC } from '../../../../lib/teact/teact';
import React, {
memo, useMemo, useCallback, useEffect,
memo, useMemo, useCallback, useEffect, useState,
} from '../../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../../global';
@ -21,6 +21,7 @@ import ListItem from '../../../ui/ListItem';
import Button from '../../../ui/Button';
import Loading from '../../../ui/Loading';
import AnimatedIcon from '../../../common/AnimatedIcon';
import Draggable from '../../../ui/Draggable';
type OwnProps = {
isActive?: boolean;
@ -30,13 +31,20 @@ type OwnProps = {
};
type StateProps = {
orderedFolderIds?: number[];
folderIds?: number[];
foldersById: Record<number, ApiChatFolder>;
recommendedChatFolders?: ApiChatFolder[];
maxFolders: number;
isPremium?: boolean;
};
type SortState = {
orderedFolderIds?: number[];
dragOrderIds?: number[];
draggedIndex?: number;
};
const FOLDER_HEIGHT_PX = 68;
const runThrottledForLoadRecommended = throttle((cb) => cb(), 60000, true);
const SettingsFoldersMain: FC<OwnProps & StateProps> = ({
@ -44,7 +52,7 @@ const SettingsFoldersMain: FC<OwnProps & StateProps> = ({
onCreateFolder,
onEditFolder,
onReset,
orderedFolderIds,
folderIds,
foldersById,
isPremium,
recommendedChatFolders,
@ -55,8 +63,15 @@ const SettingsFoldersMain: FC<OwnProps & StateProps> = ({
addChatFolder,
openLimitReachedModal,
openDeleteChatFolderModal,
sortChatFolders,
} = getActions();
const [state, setState] = useState<SortState>({
orderedFolderIds: folderIds,
dragOrderIds: folderIds,
draggedIndex: undefined,
});
// Due to the parent Transition, this component never gets unmounted,
// that's why we use throttled API call on every update.
useEffect(() => {
@ -86,15 +101,15 @@ const SettingsFoldersMain: FC<OwnProps & StateProps> = ({
const chatsCountByFolderId = useFolderManagerForChatsCount();
const userFolders = useMemo(() => {
if (!orderedFolderIds) {
if (!state.orderedFolderIds) {
return undefined;
}
if (orderedFolderIds.length <= 1) {
if (state.orderedFolderIds.length <= 1) {
return MEMO_EMPTY_ARRAY;
}
return orderedFolderIds.map((id) => {
return state.orderedFolderIds.map((id) => {
const folder = foldersById[id];
if (id === ALL_FOLDER_ID) {
@ -110,7 +125,7 @@ const SettingsFoldersMain: FC<OwnProps & StateProps> = ({
subtitle: getFolderDescriptionText(lang, folder, chatsCountByFolderId[folder.id]),
};
});
}, [orderedFolderIds, foldersById, lang, chatsCountByFolderId]);
}, [state.orderedFolderIds, foldersById, lang, chatsCountByFolderId]);
const handleCreateFolderFromRecommended = useCallback((folder: ApiChatFolder) => {
if (Object.keys(foldersById).length >= maxFolders - 1) {
@ -124,6 +139,35 @@ const SettingsFoldersMain: FC<OwnProps & StateProps> = ({
addChatFolder({ folder });
}, [foldersById, maxFolders, addChatFolder, openLimitReachedModal]);
const handleDrag = useCallback((translation: { x: number; y: number }, id: number) => {
const delta = Math.round(translation.y / FOLDER_HEIGHT_PX);
const index = state.orderedFolderIds?.indexOf(id) || 0;
const dragOrderIds = state.orderedFolderIds?.filter((folderId) => folderId !== id);
if (!dragOrderIds || !inRange(index + delta, 0, folderIds?.length || 0)) {
return;
}
dragOrderIds.splice(index + delta + (isPremium ? 0 : 1), 0, id);
setState((current) => ({
...current,
draggedIndex: index,
dragOrderIds,
}));
}, [folderIds?.length, isPremium, state.orderedFolderIds]);
const handleDragEnd = useCallback(() => {
setState((current) => {
sortChatFolders({ folderIds: current.dragOrderIds! });
return {
...current,
orderedFolderIds: current.dragOrderIds,
draggedIndex: undefined,
};
});
}, [sortChatFolders]);
const canCreateNewFolder = useMemo(() => {
return !isPremium || Object.keys(foldersById).length < maxFolders - 1;
}, [foldersById, isPremium, maxFolders]);
@ -161,60 +205,91 @@ const SettingsFoldersMain: FC<OwnProps & StateProps> = ({
<div className="settings-item pt-3">
<h4 className="settings-item-header mb-3" dir={lang.isRtl ? 'rtl' : undefined}>{lang('Filters')}</h4>
{userFolders?.length ? userFolders.map((folder, i) => {
const isBlocked = i > maxFolders - 1;
if (folder.id === ALL_FOLDER_ID) {
return (
<ListItem
className="mb-2 no-icon"
narrow
inactive
isStatic
>
{folder.title}
</ListItem>
);
}
<div className="settings-sortable-container" style={`height: ${(folderIds?.length || 0) * FOLDER_HEIGHT_PX}px`}>
{userFolders?.length ? userFolders.map((folder, i) => {
const isBlocked = i > maxFolders - 1;
const isDragged = state.draggedIndex === i;
const draggedTop = (state.orderedFolderIds?.indexOf(folder.id) ?? 0) * FOLDER_HEIGHT_PX;
const top = (state.dragOrderIds?.indexOf(folder.id) ?? 0) * FOLDER_HEIGHT_PX;
return (
<ListItem
className="mb-2 no-icon"
narrow
secondaryIcon="more"
multiline
contextActions={[
{
handler: () => {
openDeleteChatFolderModal({ folderId: folder.id });
},
destructive: true,
title: lang('Delete'),
icon: 'delete',
},
]}
// eslint-disable-next-line react/jsx-no-bind
onClick={() => {
if (isBlocked) {
openLimitReachedModal({
limit: 'dialogFilters',
});
} else {
onEditFolder(foldersById[folder.id]);
}
}}
>
<span className="title">
{folder.title}
{isBlocked && <i className="icon-lock-badge settings-folders-blocked-icon" />}
</span>
<span className="subtitle">{folder.subtitle}</span>
</ListItem>
);
}) : userFolders && !userFolders.length ? (
<p className="settings-item-description my-4" dir="auto">
You have no folders yet.
</p>
) : <Loading />}
if (folder.id === ALL_FOLDER_ID) {
return (
<Draggable
key={folder.id}
id={folder.id}
onDrag={handleDrag}
onDragEnd={handleDragEnd}
style={`top: ${isDragged ? draggedTop : top}px;`}
knobStyle={`${lang.isRtl ? 'left' : 'right'}: 0.375rem;`}
isDisabled={!isPremium || !isActive}
>
<ListItem
key={folder.id}
className="mb-2 no-icon settings-sortable-item"
narrow
inactive
multiline
isStatic
>
<span className="title">
{folder.title}
</span>
<span className="subtitle">{lang('FoldersAllChatsDesc')}</span>
</ListItem>
</Draggable>
);
}
return (
<Draggable
key={folder.id}
id={folder.id}
onDrag={handleDrag}
onDragEnd={handleDragEnd}
style={`top: ${isDragged ? draggedTop : top}px;`}
knobStyle={`${lang.isRtl ? 'left' : 'right'}: 3rem;`}
isDisabled={isBlocked || !isActive}
>
<ListItem
className="mb-2 no-icon settings-sortable-item"
narrow
secondaryIcon="more"
multiline
contextActions={[
{
handler: () => {
openDeleteChatFolderModal({ folderId: folder.id });
},
destructive: true,
title: lang('Delete'),
icon: 'delete',
},
]}
// eslint-disable-next-line react/jsx-no-bind
onClick={() => {
if (isBlocked) {
openLimitReachedModal({
limit: 'dialogFilters',
});
} else {
onEditFolder(foldersById[folder.id]);
}
}}
>
<span className="title">
{folder.title}
{isBlocked && <i className="icon-lock-badge settings-folders-blocked-icon" />}
</span>
<span className="subtitle">{folder.subtitle}</span>
</ListItem>
</Draggable>
);
}) : userFolders && !userFolders.length ? (
<p className="settings-item-description my-4" dir="auto">
You have no folders yet.
</p>
) : <Loading />}
</div>
</div>
{(recommendedChatFolders && Boolean(recommendedChatFolders.length)) && (
@ -258,13 +333,13 @@ const SettingsFoldersMain: FC<OwnProps & StateProps> = ({
export default memo(withGlobal<OwnProps>(
(global): StateProps => {
const {
orderedIds: orderedFolderIds,
orderedIds: folderIds,
byId: foldersById,
recommended: recommendedChatFolders,
} = global.chatFolders;
return {
orderedFolderIds,
folderIds,
foldersById,
isPremium: selectIsCurrentUserPremium(global),
recommendedChatFolders,
@ -272,3 +347,7 @@ export default memo(withGlobal<OwnProps>(
};
},
)(SettingsFoldersMain));
function inRange(x: number, min: number, max: number) {
return x >= min && x <= max;
}

View File

@ -0,0 +1,43 @@
.container {
position: absolute;
width: 100%;
}
.isDragging {
z-index: 2;
> *:not(.knob) {
pointer-events: none;
}
}
.knob {
position: absolute;
top: 50%;
width: 2rem;
height: 2rem;
color: var(--color-text-secondary);
font-size: 1.25rem;
line-height: 1.75;
text-align: center;
opacity: 0;
transition: opacity 150ms;
cursor: grab !important;
transform: translateY(-50%);
.container:hover & {
opacity: 1;
}
.isDragging & {
opacity: 1;
cursor: grabbing !important;
}
@media (pointer: coarse) {
opacity: 1 !important;
touch-action: none;
}
}

View File

@ -0,0 +1,189 @@
import React, {
memo, useCallback, useEffect, useMemo, useRef, useState,
} from '../../lib/teact/teact';
import type { FC } from '../../lib/teact/teact';
import buildClassName from '../../util/buildClassName';
import styles from './Draggable.module.scss';
import useLang from '../../hooks/useLang';
import buildStyle from '../../util/buildStyle';
type TPoint = {
x: number;
y: number;
};
type DraggableState = {
isDragging: boolean;
origin: TPoint;
translation: TPoint;
width?: number;
height?: number;
};
type OwnProps = {
children: React.ReactNode;
onDrag: (translation: TPoint, id: number) => void;
onDragEnd: NoneToVoidFunction;
id: number;
style?: string;
knobStyle?: string;
isDisabled?: boolean;
};
const ZERO_POINT: TPoint = { x: 0, y: 0 };
const Draggable: FC<OwnProps> = ({
children,
id,
onDrag,
onDragEnd,
style: externalStyle,
knobStyle,
isDisabled,
}) => {
const lang = useLang();
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null);
const [state, setState] = useState<DraggableState>({
isDragging: false,
origin: ZERO_POINT,
translation: ZERO_POINT,
});
const handleMouseDown = (e: React.MouseEvent | React.TouchEvent) => {
e.stopPropagation();
e.preventDefault();
const { x, y } = getClientCoordinate(e);
setState({
...state,
isDragging: true,
origin: { x, y },
width: ref.current?.offsetWidth,
height: ref.current?.offsetHeight,
});
};
const handleMouseMove = useCallback((e: MouseEvent | TouchEvent) => {
const { x, y } = getClientCoordinate(e);
const translation = {
x: x - state.origin.x,
y: y - state.origin.y,
};
setState((current) => ({
...current,
translation,
}));
onDrag(translation, id);
}, [id, onDrag, state.origin.x, state.origin.y]);
const handleMouseUp = useCallback(() => {
requestAnimationFrame(() => {
setState((current) => ({
...current,
isDragging: false,
width: undefined,
height: undefined,
}));
onDragEnd();
});
}, [onDragEnd]);
useEffect(() => {
if (state.isDragging && isDisabled) {
setState((current) => ({
...current,
isDragging: false,
width: undefined,
height: undefined,
}));
}
}, [isDisabled, state.isDragging]);
useEffect(() => {
if (state.isDragging) {
window.addEventListener('touchmove', handleMouseMove);
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('touchend', handleMouseUp);
window.addEventListener('touchcancel', handleMouseUp);
window.addEventListener('mouseup', handleMouseUp);
} else {
window.removeEventListener('touchmove', handleMouseMove);
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('touchend', handleMouseUp);
window.removeEventListener('touchcancel', handleMouseUp);
window.removeEventListener('mouseup', handleMouseUp);
setState((current) => ({
...current,
translation: ZERO_POINT,
}));
}
return () => {
if (state.isDragging) {
window.removeEventListener('touchmove', handleMouseMove);
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('touchend', handleMouseUp);
window.removeEventListener('touchcancel', handleMouseUp);
window.removeEventListener('mouseup', handleMouseUp);
}
};
}, [handleMouseMove, handleMouseUp, state.isDragging]);
const fullClassName = buildClassName(styles.container, state.isDragging && styles.isDragging);
const cssStyles = useMemo(() => {
return buildStyle(
`transform: translate(${state.translation.x}px, ${state.translation.y}px)`,
state.width ? `width: ${state.width}px` : undefined,
state.height ? `height: ${state.height}px` : undefined,
externalStyle,
);
}, [externalStyle, state.height, state.translation.x, state.translation.y, state.width]);
return (
<div style={cssStyles} className={fullClassName} ref={ref}>
{children}
{!isDisabled && (
<div
aria-label={lang('i18n_dragToSort')}
tabIndex={0}
role="button"
className={buildClassName(styles.knob, 'draggable-knob')}
onMouseDown={handleMouseDown}
onTouchStart={handleMouseDown}
style={knobStyle}
>
<i className="icon-sort" aria-hidden />
</div>
)}
</div>
);
};
export default memo(Draggable);
function getClientCoordinate(e: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent) {
let x;
let y;
if ('touches' in e) {
x = e.touches[0].clientX;
y = e.touches[0].clientY;
} else {
x = e.clientX;
y = e.clientY;
}
return { x, y };
}

View File

@ -38,7 +38,7 @@ export const MEDIA_PROGRESSIVE_CACHE_DISABLED = false;
export const MEDIA_PROGRESSIVE_CACHE_NAME = 'tt-media-progressive';
export const MEDIA_CACHE_MAX_BYTES = 512 * 1024; // 512 KB
export const CUSTOM_BG_CACHE_NAME = 'tt-custom-bg';
export const LANG_CACHE_NAME = 'tt-lang-packs-v10';
export const LANG_CACHE_NAME = 'tt-lang-packs-v11';
export const ASSET_CACHE_NAME = 'tt-assets';
export const AUTODOWNLOAD_FILESIZE_MB_LIMITS = [1, 5, 10, 50, 100, 500];

View File

@ -491,6 +491,22 @@ addActionHandler('addChatFolder', (global, actions, payload) => {
void createChatFolder(folder, maxId);
});
addActionHandler('sortChatFolders', async (global, actions, payload) => {
const { folderIds } = payload!;
const result = await callApi('sortChatFolders', folderIds);
if (result) {
global = getGlobal();
setGlobal({
...global,
chatFolders: {
...global.chatFolders,
orderedIds: folderIds,
},
});
}
});
addActionHandler('deleteChatFolder', (global, actions, payload) => {
const { id } = payload!;
const folder = selectChatFolder(global, id);

View File

@ -1052,6 +1052,7 @@ export interface ActionPayloads {
// Settings
requestNextSettingsScreen: SettingsScreens;
sortChatFolders: { folderIds: number[] };
closeDeleteChatFolderModal: never;
openDeleteChatFolderModal: { folderId: number };
loadGlobalPrivacySettings: never;

View File

@ -1143,6 +1143,7 @@ messages.getPollVotes#b86e380e flags:# peer:InputPeer id:int option:flags.0?byte
messages.getDialogFilters#f19ed96d = Vector<DialogFilter>;
messages.getSuggestedDialogFilters#a29cd42c = Vector<DialogFilterSuggested>;
messages.updateDialogFilter#1ad4a04a flags:# id:int filter:flags.0?DialogFilter = Bool;
messages.updateDialogFiltersOrder#c563c1e4 order:Vector<int> = Bool;
messages.getReplies#22ddd30c peer:InputPeer msg_id:int offset_id:int offset_date:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages;
messages.getDiscussionMessage#446972fd peer:InputPeer msg_id:int = messages.DiscussionMessage;
messages.readDiscussion#f731a9f4 peer:InputPeer msg_id:int read_max_id:int = Bool;

View File

@ -146,6 +146,7 @@
"messages.getDialogFilters",
"messages.getSuggestedDialogFilters",
"messages.updateDialogFilter",
"messages.updateDialogFiltersOrder",
"messages.getReplies",
"messages.getDiscussionMessage",
"messages.readDiscussion",

File diff suppressed because it is too large Load Diff

View File

@ -51,6 +51,9 @@
.icon-volume-3:before {
content: "\e991";
}
.icon-sort:before {
content: "\e9ac";
}
.icon-web:before {
content: "\e9ab";
}

View File

@ -1853,4 +1853,8 @@ export default {
key: 'Settings.TipsUsername',
value: 'TelegramTips',
},
FoldersAllChatsDesc: {
key: 'FoldersAllChatsDesc',
value: 'All unarchived chats',
},
} as ApiLangPack;