Management: Introduce Exported Invites (#1645)

This commit is contained in:
Alexander Zinchuk 2022-01-21 17:29:35 +01:00
parent 8f1b32cdb4
commit bfc958484c
30 changed files with 1022 additions and 46 deletions

View File

@ -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

View File

@ -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,
};
}

View File

@ -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) {

View File

@ -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 {

View File

@ -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;

View File

@ -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<OwnProps> = ({
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<OwnProps> = ({
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<OwnProps> = ({
<div className="footer">
<Button onClick={handleSubmit}>
{withTimePicker ? formatSubmitLabel(lang, selectedDate) : submitButtonLabel}
{submitButtonLabel || formatSubmitLabel(lang, selectedDate)}
</Button>
{secondButtonLabel && (
<Button onClick={onSecondButtonClick} isText>
@ -348,10 +354,10 @@ function formatSubmitLabel(lang: LangFn, date: Date) {
const today = formatDateToString(new Date(), lang.code);
if (day === today) {
return lang('Conversation.ScheduleMessage.SendToday', formatTime(date, lang));
return lang('Conversation.ScheduleMessage.SendToday', formatTime(lang, date));
}
return lang('Conversation.ScheduleMessage.SendOn', [day, formatTime(date, lang)]);
return lang('Conversation.ScheduleMessage.SendOn', [day, formatTime(lang, date)]);
}
export default memo(CalendarModal);

View File

@ -117,7 +117,13 @@ const ChatExtra: FC<OwnProps & StateProps> = ({
</ListItem>
)}
{(canInviteUsers || !username) && link && (
<ListItem icon="mention" multiline narrow ripple onClick={() => copy(link, lang('SetUrlPlaceholder'))}>
<ListItem
icon={chat.username ? 'mention' : 'link'}
multiline
narrow
ripple
onClick={() => copy(link, lang('SetUrlPlaceholder'))}
>
<div className="title">{link}</div>
<span className="subtitle">{lang('SetUrlPlaceholder')}</span>
</ListItem>

View File

@ -84,7 +84,7 @@ const MessageMeta: FC<OwnProps> = ({
)}
<span className="message-time" title={title} onMouseEnter={markActivated}>
{message.isEdited && `${lang('EditedMessage')} `}
{formatTime(message.date * 1000, lang)}
{formatTime(lang, message.date * 1000)}
</span>
{outgoingStatus && (
<MessageOutgoingStatus status={outgoingStatus} />

View File

@ -69,6 +69,7 @@ const RightColumn: FC<StateProps> = ({
closePollResults,
addChatMembers,
setNewChatMembersDialogState,
setEditingExportedInvite,
} = getDispatch();
const { width: windowWidth } = useWindowSize();
@ -123,6 +124,7 @@ const RightColumn: FC<StateProps> = ({
case ManagementScreens.ChatAdministrators:
case ManagementScreens.ChannelSubscribers:
case ManagementScreens.GroupMembers:
case ManagementScreens.Invites:
case ManagementScreens.Reactions:
setManagementScreen(ManagementScreens.Initial);
break;
@ -139,6 +141,10 @@ const RightColumn: FC<StateProps> = ({
case ManagementScreens.GroupRecentActions:
setManagementScreen(ManagementScreens.ChatAdministrators);
break;
case ManagementScreens.EditInvite:
setManagementScreen(ManagementScreens.Invites);
setEditingExportedInvite({ chatId, invite: undefined });
break;
}
break;
@ -164,6 +170,7 @@ const RightColumn: FC<StateProps> = ({
}, [
contentKey, isScrolledDown, toggleChatInfo, openUserInfo, closePollResults, setNewChatMembersDialogState,
managementScreen, toggleManagement, closeLocalTextSearch, setStickerSearchQuery, setGifSearchQuery,
setEditingExportedInvite, chatId,
]);
const handleSelectChatMember = useCallback((memberId, isPromoted) => {

View File

@ -52,6 +52,7 @@ type StateProps = {
messageSearchQuery?: string;
stickerSearchQuery?: string;
gifSearchQuery?: string;
isEditingInvite?: boolean;
};
const COLUMN_CLOSE_DELAY_MS = 300;
@ -81,6 +82,8 @@ enum HeaderContent {
GifSearch,
PollResults,
AddingMembers,
ManageInvites,
ManageEditInvite,
ManageReactions,
}
@ -104,6 +107,7 @@ const RightHeader: FC<OwnProps & StateProps> = ({
stickerSearchQuery,
gifSearchQuery,
shouldSkipAnimation,
isEditingInvite,
}) => {
const {
setLocalTextSearchQuery,
@ -191,6 +195,10 @@ const RightHeader: FC<OwnProps & StateProps> = ({
HeaderContent.ManageGroupNewAdminRights
) : managementScreen === ManagementScreens.GroupMembers ? (
HeaderContent.ManageGroupMembers
) : managementScreen === ManagementScreens.Invites ? (
HeaderContent.ManageInvites
) : managementScreen === ManagementScreens.EditInvite ? (
HeaderContent.ManageEditInvite
) : managementScreen === ManagementScreens.GroupAddAdmins ? (
HeaderContent.ManageGroupAddAdmins
) : managementScreen === ManagementScreens.Reactions ? (
@ -251,6 +259,10 @@ const RightHeader: FC<OwnProps & StateProps> = ({
return <h3>{lang('ChannelAddException')}</h3>;
case HeaderContent.ManageGroupUserPermissions:
return <h3>{lang('UserRestrictions')}</h3>;
case HeaderContent.ManageInvites:
return <h3>{lang('lng_group_invite_title')}</h3>;
case HeaderContent.ManageEditInvite:
return <h3>{isEditingInvite ? lang('EditLink') : lang('NewLink')}</h3>;
case HeaderContent.ManageGroupAddAdmins:
return <h3>{lang('Channel.Management.AddModerator')}</h3>;
case HeaderContent.StickerSearch:
@ -368,6 +380,7 @@ export default memo(withGlobal<OwnProps>(
// chat.isCreator is for Basic Groups
&& (isUserId(chat.id) || ((isChatAdmin(chat) || chat.isCreator) && !chat.isNotJoined)),
);
const isEditingInvite = Boolean(chatId && global.management.byChatId[chatId]?.editingInvite);
return {
canManage,
@ -377,6 +390,7 @@ export default memo(withGlobal<OwnProps>(
messageSearchQuery,
stickerSearchQuery,
gifSearchQuery,
isEditingInvite,
};
},
)(RightHeader));

View File

@ -5,12 +5,15 @@ import React, {
import { getDispatch, withGlobal } from '../../../lib/teact/teactn';
import { ManagementScreens, ManagementProgress } from '../../../types';
import { ApiChat, ApiMediaFormat } from '../../../api/types';
import { ApiChat, ApiExportedInvite, ApiMediaFormat } from '../../../api/types';
import { getChatAvatarHash, getHasAdminRight } from '../../../modules/helpers';
import useMedia from '../../../hooks/useMedia';
import useLang from '../../../hooks/useLang';
import { selectChat } from '../../../modules/selectors';
import useFlag from '../../../hooks/useFlag';
import useHistoryBack from '../../../hooks/useHistoryBack';
import { formatInteger } from '../../../util/textFormat';
import AvatarEditable from '../../ui/AvatarEditable';
import InputText from '../../ui/InputText';
@ -19,8 +22,6 @@ import Checkbox from '../../ui/Checkbox';
import Spinner from '../../ui/Spinner';
import FloatingActionButton from '../../ui/FloatingActionButton';
import ConfirmDialog from '../../ui/ConfirmDialog';
import useFlag from '../../../hooks/useFlag';
import useHistoryBack from '../../../hooks/useHistoryBack';
import './Management.scss';
@ -36,6 +37,9 @@ type StateProps = {
progress?: ManagementProgress;
isSignaturesShown: boolean;
canChangeInfo?: boolean;
canInvite?: boolean;
exportedInvites?: ApiExportedInvite[];
lastSyncTime?: number;
availableReactionsCount?: number;
};
@ -47,6 +51,9 @@ const ManageChannel: FC<OwnProps & StateProps> = ({
progress,
isSignaturesShown,
canChangeInfo,
canInvite,
exportedInvites,
lastSyncTime,
availableReactionsCount,
onScreenSelect,
onClose,
@ -59,6 +66,7 @@ const ManageChannel: FC<OwnProps & StateProps> = ({
leaveChannel,
deleteChannel,
openChat,
loadExportedChatInvites,
} = getDispatch();
const currentTitle = chat ? (chat.title || '') : '';
@ -77,6 +85,12 @@ const ManageChannel: FC<OwnProps & StateProps> = ({
useHistoryBack(isActive, onClose);
useEffect(() => {
if (lastSyncTime) {
loadExportedChatInvites({ chatId });
}
}, [chatId, loadExportedChatInvites, lastSyncTime]);
useEffect(() => {
if (progress === ManagementProgress.Complete) {
setIsProfileFieldsTouched(false);
@ -102,6 +116,10 @@ const ManageChannel: FC<OwnProps & StateProps> = ({
onScreenSelect(ManagementScreens.ChatAdministrators);
}, [onScreenSelect]);
const handleClickInvites = useCallback(() => {
onScreenSelect(ManagementScreens.Invites);
}, [onScreenSelect]);
const handleSetPhoto = useCallback((file: File) => {
setPhoto(file);
setIsProfileFieldsTouched(true);
@ -210,6 +228,19 @@ const ManageChannel: FC<OwnProps & StateProps> = ({
<span className="title">{lang('ChannelAdministrators')}</span>
<span className="subtitle">{adminsCount}</span>
</ListItem>
{canInvite && (
<ListItem
icon="link"
onClick={handleClickInvites}
multiline
disabled={!exportedInvites}
>
<span className="title">{lang('GroupInfo.InviteLinks')}</span>
<span className="subtitle">
{exportedInvites ? formatInteger(exportedInvites.length) : lang('Loading')}
</span>
</ListItem>
)}
<ListItem
icon="reactions"
multiline
@ -274,12 +305,16 @@ export default memo(withGlobal<OwnProps>(
const chat = selectChat(global, chatId)!;
const { progress } = global.management;
const isSignaturesShown = Boolean(chat?.isSignaturesShown);
const { invites } = global.management.byChatId[chatId] || {};
return {
chat,
progress,
isSignaturesShown,
canChangeInfo: getHasAdminRight(chat, 'changeInfo'),
canInvite: getHasAdminRight(chat, 'inviteUsers'),
lastSyncTime: global.lastSyncTime,
exportedInvites: invites,
availableReactionsCount: global.availableReactions?.filter((l) => !l.isInactive).length,
};
},

View File

@ -5,7 +5,9 @@ import React, {
import { getDispatch, withGlobal } from '../../../lib/teact/teactn';
import { ManagementScreens, ManagementProgress } from '../../../types';
import { ApiChat, ApiChatBannedRights, ApiMediaFormat } from '../../../api/types';
import {
ApiChat, ApiChatBannedRights, ApiExportedInvite, ApiMediaFormat,
} from '../../../api/types';
import { getChatAvatarHash, getHasAdminRight, isChatBasicGroup } from '../../../modules/helpers';
import useMedia from '../../../hooks/useMedia';
@ -40,6 +42,9 @@ type StateProps = {
hasLinkedChannel: boolean;
canChangeInfo?: boolean;
canBanUsers?: boolean;
canInvite?: boolean;
exportedInvites?: ApiExportedInvite[];
lastSyncTime?: number;
availableReactionsCount?: number;
};
@ -57,9 +62,12 @@ const ManageGroup: FC<OwnProps & StateProps> = ({
hasLinkedChannel,
canChangeInfo,
canBanUsers,
canInvite,
onScreenSelect,
onClose,
isActive,
exportedInvites,
lastSyncTime,
availableReactionsCount,
}) => {
const {
@ -70,6 +78,7 @@ const ManageGroup: FC<OwnProps & StateProps> = ({
deleteChannel,
closeManagement,
openChat,
loadExportedChatInvites,
} = getDispatch();
const [isDeleteDialogOpen, openDeleteDialog, closeDeleteDialog] = useFlag();
@ -87,6 +96,12 @@ const ManageGroup: FC<OwnProps & StateProps> = ({
useHistoryBack(isActive, onClose);
useEffect(() => {
if (lastSyncTime && canInvite) {
loadExportedChatInvites({ chatId });
}
}, [chatId, loadExportedChatInvites, lastSyncTime, canInvite]);
useEffect(() => {
if (progress === ManagementProgress.Complete) {
setIsProfileFieldsTouched(false);
@ -114,6 +129,10 @@ const ManageGroup: FC<OwnProps & StateProps> = ({
onScreenSelect(ManagementScreens.ChatAdministrators);
}, [onScreenSelect]);
const handleClickInvites = useCallback(() => {
onScreenSelect(ManagementScreens.Invites);
}, [onScreenSelect]);
const handleSetPhoto = useCallback((file: File) => {
setPhoto(file);
setIsProfileFieldsTouched(true);
@ -285,6 +304,19 @@ const ManageGroup: FC<OwnProps & StateProps> = ({
<span className="title">{lang('ChannelAdministrators')}</span>
<span className="subtitle">{formatInteger(adminsCount)}</span>
</ListItem>
{canInvite && (
<ListItem
icon="link"
onClick={handleClickInvites}
multiline
disabled={!exportedInvites}
>
<span className="title">{lang('GroupInfo.InviteLinks')}</span>
<span className="subtitle">
{exportedInvites ? formatInteger(exportedInvites.length) : lang('Loading')}
</span>
</ListItem>
)}
</div>
<div className="section">
<ListItem icon="group" multiline onClick={handleClickMembers}>
@ -344,6 +376,7 @@ export default memo(withGlobal<OwnProps>(
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<OwnProps>(
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,
};
},

View File

@ -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<OwnProps & StateProps> = ({
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<number>(Date.now() + DEFAULT_CUSTOM_EXPIRE_DATE);
const [selectedExpireOption, setSelectedExpireOption] = useState('unlimited');
const [customUsageLimit, setCustomUsageLimit] = useState<number | undefined>(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<HTMLInputElement>) => {
setIsRequestNeeded(e.target.checked);
}, []);
const handleTitleChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setTitle(e.target.value);
}, []);
const handleCustomUsageLimitChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
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 (
<div className="Management ManageInvite">
<div className="custom-scroll">
<div className="section">
<Checkbox
label={lang('ApproveNewMembers')}
subLabel={lang('ApproveNewMembersDescription')}
checked={isRequestNeeded}
onChange={handleIsRequestChange}
/>
</div>
<div className="section">
<InputText
className="link-name"
placeholder={lang('LinkNameHint')}
value={title}
onChange={handleTitleChange}
/>
<p className="text-muted hint">{lang('LinkNameHelp')}</p>
</div>
<div className="section">
<div className="section-header">{lang('LimitByPeriod')}</div>
<RadioGroup
name="expireOptions"
options={[
{
value: 'hour',
label: lang('Hours', 1),
},
{
value: 'day',
label: lang('Days', 1),
},
{
value: 'week',
label: lang('Weeks', 1),
},
{
value: 'unlimited',
label: lang('NoLimit'),
},
{
value: 'custom',
label: lang('lng_group_invite_expire_custom'),
},
]}
onChange={setSelectedExpireOption}
selected={selectedExpireOption}
/>
{selectedExpireOption === 'custom' && (
<Button className="expire-limit" isText onClick={openCalendar}>
{formatFullDate(lang, customExpireDate)} {formatTime(lang, customExpireDate)}
</Button>
)}
<p className="text-muted hint">{lang('TimeLimitHelp')}</p>
</div>
{!isRequestNeeded && (
<div className="section">
<div className="section-header">{lang('LimitNumberOfUses')}</div>
<RadioGroup
name="usageOptions"
options={[
...DEFAULT_USAGE_LIMITS.map((n) => ({ 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' && (
<input
className="form-control usage-limit"
type="number"
min="1"
max="99999"
value={customUsageLimit}
onChange={handleCustomUsageLimitChange}
/>
)}
<p className="text-muted hint">{lang('UsesLimitHelp')}</p>
</div>
)}
<FloatingActionButton
isShown
onClick={handleSaveClick}
ariaLabel={editingInvite ? lang('SaveLink') : lang('CreateLink')}
>
<i className="icon-check" />
</FloatingActionButton>
</div>
<CalendarModalAsync
isOpen={isCalendarOpened}
isFutureMode
withTimePicker
onClose={closeCalendar}
onSubmit={handleExpireDateChange}
selectedAt={customExpireDate}
submitButtonLabel={lang('Save')}
/>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global, { chatId }): StateProps => {
const { editingInvite } = global.management.byChatId[chatId];
return {
editingInvite,
serverTimeOffset: global.serverTimeOffset,
};
},
)(ManageInvite));

View File

@ -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<OwnProps & StateProps> = ({
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 }) => (
<Button
round
ripple={!IS_SINGLE_COLUMN_LAYOUT}
size="smaller"
color="translucent"
className={isOpen ? 'active' : ''}
onClick={onTrigger}
ariaLabel="Actions"
>
<i className="icon-more" />
</Button>
);
}, []);
return (
<div className="Management ManageInvites">
<div className="custom-scroll">
{primaryInviteLink && (
<div className="section">
<p className="text-muted">
{chat?.username ? lang('PublicLink') : lang('lng_create_permanent_link_title')}
</p>
<div className="primary-link">
<input
className="form-control primary-link-input"
value={primaryInviteLink}
readOnly
onClick={handleCopyPrimaryClicked}
/>
<DropdownMenu
className="primary-link-more-menu"
trigger={PrimaryLinkMenuButton}
positionX="right"
>
<MenuItem icon="copy" onClick={handleCopyPrimaryClicked}>{lang('Copy')}</MenuItem>
{!chat?.username && (
<MenuItem icon="delete" onClick={handlePrimaryRevoke} destructive>{lang('RevokeButton')}</MenuItem>
)}
</DropdownMenu>
</div>
<Button onClick={handleCopyPrimaryClicked}>{lang('CopyLink')}</Button>
</div>
)}
<div className="section" teactFastList>
<Button isText key="create" className="create-link" onClick={handleCreateNewClick}>
{lang('CreateNewLink')}
</Button>
{!temporalInvites && <NothingFound text="No links found" key="nothing" />}
{temporalInvites?.map((invite) => (
<ListItem
icon="link"
secondaryIcon="more"
multiline
onClick={() => copyLink(invite.link)}
contextActions={prepareContextActions(invite)}
key={invite.link}
>
<span className="title">{invite.title || invite.link}</span>
<span className="subtitle" dir="auto">
{prepareUsageText(invite)}
</span>
</ListItem>
))}
<p className="text-muted hint" key="links-hint">{lang('ManageLinksInfoHelp')}</p>
</div>
</div>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global, { chatId }): StateProps => {
const { invites } = global.management.byChatId[chatId];
const chat = selectChat(global, chatId);
return {
exportedInvites: invites,
chat,
serverTimeOffset: global.serverTimeOffset,
};
},
)(ManageInvites));

View File

@ -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;
}
}

View File

@ -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<OwnProps & StateProps> = ({
onClose={onClose}
/>
);
case ManagementScreens.Invites:
return (
<ManageInvites
chatId={chatId}
isActive={isActive}
onClose={onClose}
onScreenSelect={onScreenSelect}
/>
);
case ManagementScreens.EditInvite:
return (
<ManageInvite
chatId={chatId}
isActive={isActive}
onClose={onClose}
onScreenSelect={onScreenSelect}
/>
);
case ManagementScreens.GroupAddAdmins:
return (
<ManageGroupMembers

View File

@ -1,4 +1,6 @@
import { ChangeEvent, FormEvent, RefObject } from 'react';
import {
ChangeEvent, FormEvent, RefObject,
} from 'react';
import React, { FC, memo } from '../../lib/teact/teact';
import buildClassName from '../../util/buildClassName';

View File

@ -349,6 +349,7 @@
}
.multiline-item {
flex-grow: 1;
white-space: initial;
overflow: hidden;
@ -361,6 +362,7 @@
line-height: 1.25rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.subtitle {

View File

@ -12,6 +12,7 @@ import useLang from '../../hooks/useLang';
import RippleEffect from './RippleEffect';
import Menu from './Menu';
import MenuItem from './MenuItem';
import Button from './Button';
import './ListItem.scss';
@ -26,6 +27,7 @@ interface OwnProps {
ref?: RefObject<HTMLDivElement>;
buttonRef?: RefObject<HTMLDivElement>;
icon?: string;
secondaryIcon?: string;
className?: string;
style?: string;
children: any;
@ -40,6 +42,7 @@ interface OwnProps {
contextActions?: MenuItemContextAction[];
onMouseDown?: (e: React.MouseEvent<HTMLDivElement>) => void;
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
onSecondaryIconClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
}
const ListItem: FC<OwnProps> = (props) => {
@ -47,6 +50,7 @@ const ListItem: FC<OwnProps> = (props) => {
ref,
buttonRef,
icon,
secondaryIcon,
className,
style,
children,
@ -61,6 +65,7 @@ const ListItem: FC<OwnProps> = (props) => {
contextActions,
onMouseDown,
onClick,
onSecondaryIconClick,
} = props;
// eslint-disable-next-line no-null/no-null
@ -107,6 +112,17 @@ const ListItem: FC<OwnProps> = (props) => {
}
}, [disabled, markIsTouched, onClick, ripple, unmarkIsTouched]);
const handleSecondaryIconClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
if (disabled || e.button !== 0 || (!onSecondaryIconClick && !contextActions)) return;
e.stopPropagation();
if (onSecondaryIconClick) {
onSecondaryIconClick(e);
} else {
handleContextMenu(e);
}
};
const handleMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (inactive || IS_TOUCH_ENV) {
return;
@ -154,9 +170,9 @@ const ListItem: FC<OwnProps> = (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 && (
<i className={`icon-${icon}`} />
@ -166,6 +182,17 @@ const ListItem: FC<OwnProps> = (props) => {
{!disabled && !inactive && ripple && (
<RippleEffect />
)}
{secondaryIcon && (
<Button
className="secondary-icon"
round
color="translucent"
size="smaller"
onMouseDown={handleSecondaryIconClick}
>
<i className={`icon-${secondaryIcon}`} />
</Button>
)}
</div>
{contextActions && contextMenuPosition !== undefined && (
<Menu

View File

@ -50,6 +50,7 @@ import {
InlineBotSettings,
NewChatMembersProgress,
AudioOrigin,
ManagementState,
} from '../types';
export type MessageListType =
@ -360,11 +361,7 @@ export type GlobalState = {
management: {
progress?: ManagementProgress;
byChatId: Record<string, {
isActive: boolean;
isUsernameAvailable?: boolean;
error?: string;
}>;
byChatId: Record<string, ManagementState>;
};
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' |

View File

@ -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<long>;
messages.toggleNoForwards#b11eafa2 peer:InputPeer enabled:Bool = Updates;
messages.saveDefaultSendAs#ccfddf96 peer:InputPeer send_as:InputPeer = Bool;

View File

@ -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();

View File

@ -75,6 +75,9 @@
"messages.addChatUser",
"messages.deleteChatUser",
"messages.createChat",
"messages.getExportedChatInvites",
"messages.editExportedChatInvite",
"messages.deleteExportedChatInvite",
"messages.getDhConfig",
"messages.readMessageContents",
"messages.getStickers",

View File

@ -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,

View File

@ -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 }));
});

View File

@ -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');

View File

@ -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));

View File

@ -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 {

View File

@ -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,
}

View File

@ -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) {