From bfc958484cf39254f3411ab74a1d752f158840ed Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Fri, 21 Jan 2022 17:29:35 +0100 Subject: [PATCH] Management: Introduce Exported Invites (#1645) --- .eslintignore | 1 + src/api/gramjs/apiBuilders/chats.ts | 30 ++ src/api/gramjs/methods/chats.ts | 79 ++++++ src/api/gramjs/methods/index.ts | 1 + src/api/types/misc.ts | 14 + src/components/common/CalendarModal.tsx | 16 +- src/components/common/ChatExtra.tsx | 8 +- src/components/middle/message/MessageMeta.tsx | 2 +- src/components/right/RightColumn.tsx | 7 + src/components/right/RightHeader.tsx | 14 + .../right/management/ManageChannel.tsx | 41 ++- .../right/management/ManageGroup.tsx | 38 ++- .../right/management/ManageInvite.tsx | 268 ++++++++++++++++++ .../right/management/ManageInvites.tsx | 262 +++++++++++++++++ .../right/management/Management.scss | 74 ++++- .../right/management/Management.tsx | 21 +- src/components/ui/InputText.tsx | 4 +- src/components/ui/ListItem.scss | 2 + src/components/ui/ListItem.tsx | 31 +- src/global/types.ts | 8 +- src/lib/gramjs/tl/apiTl.js | 3 + src/lib/gramjs/tl/generateModules.js | 7 +- src/lib/gramjs/tl/static/api.json | 3 + src/modules/actions/api/chats.ts | 77 +++++ src/modules/actions/api/management.ts | 6 + src/modules/helpers/chats.ts | 2 +- src/modules/helpers/users.ts | 4 +- src/modules/reducers/management.ts | 8 +- src/types/index.ts | 11 + src/util/dateFormat.ts | 26 +- 30 files changed, 1022 insertions(+), 46 deletions(-) create mode 100644 src/components/right/management/ManageInvite.tsx create mode 100644 src/components/right/management/ManageInvites.tsx diff --git a/.eslintignore b/.eslintignore index 2bff420d0..03462bf01 100644 --- a/.eslintignore +++ b/.eslintignore @@ -9,3 +9,4 @@ src/lib/gramjs/tl/apiTl.js src/lib/gramjs/tl/schemaTl.js src/lib/gramjs/tl/apiTl.full.js src/lib/gramjs/tl/schemaTl.full.js +src/lib/gramjs/tl/generateModules.js diff --git a/src/api/gramjs/apiBuilders/chats.ts b/src/api/gramjs/apiBuilders/chats.ts index 088d7862d..25f1d6145 100644 --- a/src/api/gramjs/apiBuilders/chats.ts +++ b/src/api/gramjs/apiBuilders/chats.ts @@ -8,6 +8,7 @@ import { ApiChatFolder, ApiChatMember, ApiRestrictionReason, + ApiExportedInvite, } from '../../types'; import { pick, pickTruthy } from '../../../util/iteratees'; import { @@ -386,3 +387,32 @@ export function buildApiChatBotCommands(botInfos: GramJs.BotInfo[]) { return botCommands; }, [] as ApiBotCommand[]); } + +export function buildApiExportedInvite(invite: GramJs.ChatInviteExported) : ApiExportedInvite { + const { + revoked, + date, + expireDate, + link, + permanent, + startDate, + usage, + usageLimit, + requested, + requestNeeded, + title, + } = invite; + return { + isRevoked: revoked, + date, + expireDate, + link, + isPermanent: permanent, + startDate, + usage, + usageLimit, + isRequestNeeded: requestNeeded, + requested, + title, + }; +} diff --git a/src/api/gramjs/methods/chats.ts b/src/api/gramjs/methods/chats.ts index 3fb4abd69..054e8e227 100644 --- a/src/api/gramjs/methods/chats.ts +++ b/src/api/gramjs/methods/chats.ts @@ -26,6 +26,7 @@ import { buildApiChatFolder, buildApiChatFolderFromSuggested, buildApiChatBotCommands, + buildApiExportedInvite, } from '../apiBuilders/chats'; import { buildApiMessage, buildMessageDraft } from '../apiBuilders/messages'; import { buildApiUser, buildApiUsersAndStatuses } from '../apiBuilders/users'; @@ -1138,6 +1139,84 @@ function updateLocalDb(result: ( } } +export async function fetchExportedChatInvites({ + peer, admin, limit = 0, isRevoked, +}: { peer: ApiChat; admin: ApiUser; limit: number; isRevoked?: boolean }) { + const exportedInvites = await invokeRequest(new GramJs.messages.GetExportedChatInvites({ + peer: buildInputPeer(peer.id, peer.accessHash), + adminId: buildInputEntity(admin.id, admin.accessHash) as GramJs.InputUser, + limit, + revoked: isRevoked || undefined, + })); + + if (!exportedInvites) return undefined; + + return exportedInvites.invites.map(buildApiExportedInvite); +} + +export async function editExportedChatInvite({ + peer, isRevoked, link, expireDate, usageLimit, isRequestNeeded, title, +}: { + peer: ApiChat; + isRevoked?: boolean; + link: string; + expireDate?: number; + usageLimit?: number; + isRequestNeeded?: boolean; + title?: string; +}) { + const invite = await invokeRequest(new GramJs.messages.EditExportedChatInvite({ + link, + peer: buildInputPeer(peer.id, peer.accessHash), + expireDate, + usageLimit: !isRequestNeeded ? usageLimit : undefined, + requestNeeded: isRequestNeeded, + title, + revoked: isRevoked || undefined, + })); + + if (!invite) return undefined; + + if (invite instanceof GramJs.messages.ExportedChatInvite) { + const replaceInvite = buildApiExportedInvite(invite.invite); + return { + oldInvite: replaceInvite, + newInvite: replaceInvite, + }; + } + + if (invite instanceof GramJs.messages.ExportedChatInviteReplaced) { + const oldInvite = buildApiExportedInvite(invite.invite); + const newInvite = buildApiExportedInvite(invite.newInvite); + return { + oldInvite, + newInvite, + }; + } + return undefined; +} + +export async function exportChatInvite({ + peer, expireDate, usageLimit, isRequestNeeded, title, +}: { + peer: ApiChat; + expireDate?: number; + usageLimit?: number; + isRequestNeeded?: boolean; + title?: string; +}) { + const invite = await invokeRequest(new GramJs.messages.ExportChatInvite({ + peer: buildInputPeer(peer.id, peer.accessHash), + expireDate, + usageLimit: !isRequestNeeded ? usageLimit : undefined, + requestNeeded: isRequestNeeded || undefined, + title, + })); + + if (!invite) return undefined; + return buildApiExportedInvite(invite); +} + export async function importChatInvite({ hash }: { hash: string }) { const updates = await invokeRequest(new GramJs.messages.ImportChatInvite({ hash })); if (!(updates instanceof GramJs.Updates) || !updates.chats.length) { diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index b3659fb60..33f0b5567 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -15,6 +15,7 @@ export { getChatByUsername, togglePreHistoryHidden, updateChatDefaultBannedRights, updateChatMemberBannedRights, updateChatTitle, updateChatAbout, toggleSignatures, updateChatAdmin, fetchGroupsForDiscussion, setDiscussionGroup, migrateChat, openChatByInvite, fetchMembers, importChatInvite, addChatMembers, deleteChatMember, toggleIsProtected, + fetchExportedChatInvites, editExportedChatInvite, exportChatInvite, } from './chats'; export { diff --git a/src/api/types/misc.ts b/src/api/types/misc.ts index 7c83a1457..3d0ea81ef 100644 --- a/src/api/types/misc.ts +++ b/src/api/types/misc.ts @@ -97,6 +97,20 @@ export type ApiInviteInfo = { photo?: ApiPhoto; }; +export type ApiExportedInvite = { + isRevoked?: boolean; + isPermanent?: boolean; + link: string; + date: number; + startDate?: number; + expireDate?: number; + usageLimit?: number; + usage?: number; + isRequestNeeded?: boolean; + requested?: number; + title?: string; +}; + export interface ApiCountry { isHidden?: boolean; iso2: string; diff --git a/src/components/common/CalendarModal.tsx b/src/components/common/CalendarModal.tsx index cefda9602..a1da175e3 100644 --- a/src/components/common/CalendarModal.tsx +++ b/src/components/common/CalendarModal.tsx @@ -13,6 +13,8 @@ import Button from '../ui/Button'; import './CalendarModal.scss'; +const MAX_SAFE_DATE = 2147483647 * 1000; // API has int for dates + export type OwnProps = { selectedAt?: number; maxAt?: number; @@ -53,7 +55,7 @@ const CalendarModal: FC = ({ const lang = useLang(); const now = new Date(); const defaultSelectedDate = useMemo(() => (selectedAt ? new Date(selectedAt) : new Date()), [selectedAt]); - const maxDate = maxAt ? new Date(maxAt) : undefined; + const maxDate = new Date(Math.min(maxAt || MAX_SAFE_DATE, MAX_SAFE_DATE)); const prevIsOpen = usePrevious(isOpen); const [isTimeInputFocused, markTimeInputAsFocused, unmarkTimeInputAsFocused] = useFlag(false); @@ -76,8 +78,12 @@ const CalendarModal: FC = ({ if (!prevIsOpen && isOpen) { setSelectedDate(defaultSelectedDate); setCurrentMonthAndYear(new Date(defaultSelectedDate.getFullYear(), defaultSelectedDate.getMonth(), 1)); + if (withTimePicker) { + setSelectedHours(defaultSelectedDate.getHours().toString()); + setSelectedMinutes(defaultSelectedDate.getMinutes().toString()); + } } - }, [defaultSelectedDate, isOpen, prevIsOpen]); + }, [defaultSelectedDate, isOpen, prevIsOpen, withTimePicker]); useEffect(() => { if (isFutureMode && !isTimeInputFocused && selectedDate.getTime() < defaultSelectedDate.getTime()) { @@ -277,7 +283,7 @@ const CalendarModal: FC = ({
{secondButtonLabel && (
@@ -344,6 +376,7 @@ export default memo(withGlobal( const { progress } = global.management; const hasLinkedChannel = Boolean(chat.fullInfo?.linkedChatId); const isBasicGroup = isChatBasicGroup(chat); + const { invites } = global.management.byChatId[chatId] || {}; return { chat, @@ -352,6 +385,9 @@ export default memo(withGlobal( hasLinkedChannel, canChangeInfo: isBasicGroup ? chat.isCreator : getHasAdminRight(chat, 'changeInfo'), canBanUsers: isBasicGroup ? chat.isCreator : getHasAdminRight(chat, 'banUsers'), + canInvite: isBasicGroup ? chat.isCreator : getHasAdminRight(chat, 'inviteUsers'), + exportedInvites: invites, + lastSyncTime: global.lastSyncTime, availableReactionsCount: global.availableReactions?.filter((l) => !l.isInactive).length, }; }, diff --git a/src/components/right/management/ManageInvite.tsx b/src/components/right/management/ManageInvite.tsx new file mode 100644 index 000000000..2ad29ee30 --- /dev/null +++ b/src/components/right/management/ManageInvite.tsx @@ -0,0 +1,268 @@ +import { ChangeEvent } from 'react'; +import React, { + FC, memo, useCallback, useEffect, useState, +} from '../../../lib/teact/teact'; +import { getDispatch, withGlobal } from '../../../lib/teact/teactn'; + +import { ApiExportedInvite } from '../../../api/types'; +import { ManagementScreens } from '../../../types'; + +import useHistoryBack from '../../../hooks/useHistoryBack'; +import useLang from '../../../hooks/useLang'; +import { formatFullDate, formatTime } from '../../../util/dateFormat'; +import useFlag from '../../../hooks/useFlag'; +import { getServerTime } from '../../../util/serverTime'; + +import Checkbox from '../../ui/Checkbox'; +import InputText from '../../ui/InputText'; +import RadioGroup from '../../ui/RadioGroup'; +import CalendarModalAsync from '../../common/CalendarModal.async'; +import Button from '../../ui/Button'; +import FloatingActionButton from '../../ui/FloatingActionButton'; + +const DEFAULT_USAGE_LIMITS = [1, 10, 100]; +const DEFAULT_EXPIRE_DATE = { + hour: 3600000, + day: 86400000, + week: 604800000, +}; +const DEFAULT_CUSTOM_EXPIRE_DATE = DEFAULT_EXPIRE_DATE.hour; + +type OwnProps = { + chatId: string; + onClose: NoneToVoidFunction; + onScreenSelect: (screen: ManagementScreens) => void; + isActive: boolean; +}; + +type StateProps = { + editingInvite?: ApiExportedInvite; + serverTimeOffset: number; +}; + +const ManageInvite: FC = ({ + chatId, + editingInvite, + isActive, + serverTimeOffset, + onClose, + onScreenSelect, +}) => { + const { editExportedChatInvite, exportChatInvite } = getDispatch(); + + const lang = useLang(); + const [isCalendarOpened, openCalendar, closeCalendar] = useFlag(); + const [isRequestNeeded, setIsRequestNeeded] = useState(false); + const [title, setTitle] = useState(''); + const [customExpireDate, setCustomExpireDate] = useState(Date.now() + DEFAULT_CUSTOM_EXPIRE_DATE); + const [selectedExpireOption, setSelectedExpireOption] = useState('unlimited'); + const [customUsageLimit, setCustomUsageLimit] = useState(10); + const [selectedUsageOption, setSelectedUsageOption] = useState('0'); + + useHistoryBack(isActive, onClose); + + useEffect(() => { + if (!editingInvite) { + setTitle(''); + setSelectedExpireOption('unlimited'); + setSelectedUsageOption('0'); + setCustomExpireDate(getServerTime(serverTimeOffset) * 1000 + DEFAULT_CUSTOM_EXPIRE_DATE); + setCustomUsageLimit(10); + setIsRequestNeeded(false); + } else { + const { + title: editingTitle, usageLimit, expireDate, isRequestNeeded: editingIsRequestNeeded, + } = editingInvite; + if (editingTitle) setTitle(editingTitle); + if (usageLimit) { + setSelectedUsageOption(DEFAULT_USAGE_LIMITS.includes(usageLimit) ? usageLimit.toString() : 'custom'); + setCustomUsageLimit(usageLimit); + } + if (expireDate) { + setSelectedExpireOption('custom'); + setCustomExpireDate(expireDate * 1000); + } + if (editingIsRequestNeeded) { + setIsRequestNeeded(true); + } + } + }, [editingInvite, serverTimeOffset]); + + const handleIsRequestChange = useCallback((e: ChangeEvent) => { + setIsRequestNeeded(e.target.checked); + }, []); + + const handleTitleChange = useCallback((e: ChangeEvent) => { + setTitle(e.target.value); + }, []); + + const handleCustomUsageLimitChange = useCallback((e: ChangeEvent) => { + setCustomUsageLimit(Number.parseInt(e.target.value, 10)); + }, []); + + const handleExpireDateChange = useCallback((date: Date) => { + setCustomExpireDate(date.getTime()); + closeCalendar(); + }, [closeCalendar]); + + const handleSaveClick = useCallback(() => { + const usageLimit = selectedUsageOption === 'custom' ? customUsageLimit : selectedUsageOption; + let expireDate; + switch (selectedExpireOption) { + case 'custom': + expireDate = getServerTime(serverTimeOffset) + (customExpireDate - Date.now()) / 1000; + break; + case 'hour': + case 'day': + case 'week': + expireDate = getServerTime(serverTimeOffset) + DEFAULT_EXPIRE_DATE[selectedExpireOption] / 1000; + break; + case 'unlimited': + default: + expireDate = undefined; + } + + if (editingInvite) { + editExportedChatInvite({ + link: editingInvite.link, + chatId, + title, + isRequestNeeded, + expireDate, + usageLimit, + }); + } else { + exportChatInvite({ + chatId, + title, + isRequestNeeded, + expireDate, + usageLimit, + }); + } + onScreenSelect(ManagementScreens.Invites); + }, [ + chatId, customExpireDate, customUsageLimit, editExportedChatInvite, editingInvite, + exportChatInvite, isRequestNeeded, selectedExpireOption, selectedUsageOption, title, onScreenSelect, + serverTimeOffset, + ]); + + return ( +
+
+
+ +
+
+ +

{lang('LinkNameHelp')}

+
+
+
{lang('LimitByPeriod')}
+ + {selectedExpireOption === 'custom' && ( + + )} +

{lang('TimeLimitHelp')}

+
+ {!isRequestNeeded && ( +
+
{lang('LimitNumberOfUses')}
+ ({ value: n.toString(), label: n })), + { + value: '0', + label: lang('NoLimit'), + }, + { + value: 'custom', + label: lang('lng_group_invite_usage_custom'), + }, + ]} + onChange={setSelectedUsageOption} + selected={selectedUsageOption} + /> + {selectedUsageOption === 'custom' && ( + + )} +

{lang('UsesLimitHelp')}

+
+ )} + + + +
+ +
+ ); +}; + +export default memo(withGlobal( + (global, { chatId }): StateProps => { + const { editingInvite } = global.management.byChatId[chatId]; + + return { + editingInvite, + serverTimeOffset: global.serverTimeOffset, + }; + }, +)(ManageInvite)); diff --git a/src/components/right/management/ManageInvites.tsx b/src/components/right/management/ManageInvites.tsx new file mode 100644 index 000000000..deaed2b56 --- /dev/null +++ b/src/components/right/management/ManageInvites.tsx @@ -0,0 +1,262 @@ +import React, { + FC, memo, useCallback, useMemo, +} from '../../../lib/teact/teact'; +import { getDispatch, withGlobal } from '../../../lib/teact/teactn'; + +import { ApiChat, ApiExportedInvite } from '../../../api/types'; +import { ManagementScreens } from '../../../types'; + +import useHistoryBack from '../../../hooks/useHistoryBack'; +import useLang from '../../../hooks/useLang'; +import { formatCountdown, MILLISECONDS_IN_DAY } from '../../../util/dateFormat'; +import useInterval from '../../../hooks/useInterval'; +import useForceUpdate from '../../../hooks/useForceUpdate'; +import { selectChat } from '../../../modules/selectors'; +import { copyTextToClipboard } from '../../../util/clipboard'; +import { IS_SINGLE_COLUMN_LAYOUT } from '../../../util/environment'; +import { getServerTime } from '../../../util/serverTime'; + +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'; + +type OwnProps = { + chatId: string; + onClose: NoneToVoidFunction; + onScreenSelect: (screen: ManagementScreens) => void; + isActive: boolean; +}; + +type StateProps = { + chat?: ApiChat; + exportedInvites?: ApiExportedInvite[]; + serverTimeOffset: number; +}; + +const BULLET = '\u2022'; + +function inviteComparator(i1: ApiExportedInvite, i2: ApiExportedInvite) { + const { isPermanent: i1IsPermanent, usage: i1Usage = 0, date: i1Date } = i1; + const { isPermanent: i2IsPermanent, usage: i2Usage = 0, date: i2Date } = i2; + if (i1IsPermanent || i2IsPermanent) return Number(i1IsPermanent) - Number(i2IsPermanent); + if (i1Usage || i2Usage) return i2Usage - i1Usage; + return i2Date - i1Date; +} + +const ManageInvites: FC = ({ + chatId, + chat, + exportedInvites, + isActive, + serverTimeOffset, + onClose, + onScreenSelect, +}) => { + const { setEditingExportedInvite, showNotification, editExportedChatInvite } = getDispatch(); + useHistoryBack(isActive, onClose); + const lang = useLang(); + + const hasDetailedCountdown = useMemo(() => { + if (!exportedInvites) return undefined; + return exportedInvites + .some(({ expireDate }) => ( + expireDate && (expireDate - getServerTime(serverTimeOffset) < MILLISECONDS_IN_DAY / 1000) + )); + }, [exportedInvites, serverTimeOffset]); + const forceUpdate = useForceUpdate(); + useInterval(() => { + forceUpdate(); + }, hasDetailedCountdown ? 1000 : undefined); + + const primaryInvite = exportedInvites?.find(({ isPermanent }) => isPermanent); + const primaryInviteLink = chat?.username ? `t.me/${chat.username}` : primaryInvite?.link; + const temporalInvites = useMemo(() => { + const invites = chat?.username ? exportedInvites : exportedInvites?.filter(({ isPermanent }) => !isPermanent); + return invites?.filter(({ isRevoked }) => !isRevoked) + .sort(inviteComparator); + }, [chat?.username, exportedInvites]); + + const editInvite = (invite: ApiExportedInvite) => { + setEditingExportedInvite({ chatId, invite }); + onScreenSelect(ManagementScreens.EditInvite); + }; + + const revokeInvite = useCallback((invite: ApiExportedInvite) => { + const { + link, title, isRequestNeeded, expireDate, usageLimit, + } = invite; + editExportedChatInvite({ + chatId, + link, + title, + isRequestNeeded, + expireDate, + usageLimit, + isRevoked: true, + }); + }, [chatId, editExportedChatInvite]); + + const handleCreateNewClick = useCallback(() => { + onScreenSelect(ManagementScreens.EditInvite); + }, [onScreenSelect]); + + const handlePrimaryRevoke = useCallback(() => { + if (primaryInvite) { + revokeInvite(primaryInvite); + } + }, [primaryInvite, revokeInvite]); + + const copyLink = useCallback((link: string) => { + copyTextToClipboard(link); + showNotification({ + message: lang('LinkCopied'), + }); + }, [lang, showNotification]); + + const handleCopyPrimaryClicked = useCallback(() => { + copyLink(primaryInviteLink!); + }, [copyLink, primaryInviteLink]); + + const prepareUsageText = (invite: ApiExportedInvite) => { + const { + usage = 0, usageLimit, expireDate, isPermanent, requested, + } = invite; + let text = ''; + if (usageLimit && usage < usageLimit) { + text = lang('CanJoin', usageLimit - usage); + } else if (usage) { + text = lang('PeopleJoined', usage); + } else { + text = lang('NoOneJoined'); + } + + if (requested) { + text += ` ${BULLET} ${lang('JoinRequests', requested)}`; + } + + if (usageLimit !== undefined && usage === usageLimit) { + text += ` ${BULLET} ${lang('LinkLimitReached')}`; + } else if (expireDate) { + const diff = (expireDate - getServerTime(serverTimeOffset)) * 1000; + text += ` ${BULLET} `; + if (diff > 0) { + text += lang('InviteLink.ExpiresIn', formatCountdown(lang, diff)); + } else { + text += lang('InviteLink.Expired'); + } + } else if (isPermanent) { + text += ` ${BULLET} ${lang('Permanent')}`; + } + + return text; + }; + + const prepareContextActions = (invite: ApiExportedInvite) => { + const actions = []; + actions.push({ + title: lang('Copy'), + icon: 'copy', + handler: () => copyLink(invite.link), + }); + if (!invite.isPermanent) { + actions.push({ + title: lang('Edit'), + icon: lang('edit'), + handler: () => editInvite(invite), + }); + } + actions.push({ + title: lang('RevokeButton'), + icon: lang('delete'), + handler: () => revokeInvite(invite), + destructive: true, + }); + return actions; + }; + + const PrimaryLinkMenuButton: FC<{ onTrigger: () => void; isOpen?: boolean }> = useMemo(() => { + return ({ onTrigger, isOpen }) => ( + + ); + }, []); + + return ( +
+
+ {primaryInviteLink && ( +
+

+ {chat?.username ? lang('PublicLink') : lang('lng_create_permanent_link_title')} +

+
+ + + {lang('Copy')} + {!chat?.username && ( + {lang('RevokeButton')} + )} + +
+ +
+ )} +
+ + {!temporalInvites && } + {temporalInvites?.map((invite) => ( + copyLink(invite.link)} + contextActions={prepareContextActions(invite)} + key={invite.link} + > + {invite.title || invite.link} + + {prepareUsageText(invite)} + + + ))} +

{lang('ManageLinksInfoHelp')}

+
+
+
+ ); +}; + +export default memo(withGlobal( + (global, { chatId }): StateProps => { + const { invites } = global.management.byChatId[chatId]; + const chat = selectChat(global, chatId); + + return { + exportedInvites: invites, + chat, + serverTimeOffset: global.serverTimeOffset, + }; + }, +)(ManageInvites)); diff --git a/src/components/right/management/Management.scss b/src/components/right/management/Management.scss index 5f9e2e68b..ae462198a 100644 --- a/src/components/right/management/Management.scss +++ b/src/components/right/management/Management.scss @@ -47,13 +47,13 @@ height: 100%; &.hidden { - display: none + display: none; } } } .ListItem { - margin: 0 -.75rem; + margin: 0 -0.75rem; .Reaction { display: flex; @@ -71,6 +71,9 @@ .multiline-item .subtitle { line-height: 1.25rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } &:not(.picker-list-item) .Checkbox { @@ -94,10 +97,10 @@ .section-heading { font-weight: 500; - font-size: .9375rem; + font-size: 0.9375rem; color: var(--color-text-secondary); - &[dir=auto] { + &[dir="auto"] { text-align: initial; } } @@ -106,17 +109,17 @@ color: var(--color-text-secondary); line-height: 1.375rem; - &[dir=auto] { + &[dir="auto"] { text-align: initial; } } .section-info { color: var(--color-text-secondary); - font-size: .875rem; + font-size: 0.875rem; } - &[dir=rtl] { + &[dir="rtl"] { text-align: right; } } @@ -137,20 +140,20 @@ margin-bottom: 2rem; &:last-child { - margin-bottom: .625rem; + margin-bottom: 0.625rem; } } .Radio-main { &::before { left: 0.125rem; - top: .25rem; + top: 0.25rem; transform: none; } &::after { left: 0.4375rem; - top: .5625rem; + top: 0.5625rem; transform: none; } } @@ -160,3 +163,54 @@ .ManageGroupMembers { padding: 0.5rem 1rem; } + +.ManageInvites { + .primary-link { + position: relative; + } + + .primary-link-input { + cursor: pointer; + margin-bottom: 1rem; + } + + .primary-link-more-menu { + position: absolute; + right: 0.5rem; + top: 50%; + transform: translate(0, -50%); + z-index: 1; + } + + .create-link { + margin-bottom: 0.5rem; + } +} + +.ManageInvite { + .link-name { + margin-bottom: 1rem; + } + + .expire-limit { + margin-top: 1rem; + } + + .usage-limit { + margin-top: 1rem; + + -moz-appearance: textfield; + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + } + } +} + +.ManageInvite, .ManageInvites { + .hint { + font-size: 0.875rem; + margin-bottom: 0; + margin-top: 0.5rem; + } +} diff --git a/src/components/right/management/Management.tsx b/src/components/right/management/Management.tsx index c59158fce..7174c941c 100644 --- a/src/components/right/management/Management.tsx +++ b/src/components/right/management/Management.tsx @@ -18,6 +18,8 @@ import ManageGroupRecentActions from './ManageGroupRecentActions'; import ManageGroupAdminRights from './ManageGroupAdminRights'; import ManageGroupMembers from './ManageGroupMembers'; import ManageGroupUserPermissionsCreate from './ManageGroupUserPermissionsCreate'; +import ManageInvites from './ManageInvites'; +import ManageInvite from './ManageInvite'; import ManageReactions from './ManageReactions'; export type OwnProps = { @@ -228,7 +230,24 @@ const Management: FC = ({ onClose={onClose} /> ); - + case ManagementScreens.Invites: + return ( + + ); + case ManagementScreens.EditInvite: + return ( + + ); case ManagementScreens.GroupAddAdmins: return ( ; buttonRef?: RefObject; icon?: string; + secondaryIcon?: string; className?: string; style?: string; children: any; @@ -40,6 +42,7 @@ interface OwnProps { contextActions?: MenuItemContextAction[]; onMouseDown?: (e: React.MouseEvent) => void; onClick?: (e: React.MouseEvent) => void; + onSecondaryIconClick?: (e: React.MouseEvent) => void; } const ListItem: FC = (props) => { @@ -47,6 +50,7 @@ const ListItem: FC = (props) => { ref, buttonRef, icon, + secondaryIcon, className, style, children, @@ -61,6 +65,7 @@ const ListItem: FC = (props) => { contextActions, onMouseDown, onClick, + onSecondaryIconClick, } = props; // eslint-disable-next-line no-null/no-null @@ -107,6 +112,17 @@ const ListItem: FC = (props) => { } }, [disabled, markIsTouched, onClick, ripple, unmarkIsTouched]); + const handleSecondaryIconClick = (e: React.MouseEvent) => { + if (disabled || e.button !== 0 || (!onSecondaryIconClick && !contextActions)) return; + + e.stopPropagation(); + if (onSecondaryIconClick) { + onSecondaryIconClick(e); + } else { + handleContextMenu(e); + } + }; + const handleMouseDown = useCallback((e: React.MouseEvent) => { if (inactive || IS_TOUCH_ENV) { return; @@ -154,9 +170,9 @@ const ListItem: FC = (props) => { role="button" ref={buttonRef} tabIndex={0} - onClick={!inactive && IS_TOUCH_ENV ? handleClick : undefined} + onClick={(!inactive && IS_TOUCH_ENV) ? handleClick : undefined} onMouseDown={handleMouseDown} - onContextMenu={!inactive && contextActions ? handleContextMenu : undefined} + onContextMenu={(!inactive && contextActions) ? handleContextMenu : undefined} > {icon && ( @@ -166,6 +182,17 @@ const ListItem: FC = (props) => { {!disabled && !inactive && ripple && ( )} + {secondaryIcon && ( + + )}
{contextActions && contextMenuPosition !== undefined && ( ; + byChatId: Record; }; mediaViewer: { @@ -557,6 +554,7 @@ export type ActionTypes = ( 'searchTextMessagesLocal' | 'searchMediaMessagesLocal' | 'searchMessagesByDate' | // management 'toggleManagement' | 'closeManagement' | 'checkPublicLink' | 'updatePublicLink' | 'updatePrivateLink' | + 'setEditingExportedInvite' | 'loadExportedChatInvites' | 'editExportedChatInvite' | 'exportChatInvite' | // groups 'togglePreHistoryHidden' | 'updateChatDefaultBannedRights' | 'updateChatMemberBannedRights' | 'updateChatAdmin' | 'acceptInviteConfirmation' | diff --git a/src/lib/gramjs/tl/apiTl.js b/src/lib/gramjs/tl/apiTl.js index 3035db2af..43a9c5dce 100644 --- a/src/lib/gramjs/tl/apiTl.js +++ b/src/lib/gramjs/tl/apiTl.js @@ -1082,6 +1082,9 @@ messages.getDiscussionMessage#446972fd peer:InputPeer msg_id:int = messages.Disc messages.readDiscussion#f731a9f4 peer:InputPeer msg_id:int read_max_id:int = Bool; messages.unpinAllMessages#f025bc8b peer:InputPeer = messages.AffectedHistory; messages.deleteChat#5bd0ee50 chat_id:long = Bool; +messages.getExportedChatInvites#a2b5a3f6 flags:# revoked:flags.3?true peer:InputPeer admin_id:InputUser offset_date:flags.2?int offset_link:flags.2?string limit:int = messages.ExportedChatInvites; +messages.editExportedChatInvite#bdca2f75 flags:# revoked:flags.2?true peer:InputPeer link:string expire_date:flags.0?int usage_limit:flags.1?int request_needed:flags.3?Bool title:flags.4?string = messages.ExportedChatInvite; +messages.deleteExportedChatInvite#d464a42b peer:InputPeer link:string = Bool; messages.getMessageReadParticipants#2c6f97b7 peer:InputPeer msg_id:int = Vector; messages.toggleNoForwards#b11eafa2 peer:InputPeer enabled:Bool = Updates; messages.saveDefaultSendAs#ccfddf96 peer:InputPeer send_as:InputPeer = Bool; diff --git a/src/lib/gramjs/tl/generateModules.js b/src/lib/gramjs/tl/generateModules.js index 6b48bf36d..f2dfa6728 100644 --- a/src/lib/gramjs/tl/generateModules.js +++ b/src/lib/gramjs/tl/generateModules.js @@ -56,9 +56,10 @@ function main() { } function stripTl(tl) { - return tl.replace(/\/\*[\s\S]*?\*\/|\/\/.*/g, '') - .replace(/\n\s*\n/g, '\n') - .replace(/`/g, '\\`'); + return tl.replace(/\/\*[\s\S]*?\*\/|\/\/.*/g, '') // Remove comments + .replace(/\n\s*\n/g, '\n') // Trim & add newline + .replace(/`/g, '\\`') // Escape backticks + .replace(/\r/g, ''); // Remove carriage return } main(); diff --git a/src/lib/gramjs/tl/static/api.json b/src/lib/gramjs/tl/static/api.json index 692a458fc..7df9ca9eb 100644 --- a/src/lib/gramjs/tl/static/api.json +++ b/src/lib/gramjs/tl/static/api.json @@ -75,6 +75,9 @@ "messages.addChatUser", "messages.deleteChatUser", "messages.createChat", + "messages.getExportedChatInvites", + "messages.editExportedChatInvite", + "messages.deleteExportedChatInvite", "messages.getDhConfig", "messages.readMessageContents", "messages.getStickers", diff --git a/src/modules/actions/api/chats.ts b/src/modules/actions/api/chats.ts index 6910bb098..29ef44171 100644 --- a/src/modules/actions/api/chats.ts +++ b/src/modules/actions/api/chats.ts @@ -30,6 +30,7 @@ import { updateChatListSecondaryInfo, updateManagementProgress, leaveChat, + updateManagement, } from '../../reducers'; import { selectChat, @@ -610,6 +611,82 @@ addReducer('acceptInviteConfirmation', (global, actions, payload) => { })(); }); +addReducer('loadExportedChatInvites', (global, actions, payload) => { + const { + chatId, adminId, isRevoked, limit, + } = payload!; + const peer = selectChat(global, chatId); + const admin = selectUser(global, adminId || global.currentUserId); + if (!peer || !admin) return; + + (async () => { + const result = await callApi('fetchExportedChatInvites', { + peer, admin, isRevoked, limit, + }); + if (!result) { + return; + } + + setGlobal(updateManagement(getGlobal(), chatId, { invites: result })); + })(); +}); + +addReducer('editExportedChatInvite', (global, actions, payload) => { + const { + chatId, link, isRevoked, expireDate, usageLimit, isRequestNeeded, title, + } = payload!; + const peer = selectChat(global, chatId); + if (!peer) return; + + (async () => { + const result = await callApi('editExportedChatInvite', { + peer, + link, + isRevoked, + expireDate, + usageLimit, + isRequestNeeded, + title, + }); + if (!result) { + return; + } + global = getGlobal(); + let invites = global.management.byChatId[chatId].invites || []; + const { oldInvite, newInvite } = result; + invites = invites.filter((current) => current.link !== oldInvite.link); + setGlobal(updateManagement(global, chatId, { + invites: [...invites, newInvite], + })); + })(); +}); + +addReducer('exportChatInvite', (global, actions, payload) => { + const { + chatId, expireDate, usageLimit, isRequestNeeded, title, + } = payload!; + const peer = selectChat(global, chatId); + if (!peer) return; + + (async () => { + const result = await callApi('exportChatInvite', { + peer, + expireDate, + usageLimit, + isRequestNeeded, + title, + }); + if (!result) { + return; + } + global = getGlobal(); + const invites = global.management.byChatId[chatId].invites || []; + setGlobal(updateManagement(global, chatId, { + invites: [...invites, result], + })); + })(); +}); + addReducer('openChatByUsername', (global, actions, payload) => { const { username, messageId, commentId, startParam, diff --git a/src/modules/actions/api/management.ts b/src/modules/actions/api/management.ts index 93bd48a9e..96c168682 100644 --- a/src/modules/actions/api/management.ts +++ b/src/modules/actions/api/management.ts @@ -76,3 +76,9 @@ addReducer('updatePrivateLink', (global) => { callApi('updatePrivateLink', { chat }); }); + +addReducer('setEditingExportedInvite', (global, actions, payload) => { + const { chatId, invite } = payload; + + setGlobal(updateManagement(global, chatId, { editingInvite: invite })); +}); diff --git a/src/modules/helpers/chats.ts b/src/modules/helpers/chats.ts index 639478a1a..54af16502 100644 --- a/src/modules/helpers/chats.ts +++ b/src/modules/helpers/chats.ts @@ -203,7 +203,7 @@ export function getMessageSendingRestrictionReason( 'Channel.Persmission.Denied.SendMessages.Until', lang( 'formatDateAtTime', - [formatDateToString(new Date(untilDate * 1000), lang.code), formatTime(untilDate * 1000, lang)], + [formatDateToString(new Date(untilDate * 1000), lang.code), formatTime(lang, untilDate * 1000)], ), ) : lang('Channel.Persmission.Denied.SendMessages.Forever'); diff --git a/src/modules/helpers/users.ts b/src/modules/helpers/users.ts index 5d1eb553a..2b9a30cc4 100644 --- a/src/modules/helpers/users.ts +++ b/src/modules/helpers/users.ts @@ -131,7 +131,7 @@ export function getUserStatus( } // other - return lang('LastSeen.TodayAt', formatTime(wasOnlineDate, lang)); + return lang('LastSeen.TodayAt', formatTime(lang, wasOnlineDate)); } // yesterday @@ -140,7 +140,7 @@ export function getUserStatus( yesterday.setHours(0, 0, 0, 0); const serverYesterday = new Date(yesterday.getTime() + serverTimeOffset * 1000); if (wasOnlineDate > serverYesterday) { - return lang('LastSeen.YesterdayAt', formatTime(wasOnlineDate, lang)); + return lang('LastSeen.YesterdayAt', formatTime(lang, wasOnlineDate)); } return lang('LastSeen.AtDate', formatFullDate(lang, wasOnlineDate)); diff --git a/src/modules/reducers/management.ts b/src/modules/reducers/management.ts index 33e6ade9e..c68cce33a 100644 --- a/src/modules/reducers/management.ts +++ b/src/modules/reducers/management.ts @@ -1,11 +1,5 @@ import { GlobalState } from '../../global/types'; -import { ManagementProgress } from '../../types'; - -interface ManagementState { - isActive: boolean; - isUsernameAvailable?: boolean; - error?: string; -} +import { ManagementProgress, ManagementState } from '../../types'; export function updateManagementProgress(global: GlobalState, progress: ManagementProgress): GlobalState { return { diff --git a/src/types/index.ts b/src/types/index.ts index 0663b5fb6..0abc1b9ba 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,5 +1,6 @@ import { ApiBotInlineMediaResult, ApiBotInlineResult, ApiBotInlineSwitchPm, + ApiExportedInvite, ApiLanguage, ApiMessage, ApiShippingAddress, ApiStickerSet, } from '../api/types'; @@ -279,6 +280,14 @@ export enum ManagementProgress { Error, } +export interface ManagementState { + isActive: boolean; + isUsernameAvailable?: boolean; + error?: string; + invites?: ApiExportedInvite[]; + editingInvite?: ApiExportedInvite; +} + export enum NewChatMembersProgress { Closed, InProgress, @@ -321,6 +330,8 @@ export enum ManagementScreens { ChatNewAdminRights, GroupMembers, GroupAddAdmins, + Invites, + EditInvite, Reactions, } diff --git a/src/util/dateFormat.ts b/src/util/dateFormat.ts index 0d3866e12..8f8afa0df 100644 --- a/src/util/dateFormat.ts +++ b/src/util/dateFormat.ts @@ -32,7 +32,7 @@ function toIsoString(date: Date) { } // @optimization `toLocaleTimeString` is avoided because of bad performance -export function formatTime(datetime: number | Date, lang: LangFn) { +export function formatTime(lang: LangFn, datetime: number | Date) { const date = typeof datetime === 'number' ? new Date(datetime) : datetime; const timeFormat = lang.timeFormat || '24h'; @@ -51,7 +51,7 @@ export function formatPastTimeShort(lang: LangFn, datetime: number | Date) { const today = getDayStart(new Date()); if (date >= today) { - return formatTime(date, lang); + return formatTime(lang, date); } const weekAgo = new Date(today); @@ -82,6 +82,26 @@ export function formatMonthAndYear(lang: LangFn, date: Date, isShort = false) { return formatDate(lang, date, format); } +export function formatCountdown( + lang: LangFn, + msLeft: number, +) { + const days = Math.floor(msLeft / MILLISECONDS_IN_DAY); + if (msLeft < 0) { + return 0; + } else if (days < 1) { + return formatMediaDuration(msLeft / 1000); + } else if (days < 7) { + return lang('Days', days); + } else if (days < 30) { + return lang('Weeks', Math.floor(days / 7)); + } else if (days < 365) { + return lang('Months', Math.floor(days / 30)); + } else { + return lang('Years', Math.floor(days / 365)); + } +} + export function formatHumanDate( lang: LangFn, datetime: number | Date, @@ -148,7 +168,7 @@ export function formatMediaDateTime( ) { const date = typeof datetime === 'number' ? new Date(datetime) : datetime; - return `${formatHumanDate(lang, date, true, undefined, isUpperFirst)}, ${formatTime(date, lang)}`; + return `${formatHumanDate(lang, date, true, undefined, isUpperFirst)}, ${formatTime(lang, date)}`; } export function formatMediaDuration(duration: number, maxValue?: number) {