Management: Introduce Exported Invites (#1645)
This commit is contained in:
parent
8f1b32cdb4
commit
bfc958484c
@ -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
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
268
src/components/right/management/ManageInvite.tsx
Normal file
268
src/components/right/management/ManageInvite.tsx
Normal 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));
|
||||
262
src/components/right/management/ManageInvites.tsx
Normal file
262
src/components/right/management/ManageInvites.tsx
Normal 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));
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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' |
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -75,6 +75,9 @@
|
||||
"messages.addChatUser",
|
||||
"messages.deleteChatUser",
|
||||
"messages.createChat",
|
||||
"messages.getExportedChatInvites",
|
||||
"messages.editExportedChatInvite",
|
||||
"messages.deleteExportedChatInvite",
|
||||
"messages.getDhConfig",
|
||||
"messages.readMessageContents",
|
||||
"messages.getStickers",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 }));
|
||||
});
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user