diff --git a/src/api/gramjs/apiBuilders/statistics.ts b/src/api/gramjs/apiBuilders/statistics.ts index e80c59a94..cccbae6a9 100644 --- a/src/api/gramjs/apiBuilders/statistics.ts +++ b/src/api/gramjs/apiBuilders/statistics.ts @@ -161,7 +161,7 @@ function buildStatisticsOverview({ current, previous }: GramJs.StatsAbsValueAndP }; } -function buildStatisticsPercentage(data: GramJs.StatsPercentValue): StatisticsOverviewPercentage { +export function buildStatisticsPercentage(data: GramJs.StatsPercentValue): StatisticsOverviewPercentage { return { percentage: ((data.part / data.total) * 100).toFixed(2), }; diff --git a/src/api/gramjs/apiBuilders/stories.ts b/src/api/gramjs/apiBuilders/stories.ts index 1f264c501..157bc2f82 100644 --- a/src/api/gramjs/apiBuilders/stories.ts +++ b/src/api/gramjs/apiBuilders/stories.ts @@ -1,14 +1,18 @@ -import { Api as GramJs } from '../../../lib/gramjs'; +import { Api as GramJs, errors } from '../../../lib/gramjs'; import type { + ApiApplyBoostInfo, + ApiBoostsStatus, ApiMediaArea, ApiMediaAreaCoordinates, ApiMessage, ApiStealthMode, ApiStoryView, ApiTypeStory, } from '../../types'; import { buildCollectionByCallback } from '../../../util/iteratees'; +import { getServerTime } from '../../../util/serverTime'; import { buildPrivacyRules } from './common'; import { buildGeoPoint, buildMessageMediaContent, buildMessageTextContent } from './messageContent'; import { buildApiPeerId, getApiChatIdFromMtpPeer } from './peers'; import { buildApiReaction, buildReactionCount } from './reactions'; +import { buildStatisticsPercentage } from './statistics'; export function buildApiStory(peerId: string, story: GramJs.TypeStoryItem): ApiTypeStory { if (story instanceof GramJs.StoryItemDeleted) { @@ -161,3 +165,56 @@ export function buildApiPeerStories(peerStories: GramJs.PeerStories) { return buildCollectionByCallback(peerStories.stories, (story) => [story.id, buildApiStory(peerId, story)]); } + +export function buildApiApplyBoostInfo( + applyBoostInfo: GramJs.stories.TypeCanApplyBoostResult, +): ApiApplyBoostInfo | undefined { + if (applyBoostInfo instanceof GramJs.stories.CanApplyBoostOk) { + return { type: 'ok' }; + } + + if (applyBoostInfo instanceof GramJs.stories.CanApplyBoostReplace) { + return { + type: 'replace', + boostedChatId: getApiChatIdFromMtpPeer(applyBoostInfo.currentBoost), + }; + } + + return undefined; +} + +export function buildApiApplyBoostInfoFromError( + error: unknown, +): ApiApplyBoostInfo | undefined { + if (error instanceof errors.FloodWaitError) { + return { + type: 'wait', + waitUntil: getServerTime() + error.seconds, + }; + } + + if (error instanceof Error) { + if (error.message === 'BOOST_NOT_MODIFIED') { + return { + type: 'already', + }; + } + } + + return undefined; +} + +export function buildApiBoostsStatus(boostStatus: GramJs.stories.BoostsStatus): ApiBoostsStatus { + const { + level, boostUrl, boosts, myBoost, currentLevelBoosts, nextLevelBoosts, premiumAudience, + } = boostStatus; + return { + level, + currentLevelBoosts, + boosts, + hasMyBoost: Boolean(myBoost), + boostUrl, + nextLevelBoosts, + ...(premiumAudience && { premiumAudience: buildStatisticsPercentage(premiumAudience) }), + }; +} diff --git a/src/api/gramjs/methods/stories.ts b/src/api/gramjs/methods/stories.ts index 89f7e6023..b05ae9963 100644 --- a/src/api/gramjs/methods/stories.ts +++ b/src/api/gramjs/methods/stories.ts @@ -17,6 +17,9 @@ import { buildCollectionByCallback } from '../../../util/iteratees'; import { buildApiChatFromPreview } from '../apiBuilders/chats'; import { getApiChatIdFromMtpPeer } from '../apiBuilders/peers'; import { + buildApiApplyBoostInfo, + buildApiApplyBoostInfoFromError, + buildApiBoostsStatus, buildApiPeerStories, buildApiStealthMode, buildApiStory, @@ -426,3 +429,68 @@ export function activateStealthMode({ shouldReturnTrue: true, }); } + +export async function fetchCanApplyBoost({ + chat, +} : { + chat: ApiChat; +}) { + let result: GramJs.stories.TypeCanApplyBoostResult | undefined; + try { + result = await invokeRequest(new GramJs.stories.CanApplyBoost({ + peer: buildInputPeer(chat.id, chat.accessHash), + }), { + shouldThrow: true, + }); + } catch (error) { + const info = buildApiApplyBoostInfoFromError(error); + if (!info) return undefined; + return { + info, + chats: [], + }; + } + + if (!result) { + return undefined; + } + + const mtpChats = 'chats' in result ? result.chats : []; + addEntitiesToLocalDb(mtpChats); + + const chats = mtpChats.map((c) => buildApiChatFromPreview(c)).filter(Boolean); + const info = buildApiApplyBoostInfo(result); + + return { + info, + chats, + }; +} + +export function applyBoost({ + chat, +} : { + chat: ApiChat; +}) { + return invokeRequest(new GramJs.stories.ApplyBoost({ + peer: buildInputPeer(chat.id, chat.accessHash), + }), { + shouldReturnTrue: true, + }); +} + +export async function fetchBoostsStatus({ + chat, +}: { + chat: ApiChat; +}) { + const result = await invokeRequest(new GramJs.stories.GetBoostsStatus({ + peer: buildInputPeer(chat.id, chat.accessHash), + })); + + if (!result) { + return undefined; + } + + return buildApiBoostsStatus(result); +} diff --git a/src/api/types/stories.ts b/src/api/types/stories.ts index 24d94335e..6a8ab6d5b 100644 --- a/src/api/types/stories.ts +++ b/src/api/types/stories.ts @@ -2,6 +2,7 @@ import type { ApiPrivacySettings } from '../../types'; import type { ApiGeoPoint, ApiMessage, ApiReaction, ApiReactionCount, } from './messages'; +import type { StatisticsOverviewPercentage } from './statistics'; export interface ApiStory { '@type'?: 'story'; @@ -108,3 +109,33 @@ export type ApiMediaAreaSuggestedReaction = { }; export type ApiMediaArea = ApiMediaAreaVenue | ApiMediaAreaGeoPoint | ApiMediaAreaSuggestedReaction; + +export type ApiApplyBoostOk = { + type: 'ok'; +}; + +export type ApiApplyBoostReplace = { + type: 'replace'; + boostedChatId: string; +}; + +export type ApiApplyBoostWait = { + type: 'wait'; + waitUntil: number; +}; + +export type ApiApplyBoostAlready = { + type: 'already'; +}; + +export type ApiApplyBoostInfo = ApiApplyBoostOk | ApiApplyBoostReplace | ApiApplyBoostWait | ApiApplyBoostAlready; + +export type ApiBoostsStatus = { + level: number; + currentLevelBoosts: number; + boosts: number; + nextLevelBoosts?: number; + hasMyBoost?: boolean; + boostUrl: string; + premiumAudience?: StatisticsOverviewPercentage; +}; diff --git a/src/assets/font-icons/boost.svg b/src/assets/font-icons/boost.svg new file mode 100644 index 000000000..062c8e599 --- /dev/null +++ b/src/assets/font-icons/boost.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/font-icons/boostcircle.svg b/src/assets/font-icons/boostcircle.svg new file mode 100644 index 000000000..035186155 --- /dev/null +++ b/src/assets/font-icons/boostcircle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts index e6dffc9a5..61a0dd27a 100644 --- a/src/bundles/extra.ts +++ b/src/bundles/extra.ts @@ -19,6 +19,7 @@ export { default as PremiumMainModal } from '../components/main/premium/PremiumM export { default as GiftPremiumModal } from '../components/main/premium/GiftPremiumModal'; export { default as PremiumLimitReachedModal } from '../components/main/premium/common/PremiumLimitReachedModal'; export { default as StatusPickerMenu } from '../components/left/main/StatusPickerMenu'; +export { default as BoostModal } from '../components/modals/boost/BoostModal'; export { default as ChatlistModal } from '../components/modals/chatlist/ChatlistModal'; diff --git a/src/components/common/Picker.tsx b/src/components/common/Picker.tsx index 704bdf66b..70d806f76 100644 --- a/src/components/common/Picker.tsx +++ b/src/components/common/Picker.tsx @@ -132,7 +132,7 @@ const Picker: FC = ({
{lockedSelectedIds.map((id, i) => ( = ({ ))} {unlockedSelectedIds.map((id, i) => ( void; }; @@ -38,6 +39,7 @@ const PickerSelectedItem: FC = ({ icon, title, isMinimized, + isStandalone, canClose, clickArg, chat, @@ -81,6 +83,7 @@ const PickerSelectedItem: FC = ({ chat?.isForum && 'forum-avatar', isMinimized && 'minimized', canClose && 'closeable', + isStandalone && 'standalone', ); return ( @@ -106,13 +109,13 @@ const PickerSelectedItem: FC = ({ }; export default memo(withGlobal( - (global, { chatOrUserId, forceShowSelf }): StateProps => { - if (!chatOrUserId) { + (global, { peerId, forceShowSelf }): StateProps => { + if (!peerId) { return {}; } - const chat = selectChat(global, chatOrUserId); - const user = selectUser(global, chatOrUserId); + const chat = selectChat(global, peerId); + const user = selectUser(global, peerId); const isSavedMessages = !forceShowSelf && user && user.isSelf; return { diff --git a/src/components/common/PremiumProgress.module.scss b/src/components/common/PremiumProgress.module.scss new file mode 100644 index 000000000..f148f236f --- /dev/null +++ b/src/components/common/PremiumProgress.module.scss @@ -0,0 +1,123 @@ +.root { + --percent: calc(var(--progress, 0.5) * 100%); + display: flex; + position: relative; + height: 2rem; + background: #F1F3F5; + border-radius: 0.625rem; + color: black; +} + +.withBadge { + margin-top: 2rem; +} + +.badgeContainer { + --shift-x: calc(clamp(10%, var(--percent), 90%) - 50%); + display: flex; + justify-content: center; + position: absolute; + top: -1.5rem; + left: 0; + right: 0; + transform: translate(var(--shift-x), -20px); + + transition: transform 0.2s ease-in-out; + animation: slide-in 0.5s ease-in-out; +} + +@keyframes slide-in { + from { + transform: translate(-50%, -20px); + } + + to { + transform: translate(var(--shift-x), -20px); + } +} + +.floating-badge { + display: flex; + align-items: center; + justify-content: center; + color: #ffffff; + position: relative; + padding: 0.25rem 0.75rem; + border-radius: 1rem; + background-color: #7E85FF; + animation: rotate-in 0.5s ease-in-out; +} + +@keyframes rotate-in { + 0% { + transform: rotate(0deg); + } + + 50% { + // Rotate more if progress is higher + transform: rotate(calc(-20deg * var(--progress))); + } + + 100% { + transform: rotate(0deg); + } +} + +.floating-badge-triangle { + position: absolute; + bottom: -15px; +} + +.floating-badge-icon { + font-size: 1.25rem; + margin-right: 0.25rem; +} + +.floating-badge-value { + font-size: 16px; + font-weight: 500; +} + +.left, .right { + position: absolute; + top: 0; + bottom: 0; + display: flex; + align-items: center; + font-weight: 500; +} + +.left { + left: 0.75rem; +} + +.right { + right: 0.75rem; +} + +.progress { + --multiplier: calc(1 / var(--progress) - 1); + overflow: hidden; + position: absolute; + top: 0; + left: 0; + bottom: 0; + width: var(--percent); + border-top-left-radius: 0.625rem; + border-bottom-left-radius: 0.625rem; + background-image: var(--premium-gradient); + background-size: calc(1 / var(--progress) * 100%) 100%; + + .left, .right { + color: white; + white-space: nowrap; + } + + .right { + right: calc(-100% * var(--multiplier) + 0.75rem); + } +} + +.fullProgress { + border-radius: 0.625rem; +} diff --git a/src/components/common/PremiumProgress.tsx b/src/components/common/PremiumProgress.tsx new file mode 100644 index 000000000..47e0f985b --- /dev/null +++ b/src/components/common/PremiumProgress.tsx @@ -0,0 +1,79 @@ +import type { FC } from '../../lib/teact/teact'; +import React, { memo } from '../../lib/teact/teact'; + +import type { IconName } from '../../types/icons'; + +import buildClassName from '../../util/buildClassName'; +import buildStyle from '../../util/buildStyle'; + +import useLang from '../../hooks/useLang'; + +import Icon from './Icon'; + +import styles from './PremiumProgress.module.scss'; + +type OwnProps = { + leftText?: string; + rightText?: string; + floatingBadgeIcon?: IconName; + floatingBadgeText?: string; + progress?: number; + className?: string; +}; + +const LimitPreview: FC = ({ + leftText, + rightText, + floatingBadgeText, + floatingBadgeIcon, + progress, + className, +}) => { + const lang = useLang(); + + const hasFloatingBadge = Boolean(floatingBadgeIcon || floatingBadgeText); + const isProgressFull = Boolean(progress) && progress > 0.99; + + return ( +
+ {hasFloatingBadge && ( +
+
+ {floatingBadgeIcon && } + {floatingBadgeText && ( +
{floatingBadgeText}
+ )} +
+ + + +
+
+
+ )} +
+ {leftText} +
+
+ {rightText} +
+
+
+ {leftText} +
+
+ {rightText} +
+
+
+ ); +}; + +export default memo(LimitPreview); diff --git a/src/components/common/helpers/boostInfo.ts b/src/components/common/helpers/boostInfo.ts new file mode 100644 index 000000000..328fc1628 --- /dev/null +++ b/src/components/common/helpers/boostInfo.ts @@ -0,0 +1,24 @@ +import type { ApiBoostsStatus } from '../../../api/types'; + +export function getBoostProgressInfo(boostInfo: ApiBoostsStatus) { + const { + level, boosts, currentLevelBoosts, nextLevelBoosts, hasMyBoost, + } = boostInfo; + + const currentLevel = level; + const hasNextLevel = Boolean(nextLevelBoosts); + + const isJustUpgraded = boosts === currentLevelBoosts && hasMyBoost; + + const levelProgress = (!nextLevelBoosts || isJustUpgraded) ? 1 + : (boosts - currentLevelBoosts) / (nextLevelBoosts - currentLevelBoosts); + const remainingBoosts = nextLevelBoosts ? nextLevelBoosts - boosts : 0; + + return { + currentLevel, + hasNextLevel, + boosts, + levelProgress, + remainingBoosts, + }; +} diff --git a/src/components/left/main/LeftMainHeader.tsx b/src/components/left/main/LeftMainHeader.tsx index a5d46b377..616d9c8f0 100644 --- a/src/components/left/main/LeftMainHeader.tsx +++ b/src/components/left/main/LeftMainHeader.tsx @@ -229,7 +229,7 @@ const LeftMainHeader: FC = ({ )} {globalSearchChatId && ( = ({ > {localResults.map((id) => ( diff --git a/src/components/left/settings/folders/SettingsFoldersChatsPicker.tsx b/src/components/left/settings/folders/SettingsFoldersChatsPicker.tsx index e004992f9..1e3bf22bb 100644 --- a/src/components/left/settings/folders/SettingsFoldersChatsPicker.tsx +++ b/src/components/left/settings/folders/SettingsFoldersChatsPicker.tsx @@ -196,7 +196,7 @@ const SettingsFoldersChatsPicker: FC = ({ {selectedChatTypes.map(renderSelectedChatType)} {selectedIds.map((id, i) => ( = ({ deleteFolderDialog, isMasterTab, chatlistModal, + boostModal, noRightColumnAnimation, isSynced, }) => { @@ -554,6 +557,7 @@ const Main: FC = ({ userId={newContactUserId} isByPhoneNumber={newContactByPhoneNumber} /> + @@ -616,6 +620,7 @@ export default memo(withGlobal( limitReachedModal, deleteFolderDialogModal, chatlistModal, + boostModal, } = selectTabState(global); const { chatId: audioChatId, messageId: audioMessageId } = audioPlayer; @@ -678,6 +683,7 @@ export default memo(withGlobal( isMasterTab, requestedDraft, chatlistModal, + boostModal, noRightColumnAnimation, isSynced: global.isSynced, }; diff --git a/src/components/middle/message/helpers/webpageType.ts b/src/components/middle/message/helpers/webpageType.ts index b5f7c3d20..6cc06711c 100644 --- a/src/components/middle/message/helpers/webpageType.ts +++ b/src/components/middle/message/helpers/webpageType.ts @@ -25,7 +25,9 @@ export function getWebpageButtonText(type?: string) { case 'telegram_chatlist': return 'ViewChatList'; case 'telegram_story': - return 'ViewStory'; + return 'lng_view_button_story'; + case 'telegram_channel_boost': + return 'lng_view_button_boost'; default: return undefined; } diff --git a/src/components/modals/boost/BoostModal.async.tsx b/src/components/modals/boost/BoostModal.async.tsx new file mode 100644 index 000000000..c6027accc --- /dev/null +++ b/src/components/modals/boost/BoostModal.async.tsx @@ -0,0 +1,18 @@ +import type { FC } from '../../../lib/teact/teact'; +import React from '../../../lib/teact/teact'; + +import type { OwnProps } from './BoostModal'; + +import { Bundles } from '../../../util/moduleLoader'; + +import useModuleLoader from '../../../hooks/useModuleLoader'; + +const BoostModalAsync: FC = (props) => { + const { info } = props; + const BoostModal = useModuleLoader(Bundles.Extra, 'BoostModal', !info); + + // eslint-disable-next-line react/jsx-props-no-spreading + return BoostModal ? : undefined; +}; + +export default BoostModalAsync; diff --git a/src/components/modals/boost/BoostModal.module.scss b/src/components/modals/boost/BoostModal.module.scss new file mode 100644 index 000000000..5f313d5b3 --- /dev/null +++ b/src/components/modals/boost/BoostModal.module.scss @@ -0,0 +1,69 @@ +.content { + display: flex; + flex-direction: column; + gap: 1rem; + padding-top: 0 !important; + min-height: 14rem; + overflow: hidden; +} + +.loading { + margin-block: auto; +} + +.text-center { + text-align: center; + text-wrap: balance +} + +.description { + padding: 0 0.75rem; +} + +.chip { + align-self: center; +} + +.replaceModal :global(.modal-dialog) { + max-width: 22rem; +} + +.replaceModalContent { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.avatarContainer { + display: flex; + align-self: center; + gap: 0.25rem; + align-items: center; + margin-bottom: 0.5rem; +} + +.arrow { + font-size: 2rem; + color: var(--color-text-secondary); +} + +.boostedWrapper { + position: relative; +} + +.boostedMark { + position: absolute; + bottom: -0.125rem; + right: -0.125rem; + font-size: 1.25rem; + background-color: var(--color-background); + padding: 0.125rem; + border-radius: 50%; + z-index: 10; + + &::before { + background-image: var(--premium-gradient); + background-clip: text; + -webkit-text-fill-color: transparent; + } +} diff --git a/src/components/modals/boost/BoostModal.tsx b/src/components/modals/boost/BoostModal.tsx new file mode 100644 index 000000000..6245bafca --- /dev/null +++ b/src/components/modals/boost/BoostModal.tsx @@ -0,0 +1,311 @@ +import React, { memo, useMemo } from '../../../lib/teact/teact'; +import { getActions, withGlobal } from '../../../global'; + +import type { ApiApplyBoostInfo, ApiChat } from '../../../api/types'; +import type { TabState } from '../../../global/types'; + +import { getChatTitle } from '../../../global/helpers'; +import { selectChat } from '../../../global/selectors'; +import buildClassName from '../../../util/buildClassName'; +import { formatDateInFuture } from '../../../util/dateFormat'; +import { getServerTime } from '../../../util/serverTime'; +import { getBoostProgressInfo } from '../../common/helpers/boostInfo'; +import renderText from '../../common/helpers/renderText'; + +import useFlag from '../../../hooks/useFlag'; +import useLang from '../../../hooks/useLang'; +import useLastCallback from '../../../hooks/useLastCallback'; + +import Avatar from '../../common/Avatar'; +import Icon from '../../common/Icon'; +import PickerSelectedItem from '../../common/PickerSelectedItem'; +import PremiumProgress from '../../common/PremiumProgress'; +import Button from '../../ui/Button'; +import ConfirmDialog from '../../ui/ConfirmDialog'; +import Loading from '../../ui/Loading'; +import Modal from '../../ui/Modal'; + +import styles from './BoostModal.module.scss'; + +type LoadedParams = { + applyInfo?: ApiApplyBoostInfo; + leftText: string; + rightText?: string; + value: string; + progress: number; + descriptionText: string; + isBoosted?: boolean; +}; + +type BoostInfo = ({ + isStatusLoaded: false; + title: string; +} & Undefined) | ({ + isStatusLoaded: true; + title: string; +} & LoadedParams); + +export type OwnProps = { + info: TabState['boostModal']; +}; + +type StateProps = { + chat?: ApiChat; + boostedChat?: ApiChat; +}; + +const BoostModal = ({ + info, + chat, + boostedChat, +}: OwnProps & StateProps) => { + const { + openChat, + applyBoost, + closeBoostModal, + requestConfetti, + } = getActions(); + + const [isReplaceModalOpen, openReplaceModal, closeReplaceModal] = useFlag(); + const [isWaitDialogOpen, openWaitDialog, closeWaitDialog] = useFlag(); + + const isOpen = Boolean(info); + + const lang = useLang(); + + const chatTitle = useMemo(() => { + if (!chat) { + return undefined; + } + + return getChatTitle(lang, chat); + }, [chat, lang]); + + const boostedChatTitle = useMemo(() => { + if (!boostedChat) { + return undefined; + } + + return getChatTitle(lang, boostedChat); + }, [boostedChat, lang]); + + const { + isStatusLoaded, + isBoosted, + applyInfo, + title, + leftText, + rightText, + value, + progress, + descriptionText, + }: BoostInfo = useMemo(() => { + if (!info?.boostStatus || !chat) { + return { + isStatusLoaded: false, + title: lang('Loading'), + }; + } + + const { + level, currentLevelBoosts, hasMyBoost, + } = info.boostStatus; + + const { + boosts, + currentLevel, + hasNextLevel, + levelProgress, + remainingBoosts, + } = getBoostProgressInfo(info.boostStatus); + + const hasBoost = hasMyBoost || info.applyInfo?.type === 'already'; + const isJustUpgraded = boosts === currentLevelBoosts && hasBoost; + + const left = lang('BoostsLevel', currentLevel); + const right = hasNextLevel ? lang('BoostsLevel', currentLevel + 1) : undefined; + + const moreBoosts = lang('ChannelBoost.MoreBoosts', remainingBoosts); + const currentStoriesPerDay = lang('ChannelBoost.StoriesPerDay', level); + const nextLevelStoriesPerDay = lang('ChannelBoost.StoriesPerDay', level + 1); + + const modalTitle = hasBoost ? lang('ChannelBoost.YouBoostedOtherChannel') + : level === 0 ? lang('lng_boost_channel_title_first') : lang('lng_boost_channel_title_more'); + + let description: string | undefined; + if (level === 0) { + if (!hasBoost) { + description = lang('ChannelBoost.EnableStoriesForChannelText', [chatTitle, moreBoosts]); + } else { + description = lang('ChannelBoost.EnableStoriesMoreRequired', moreBoosts); + } + } else if (isJustUpgraded) { + if (level === 1) { + description = lang('ChannelBoost.EnabledStoriesForChannelText'); + } else { + description = lang('ChannelBoost.BoostedChannelReachedLevel', [level, currentStoriesPerDay]); + } + } else { + description = lang('ChannelBoost.HelpUpgradeChannelText', [chatTitle, moreBoosts, nextLevelStoriesPerDay]); + } + + return { + isStatusLoaded: true, + title: modalTitle, + leftText: left, + rightText: right, + value: boosts.toString(), + progress: levelProgress, + remainingBoosts, + descriptionText: description, + applyInfo: info.applyInfo, + isBoosted: hasBoost, + }; + }, [chat, chatTitle, info, lang]); + + const handleOpenChat = useLastCallback(() => { + openChat({ id: chat!.id }); + closeBoostModal(); + }); + + const handleApplyBoost = useLastCallback(() => { + closeReplaceModal(); + applyBoost({ chatId: chat!.id }); + requestConfetti(); + }); + + const handleButtonClick = useLastCallback(() => { + if (applyInfo?.type === 'ok') { + handleApplyBoost(); + } + + if (applyInfo?.type === 'replace') { + openReplaceModal(); + } + + if (applyInfo?.type === 'wait') { + openWaitDialog(); + } + + if (isBoosted) { + closeBoostModal(); + } + }); + + function renderContent() { + if (!isStatusLoaded) { + return ; + } + + return ( + <> + {chat && ( + + )} + +
+ {renderText(descriptionText, ['simple_markdown', 'emoji'])} +
+ + + ); + } + + return ( + + {renderContent()} + {applyInfo?.type === 'replace' && boostedChatTitle && ( + +
+
+ + +
+ + +
+
+ {renderText(lang('ChannelBoost.ReplaceBoost', [boostedChatTitle, chatTitle]), ['simple_markdown', 'emoji'])} +
+
+ + +
+
+ )} + {applyInfo?.type === 'wait' && ( + + {renderText( + lang( + 'ChannelBoost.Error.BoostTooOftenText', + formatDateInFuture(lang, getServerTime(), applyInfo.waitUntil), + ), + ['simple_markdown', 'emoji'], + )} + + )} +
+ ); +}; + +export default memo(withGlobal( + (global, { info }): StateProps => { + const chat = info && selectChat(global, info?.chatId); + const boostedChat = info?.applyInfo?.type === 'replace' + ? selectChat(global, info.applyInfo.boostedChatId) : undefined; + + return { + chat, + boostedChat, + }; + }, +)(BoostModal)); diff --git a/src/components/ui/Button.scss b/src/components/ui/Button.scss index b2a06039f..1ddbaf9d2 100644 --- a/src/components/ui/Button.scss +++ b/src/components/ui/Button.scss @@ -359,7 +359,8 @@ .Spinner { position: absolute; right: 0.875rem; - top: 0.875rem; + top: 50%; + transform: translateY(-50%); --spinner-size: 1.8125rem; } diff --git a/src/components/ui/ConfirmDialog.tsx b/src/components/ui/ConfirmDialog.tsx index 114bb395f..1cdf38a61 100644 --- a/src/components/ui/ConfirmDialog.tsx +++ b/src/components/ui/ConfirmDialog.tsx @@ -20,6 +20,7 @@ type OwnProps = { confirmLabel?: string; confirmIsDestructive?: boolean; isConfirmDisabled?: boolean; + isOnlyConfirm?: boolean; areButtonsInColumn?: boolean; className?: string; children?: React.ReactNode; @@ -37,6 +38,7 @@ const ConfirmDialog: FC = ({ confirmLabel = 'Confirm', confirmIsDestructive, isConfirmDisabled, + isOnlyConfirm, areButtonsInColumn, className, children, @@ -82,7 +84,7 @@ const ConfirmDialog: FC = ({ > {confirmLabel} - + {!isOnlyConfirm && }
); diff --git a/src/components/ui/Loading.tsx b/src/components/ui/Loading.tsx index 44536c7d8..f5b7dea53 100644 --- a/src/components/ui/Loading.tsx +++ b/src/components/ui/Loading.tsx @@ -9,12 +9,15 @@ import './Loading.scss'; type OwnProps = { color?: 'blue' | 'white' | 'black' | 'yellow'; backgroundColor?: 'light' | 'dark'; + className?: string; onClick?: NoneToVoidFunction; }; -const Loading = ({ color = 'blue', backgroundColor, onClick }: OwnProps) => { +const Loading = ({ + color = 'blue', backgroundColor, className, onClick, +}: OwnProps) => { return ( -
+
); diff --git a/src/global/actions/api/chats.ts b/src/global/actions/api/chats.ts index 9d61b5472..2e3ea3217 100644 --- a/src/global/actions/api/chats.ts +++ b/src/global/actions/api/chats.ts @@ -42,6 +42,7 @@ import { isChatSummaryOnly, isChatSuperGroup, isUserBot, + toChannelId, } from '../../helpers'; import { addActionHandler, getGlobal, setGlobal, @@ -973,6 +974,7 @@ addActionHandler('openTelegramLink', (global, actions, payload): ActionReturnTyp checkChatlistInvite, openChatByUsername: openChatByUsernameAction, openStoryViewerByUsername, + processBoostParameters, } = actions; if (url.match(RE_TG_LINK)) { @@ -1002,6 +1004,7 @@ addActionHandler('openTelegramLink', (global, actions, payload): ActionReturnTyp const hasStartApp = params.hasOwnProperty('startapp'); const choose = parseChooseParameter(params.choose); const storyId = part2 === 's' && (Number(part3) || undefined); + const hasBoost = params.hasOwnProperty('boost'); if (part1.match(/^\+([0-9]+)(\?|$)/)) { openChatByPhoneNumber({ @@ -1064,19 +1067,39 @@ addActionHandler('openTelegramLink', (global, actions, payload): ActionReturnTyp inviteHash: params.voicechat || params.livestream, tabId, }); + } else if (part1 === 'boost') { + const username = part2; + const id = params.c; + + const isPrivate = !username && Boolean(id); + + processBoostParameters({ + usernameOrId: username || id, + isPrivate, + tabId, + }); + } else if (hasBoost) { + const isPrivate = part1 === 'c' && Boolean(chatOrChannelPostId); + processBoostParameters({ + usernameOrId: chatOrChannelPostId || part1, + isPrivate, + tabId, + }); } else if (part1 === 'c' && chatOrChannelPostId && messageId) { - const chatId = `-100${chatOrChannelPostId}`; + const chatId = toChannelId(chatOrChannelPostId); const chat = selectChat(global, chatId); if (!chat) { showNotification({ message: 'Chat does not exist', tabId }); return; } - focusMessage({ - chatId: chat.id, - messageId, - tabId, - }); + if (messageId) { + focusMessage({ + chatId: chat.id, + messageId, + tabId, + }); + } } else if (part1.startsWith('$')) { openInvoice({ slug: part1.substring(1), @@ -1110,6 +1133,37 @@ addActionHandler('openTelegramLink', (global, actions, payload): ActionReturnTyp } }); +addActionHandler('processBoostParameters', async (global, actions, payload): Promise => { + const { usernameOrId, isPrivate, tabId = getCurrentTabId() } = payload; + + let chat: ApiChat | undefined; + + if (isPrivate) { + const chatId = toChannelId(usernameOrId); + chat = selectChat(global, chatId); + if (!chat) { + actions.showNotification({ message: 'Chat does not exist', tabId }); + return; + } + } else { + chat = await fetchChatByUsername(global, usernameOrId); + if (!chat) { + actions.showNotification({ message: 'User does not exist', tabId }); + return; + } + } + + if (!isChatChannel(chat)) { + actions.openChat({ id: chat.id, tabId }); + return; + } + + actions.openBoostModal({ + chatId: chat.id, + tabId, + }); +}); + addActionHandler('acceptInviteConfirmation', async (global, actions, payload): Promise => { const { hash, tabId = getCurrentTabId() } = payload!; const result = await callApi('importChatInvite', { hash }); @@ -1139,7 +1193,9 @@ addActionHandler('openChatByUsername', async (global, actions, payload): Promise return; } if (!isWebApp) { - await openChatByUsername(global, actions, username, threadId, messageId, startParam, startAttach, attach, tabId); + await openChatByUsername( + global, actions, username, threadId, messageId, startParam, startAttach, attach, tabId, + ); return; } } diff --git a/src/global/actions/api/stories.ts b/src/global/actions/api/stories.ts index bcb31a0db..d1d514ed8 100644 --- a/src/global/actions/api/stories.ts +++ b/src/global/actions/api/stories.ts @@ -6,7 +6,7 @@ import { buildCollectionByKey } from '../../../util/iteratees'; import { translate } from '../../../util/langProvider'; import { getServerTime } from '../../../util/serverTime'; import { callApi } from '../../../api/gramjs'; -import { buildApiInputPrivacyRules } from '../../helpers'; +import { buildApiInputPrivacyRules, isChatChannel } from '../../helpers'; import { addActionHandler, getGlobal, setGlobal } from '../../index'; import { addChats, @@ -26,8 +26,10 @@ import { updateStoryViews, updateStoryViewsLoading, } from '../../reducers'; +import { updateTabState } from '../../reducers/tabs'; import { - selectPeer, selectPeerStories, selectPeerStory, + selectChat, + selectPeer, selectPeerStories, selectPeerStory, selectTabState, } from '../../selectors'; const INFINITE_LOOP_MARKER = 100; @@ -502,3 +504,89 @@ addActionHandler('activateStealthMode', (global, actions, payload): ActionReturn callApi('activateStealthMode', { isForPast: isForPast || true, isForFuture: isForFuture || true }); }); + +addActionHandler('openBoostModal', async (global, actions, payload): Promise => { + const { chatId, tabId = getCurrentTabId() } = payload; + const chat = selectChat(global, chatId); + if (!chat || !isChatChannel(chat)) return; + + global = updateTabState(global, { + boostModal: { + chatId, + }, + }, tabId); + setGlobal(global); + + const result = await callApi('fetchBoostsStatus', { + chat, + }); + + if (!result) { + actions.closeBoostModal({ tabId }); + return; + } + + global = getGlobal(); + global = updateTabState(global, { + boostModal: { + chatId, + boostStatus: result, + }, + }, tabId); + setGlobal(global); + + const applyInfoResult = await callApi('fetchCanApplyBoost', { + chat, + }); + + if (!applyInfoResult?.info) return; + + const applyInfo = applyInfoResult.info; + + global = getGlobal(); + const tabState = selectTabState(global, tabId); + if (!tabState.boostModal) return; + + global = addChats(global, buildCollectionByKey(applyInfoResult.chats, 'id')); + global = updateTabState(global, { + boostModal: { + ...tabState.boostModal, + applyInfo, + }, + }, tabId); + setGlobal(global); +}); + +addActionHandler('applyBoost', async (global, actions, payload): Promise => { + const { chatId, tabId = getCurrentTabId() } = payload; + + const chat = selectChat(global, chatId); + if (!chat) return; + + const result = await callApi('applyBoost', { + chat, + }); + + if (!result) { + return; + } + + const newStatusResult = await callApi('fetchBoostsStatus', { + chat, + }); + + if (!newStatusResult) { + return; + } + + global = getGlobal(); + const tabState = selectTabState(global, tabId); + if (!tabState.boostModal?.boostStatus) return; + global = updateTabState(global, { + boostModal: { + ...tabState.boostModal, + boostStatus: newStatusResult, + }, + }, tabId); + setGlobal(global); +}); diff --git a/src/global/actions/ui/stories.ts b/src/global/actions/ui/stories.ts index fe2f06cbc..9024b7cd9 100644 --- a/src/global/actions/ui/stories.ts +++ b/src/global/actions/ui/stories.ts @@ -411,3 +411,11 @@ addActionHandler('updateStoryView', (global, actions, payload): ActionReturnType }, }, tabId); }); + +addActionHandler('closeBoostModal', (global, actions, payload): ActionReturnType => { + const { tabId = getCurrentTabId() } = payload || {}; + + return updateTabState(global, { + boostModal: undefined, + }, tabId); +}); diff --git a/src/global/helpers/chats.ts b/src/global/helpers/chats.ts index e4676c132..74edd5e4d 100644 --- a/src/global/helpers/chats.ts +++ b/src/global/helpers/chats.ts @@ -35,6 +35,10 @@ export function isChannelId(entityId: string) { return entityId.length === CHANNEL_ID_LENGTH && entityId.startsWith('-100'); } +export function toChannelId(mtpId: string) { + return `-100${mtpId}`; +} + export function isChatGroup(chat: ApiChat) { return isChatBasicGroup(chat) || isChatSuperGroup(chat); } diff --git a/src/global/types.ts b/src/global/types.ts index 27be0ee0e..32ebaf39e 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -1,8 +1,10 @@ import type { ApiAppConfig, + ApiApplyBoostInfo, ApiAttachBot, ApiAttachment, ApiAvailableReaction, + ApiBoostsStatus, ApiChannelStatistics, ApiChat, ApiChatAdminRights, @@ -617,6 +619,12 @@ export type TabState = { suggestedPeerIds?: string[]; }; }; + + boostModal?: { + chatId: string; + boostStatus?: ApiBoostsStatus; + applyInfo?: ApiApplyBoostInfo; + }; }; export type GlobalState = { @@ -1358,6 +1366,10 @@ export interface ActionPayloads { startApp?: string; originalParts?: string[]; } & WithTabId; + processBoostParameters: { + usernameOrId: string; + isPrivate?: boolean; + } & WithTabId; requestThreadInfoUpdate: { chatId: string; threadId: number; @@ -2068,6 +2080,14 @@ export interface ActionPayloads { isForFuture?: boolean; } | undefined; + openBoostModal: { + chatId: string; + } & WithTabId; + closeBoostModal: WithTabId | undefined; + applyBoost: { + chatId: string; + } & WithTabId; + // Media Viewer & Audio Player openMediaViewer: { chatId?: string; diff --git a/src/lib/gramjs/tl/api.d.ts b/src/lib/gramjs/tl/api.d.ts index b78211038..3b087cc2a 100644 --- a/src/lib/gramjs/tl/api.d.ts +++ b/src/lib/gramjs/tl/api.d.ts @@ -348,7 +348,7 @@ namespace Api { export type TypeAccessPointRule = AccessPointRule; export type TypeTlsClientHello = TlsClientHello; export type TypeTlsBlock = TlsBlockString | TlsBlockRandom | TlsBlockZero | TlsBlockDomain | TlsBlockGrease | TlsBlockScope; - + export namespace storage { export type TypeFileType = storage.FileUnknown | storage.FilePartial | storage.FileJpeg | storage.FileGif | storage.FilePng | storage.FilePdf | storage.FileMp3 | storage.FileMov | storage.FileMp4 | storage.FileWebp; @@ -423,6 +423,7 @@ namespace Api { export type TypeEmojiGroups = messages.EmojiGroupsNotModified | messages.EmojiGroups; export type TypeTranslatedText = messages.TranslateResult; export type TypeBotApp = messages.BotApp; + export type TypeWebPage = messages.WebPage; } export namespace updates { @@ -8915,7 +8916,7 @@ namespace Api { }> { entries: Api.TypeTlsBlock[]; }; - + export namespace storage { export class FileUnknown extends VirtualClass {}; @@ -9753,6 +9754,15 @@ namespace Api { hasSettings?: true; app: Api.TypeBotApp; }; + export class WebPage extends VirtualClass<{ + webpage: Api.TypeWebPage; + chats: Api.TypeChat[]; + users: Api.TypeUser[]; + }> { + webpage: Api.TypeWebPage; + chats: Api.TypeChat[]; + users: Api.TypeUser[]; + }; } export namespace updates { @@ -10807,6 +10817,7 @@ namespace Api { boosts: int; nextLevelBoosts?: int; premiumAudience?: Api.TypeStatsPercentValue; + boostUrl: string; }> { // flags: undefined; myBoost?: true; @@ -10815,6 +10826,7 @@ namespace Api { boosts: int; nextLevelBoosts?: int; premiumAudience?: Api.TypeStatsPercentValue; + boostUrl: string; }; export class CanApplyBoostOk extends VirtualClass {}; export class CanApplyBoostReplace extends VirtualClass<{ @@ -10971,7 +10983,7 @@ namespace Api { }>, Api.TypeDestroySessionRes> { sessionId: long; }; - + export namespace auth { export class SendCode extends Request, Api.TypeWebPage> { + }>, messages.TypeWebPage> { url: string; hash: int; }; diff --git a/src/lib/gramjs/tl/apiTl.js b/src/lib/gramjs/tl/apiTl.js index 2121fac43..26a7e1948 100644 --- a/src/lib/gramjs/tl/apiTl.js +++ b/src/lib/gramjs/tl/apiTl.js @@ -1148,11 +1148,12 @@ mediaAreaGeoPoint#df8b3b22 coordinates:MediaAreaCoordinates geo:GeoPoint = Media mediaAreaSuggestedReaction#14455871 flags:# dark:flags.0?true flipped:flags.1?true coordinates:MediaAreaCoordinates reaction:Reaction = MediaArea; peerStories#9a35e999 flags:# peer:Peer max_read_id:flags.0?int stories:Vector = PeerStories; stories.peerStories#cae68768 stories:PeerStories chats:Vector users:Vector = stories.PeerStories; -stories.boostsStatus#66ea1fef flags:# my_boost:flags.2?true level:int current_level_boosts:int boosts:int next_level_boosts:flags.0?int premium_audience:flags.1?StatsPercentValue = stories.BoostsStatus; +stories.boostsStatus#e5c1aa5c flags:# my_boost:flags.2?true level:int current_level_boosts:int boosts:int next_level_boosts:flags.0?int premium_audience:flags.1?StatsPercentValue boost_url:string = stories.BoostsStatus; stories.canApplyBoostOk#c3173587 = stories.CanApplyBoostResult; stories.canApplyBoostReplace#712c4655 current_boost:Peer chats:Vector = stories.CanApplyBoostResult; booster#e9e6380 user_id:long expires:int = Booster; stories.boostersList#f3dd3d1d flags:# count:int boosters:Vector next_offset:flags.0?string users:Vector = stories.BoostersList; +messages.webPage#fd5e12bd webpage:WebPage chats:Vector users:Vector = messages.WebPage; ---functions--- invokeAfterMsg#cb9f372d {X:Type} msg_id:long query:!X = X; initConnection#c1cd5ea9 {X:Type} flags:# api_id:int device_model:string system_version:string app_version:string system_lang_code:string lang_pack:string lang_code:string proxy:flags.0?InputClientProxy params:flags.1?JSONValue query:!X = X; @@ -1279,7 +1280,7 @@ messages.getRecentStickers#9da9403b flags:# attached:flags.0?true hash:long = me messages.saveRecentSticker#392718f8 flags:# attached:flags.0?true id:InputDocument unsave:Bool = Bool; messages.clearRecentStickers#8999602d flags:# attached:flags.0?true = Bool; messages.getCommonChats#e40ca104 user_id:InputUser max_id:long limit:int = messages.Chats; -messages.getWebPage#32ca8f91 url:string hash:int = WebPage; +messages.getWebPage#8d9692a3 url:string hash:int = messages.WebPage; messages.toggleDialogPin#a731e257 flags:# pinned:flags.0?true peer:InputDialogPeer = Bool; messages.getPinnedDialogs#d6b94df2 folder_id:int = messages.PeerDialogs; messages.uploadMedia#519bc2b1 peer:InputPeer media:InputMedia = MessageMedia; @@ -1468,4 +1469,8 @@ stories.activateStealthMode#57bbd166 flags:# past:flags.0?true future:flags.1?tr stories.sendReaction#7fd736b2 flags:# add_to_recent:flags.0?true peer:InputPeer story_id:int reaction:Reaction = Updates; stories.getPeerStories#2c4ada50 peer:InputPeer = stories.PeerStories; stories.getPeerMaxIDs#535983c3 id:Vector = Vector; -stories.togglePeerStoriesHidden#bd0415c4 peer:InputPeer hidden:Bool = Bool;`; \ No newline at end of file +stories.togglePeerStoriesHidden#bd0415c4 peer:InputPeer hidden:Bool = Bool; +stories.getBoostsStatus#4c449472 peer:InputPeer = stories.BoostsStatus; +stories.getBoostersList#337ef980 peer:InputPeer offset:string limit:int = stories.BoostersList; +stories.canApplyBoost#db05c1bd peer:InputPeer = stories.CanApplyBoostResult; +stories.applyBoost#f29d7c2b peer:InputPeer = Bool;`; diff --git a/src/lib/gramjs/tl/static/api.json b/src/lib/gramjs/tl/static/api.json index f528dde15..3ff857756 100644 --- a/src/lib/gramjs/tl/static/api.json +++ b/src/lib/gramjs/tl/static/api.json @@ -314,5 +314,9 @@ "stories.sendReaction", "stories.getPeerMaxIDs", "stories.togglePeerStoriesHidden", - "stories.getPeerStories" + "stories.getPeerStories", + "stories.getBoostsStatus", + "stories.getBoostersList", + "stories.canApplyBoost", + "stories.applyBoost" ] diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss index 1fa995831..e5c4a04e8 100644 --- a/src/styles/_variables.scss +++ b/src/styles/_variables.scss @@ -288,6 +288,8 @@ $color-message-story-mention-to: #74bcff; --drag-target-border: url("data:image/svg+xml,%3csvg width='100%25' height='100%25' xmlns='http://www.w3.org/2000/svg'%3e%3crect width='100%25' height='100%25' fill='none' rx='8' ry='8' stroke='%23DDDFE0' stroke-width='4' stroke-dasharray='9.1%2c 10.5' stroke-dashoffset='3' stroke-linecap='round'/%3e%3c/svg%3e"); --drag-target-border-hovered: url("data:image/svg+xml,%3csvg width='100%25' height='100%25' xmlns='http://www.w3.org/2000/svg'%3e%3crect width='100%25' height='100%25' fill='none' rx='8' ry='8' stroke='%2363A2E3' stroke-width='4' stroke-dasharray='9.1%2c 10.5' stroke-dashoffset='3' stroke-linecap='round'/%3e%3c/svg%3e"); + --premium-gradient: linear-gradient(84.4deg, #6C93FF -4.85%, #976FFF 51.72%, #DF69D1 110.7%); + --layer-blackout-opacity: 0.3; --layer-transition: 300ms cubic-bezier(0.33, 1, 0.68, 1); diff --git a/src/styles/icons.scss b/src/styles/icons.scss index 0386ae6eb..456b0e0f9 100644 --- a/src/styles/icons.scss +++ b/src/styles/icons.scss @@ -3,8 +3,8 @@ $icons-font: "icons"; @font-face { font-family: $icons-font; - src: url("./icons.woff2?2e8e2fec4b27141c4d298083615a0665") format("woff2"), -url("./icons.woff?2e8e2fec4b27141c4d298083615a0665") format("woff"); + src: url("./icons.woff2?aa9c231863df4bab22759fc9f141c077") format("woff2"), +url("./icons.woff?aa9c231863df4bab22759fc9f141c077") format("woff"); font-weight: normal; font-style: normal; font-display: block; @@ -58,200 +58,202 @@ $icons-map: ( "avatar-deleted-account": "\f115", "avatar-saved-messages": "\f116", "bold": "\f117", - "bot-command": "\f118", - "bot-commands-filled": "\f119", - "bots": "\f11a", - "bug": "\f11b", - "calendar-filter": "\f11c", - "calendar": "\f11d", - "camera-add": "\f11e", - "camera": "\f11f", - "car": "\f120", - "card": "\f121", - "channel-filled": "\f122", - "channel": "\f123", - "channelviews": "\f124", - "chat-badge": "\f125", - "chats-badge": "\f126", - "check": "\f127", - "close-circle": "\f128", - "close-topic": "\f129", - "close": "\f12a", - "cloud-download": "\f12b", - "collapse": "\f12c", - "colorize": "\f12d", - "comments-sticker": "\f12e", - "comments": "\f12f", - "copy-media": "\f130", - "copy": "\f131", - "darkmode": "\f132", - "data": "\f133", - "delete-filled": "\f134", - "delete-left": "\f135", - "delete-user": "\f136", - "delete": "\f137", - "document": "\f138", - "double-badge": "\f139", - "down": "\f13a", - "download": "\f13b", - "eats": "\f13c", - "edit": "\f13d", - "email": "\f13e", - "enter": "\f13f", - "expand": "\f140", - "eye-closed-outline": "\f141", - "eye-closed": "\f142", - "eye-outline": "\f143", - "eye": "\f144", - "favorite-filled": "\f145", - "favorite": "\f146", - "file-badge": "\f147", - "flag": "\f148", - "folder-badge": "\f149", - "folder": "\f14a", - "fontsize": "\f14b", - "forums": "\f14c", - "forward": "\f14d", - "fullscreen": "\f14e", - "gifs": "\f14f", - "gift": "\f150", - "group-filled": "\f151", - "group": "\f152", - "grouped-disable": "\f153", - "grouped": "\f154", - "hand-stop": "\f155", - "hashtag": "\f156", - "heart-outline": "\f157", - "heart": "\f158", - "help": "\f159", - "info-filled": "\f15a", - "info": "\f15b", - "install": "\f15c", - "italic": "\f15d", - "key": "\f15e", - "keyboard": "\f15f", - "lamp": "\f160", - "language": "\f161", - "large-pause": "\f162", - "large-play": "\f163", - "link-badge": "\f164", - "link-broken": "\f165", - "link": "\f166", - "location": "\f167", - "lock-badge": "\f168", - "lock": "\f169", - "logout": "\f16a", - "loop": "\f16b", - "mention": "\f16c", - "message-failed": "\f16d", - "message-pending": "\f16e", - "message-read": "\f16f", - "message-succeeded": "\f170", - "message": "\f171", - "microphone-alt": "\f172", - "microphone": "\f173", - "monospace": "\f174", - "more-circle": "\f175", - "more": "\f176", - "mute": "\f177", - "muted": "\f178", - "new-chat-filled": "\f179", - "next": "\f17a", - "noise-suppression": "\f17b", - "non-contacts": "\f17c", - "open-in-new-tab": "\f17d", - "password-off": "\f17e", - "pause": "\f17f", - "permissions": "\f180", - "phone-discard-outline": "\f181", - "phone-discard": "\f182", - "phone": "\f183", - "photo": "\f184", - "pin-badge": "\f185", - "pin-list": "\f186", - "pin": "\f187", - "pinned-chat": "\f188", - "pinned-message": "\f189", - "pip": "\f18a", - "play-story": "\f18b", - "play": "\f18c", - "poll": "\f18d", - "premium": "\f18e", - "previous": "\f18f", - "privacy-policy": "\f190", - "readchats": "\f191", - "recent": "\f192", - "reload": "\f193", - "remove": "\f194", - "reopen-topic": "\f195", - "replace": "\f196", - "replies": "\f197", - "reply-filled": "\f198", - "reply": "\f199", - "revote": "\f19a", - "save-story": "\f19b", - "saved-messages": "\f19c", - "schedule": "\f19d", - "search": "\f19e", - "select": "\f19f", - "send-outline": "\f1a0", - "send": "\f1a1", - "settings-filled": "\f1a2", - "settings": "\f1a3", - "share-filled": "\f1a4", - "share-screen-outlined": "\f1a5", - "share-screen-stop": "\f1a6", - "share-screen": "\f1a7", - "sidebar": "\f1a8", - "skip-next": "\f1a9", - "skip-previous": "\f1aa", - "smallscreen": "\f1ab", - "smile": "\f1ac", - "sort": "\f1ad", - "speaker-muted-story": "\f1ae", - "speaker-outline": "\f1af", - "speaker-story": "\f1b0", - "speaker": "\f1b1", - "spoiler-disable": "\f1b2", - "spoiler": "\f1b3", - "sport": "\f1b4", - "stats": "\f1b5", - "stealth-future": "\f1b6", - "stealth-past": "\f1b7", - "stickers": "\f1b8", - "stop-raising-hand": "\f1b9", - "stop": "\f1ba", - "story-caption": "\f1bb", - "story-expired": "\f1bc", - "story-priority": "\f1bd", - "story-reply": "\f1be", - "strikethrough": "\f1bf", - "timer": "\f1c0", - "transcribe": "\f1c1", - "truck": "\f1c2", - "unarchive": "\f1c3", - "underlined": "\f1c4", - "unlock-badge": "\f1c5", - "unlock": "\f1c6", - "unmute": "\f1c7", - "unpin": "\f1c8", - "unread": "\f1c9", - "up": "\f1ca", - "user-filled": "\f1cb", - "user-online": "\f1cc", - "user": "\f1cd", - "video-outlined": "\f1ce", - "video-stop": "\f1cf", - "video": "\f1d0", - "voice-chat": "\f1d1", - "volume-1": "\f1d2", - "volume-2": "\f1d3", - "volume-3": "\f1d4", - "web": "\f1d5", - "webapp": "\f1d6", - "word-wrap": "\f1d7", - "zoom-in": "\f1d8", - "zoom-out": "\f1d9", + "boost": "\f118", + "boostcircle": "\f119", + "bot-command": "\f11a", + "bot-commands-filled": "\f11b", + "bots": "\f11c", + "bug": "\f11d", + "calendar-filter": "\f11e", + "calendar": "\f11f", + "camera-add": "\f120", + "camera": "\f121", + "car": "\f122", + "card": "\f123", + "channel-filled": "\f124", + "channel": "\f125", + "channelviews": "\f126", + "chat-badge": "\f127", + "chats-badge": "\f128", + "check": "\f129", + "close-circle": "\f12a", + "close-topic": "\f12b", + "close": "\f12c", + "cloud-download": "\f12d", + "collapse": "\f12e", + "colorize": "\f12f", + "comments-sticker": "\f130", + "comments": "\f131", + "copy-media": "\f132", + "copy": "\f133", + "darkmode": "\f134", + "data": "\f135", + "delete-filled": "\f136", + "delete-left": "\f137", + "delete-user": "\f138", + "delete": "\f139", + "document": "\f13a", + "double-badge": "\f13b", + "down": "\f13c", + "download": "\f13d", + "eats": "\f13e", + "edit": "\f13f", + "email": "\f140", + "enter": "\f141", + "expand": "\f142", + "eye-closed-outline": "\f143", + "eye-closed": "\f144", + "eye-outline": "\f145", + "eye": "\f146", + "favorite-filled": "\f147", + "favorite": "\f148", + "file-badge": "\f149", + "flag": "\f14a", + "folder-badge": "\f14b", + "folder": "\f14c", + "fontsize": "\f14d", + "forums": "\f14e", + "forward": "\f14f", + "fullscreen": "\f150", + "gifs": "\f151", + "gift": "\f152", + "group-filled": "\f153", + "group": "\f154", + "grouped-disable": "\f155", + "grouped": "\f156", + "hand-stop": "\f157", + "hashtag": "\f158", + "heart-outline": "\f159", + "heart": "\f15a", + "help": "\f15b", + "info-filled": "\f15c", + "info": "\f15d", + "install": "\f15e", + "italic": "\f15f", + "key": "\f160", + "keyboard": "\f161", + "lamp": "\f162", + "language": "\f163", + "large-pause": "\f164", + "large-play": "\f165", + "link-badge": "\f166", + "link-broken": "\f167", + "link": "\f168", + "location": "\f169", + "lock-badge": "\f16a", + "lock": "\f16b", + "logout": "\f16c", + "loop": "\f16d", + "mention": "\f16e", + "message-failed": "\f16f", + "message-pending": "\f170", + "message-read": "\f171", + "message-succeeded": "\f172", + "message": "\f173", + "microphone-alt": "\f174", + "microphone": "\f175", + "monospace": "\f176", + "more-circle": "\f177", + "more": "\f178", + "mute": "\f179", + "muted": "\f17a", + "new-chat-filled": "\f17b", + "next": "\f17c", + "noise-suppression": "\f17d", + "non-contacts": "\f17e", + "open-in-new-tab": "\f17f", + "password-off": "\f180", + "pause": "\f181", + "permissions": "\f182", + "phone-discard-outline": "\f183", + "phone-discard": "\f184", + "phone": "\f185", + "photo": "\f186", + "pin-badge": "\f187", + "pin-list": "\f188", + "pin": "\f189", + "pinned-chat": "\f18a", + "pinned-message": "\f18b", + "pip": "\f18c", + "play-story": "\f18d", + "play": "\f18e", + "poll": "\f18f", + "premium": "\f190", + "previous": "\f191", + "privacy-policy": "\f192", + "readchats": "\f193", + "recent": "\f194", + "reload": "\f195", + "remove": "\f196", + "reopen-topic": "\f197", + "replace": "\f198", + "replies": "\f199", + "reply-filled": "\f19a", + "reply": "\f19b", + "revote": "\f19c", + "save-story": "\f19d", + "saved-messages": "\f19e", + "schedule": "\f19f", + "search": "\f1a0", + "select": "\f1a1", + "send-outline": "\f1a2", + "send": "\f1a3", + "settings-filled": "\f1a4", + "settings": "\f1a5", + "share-filled": "\f1a6", + "share-screen-outlined": "\f1a7", + "share-screen-stop": "\f1a8", + "share-screen": "\f1a9", + "sidebar": "\f1aa", + "skip-next": "\f1ab", + "skip-previous": "\f1ac", + "smallscreen": "\f1ad", + "smile": "\f1ae", + "sort": "\f1af", + "speaker-muted-story": "\f1b0", + "speaker-outline": "\f1b1", + "speaker-story": "\f1b2", + "speaker": "\f1b3", + "spoiler-disable": "\f1b4", + "spoiler": "\f1b5", + "sport": "\f1b6", + "stats": "\f1b7", + "stealth-future": "\f1b8", + "stealth-past": "\f1b9", + "stickers": "\f1ba", + "stop-raising-hand": "\f1bb", + "stop": "\f1bc", + "story-caption": "\f1bd", + "story-expired": "\f1be", + "story-priority": "\f1bf", + "story-reply": "\f1c0", + "strikethrough": "\f1c1", + "timer": "\f1c2", + "transcribe": "\f1c3", + "truck": "\f1c4", + "unarchive": "\f1c5", + "underlined": "\f1c6", + "unlock-badge": "\f1c7", + "unlock": "\f1c8", + "unmute": "\f1c9", + "unpin": "\f1ca", + "unread": "\f1cb", + "up": "\f1cc", + "user-filled": "\f1cd", + "user-online": "\f1ce", + "user": "\f1cf", + "video-outlined": "\f1d0", + "video-stop": "\f1d1", + "video": "\f1d2", + "voice-chat": "\f1d3", + "volume-1": "\f1d4", + "volume-2": "\f1d5", + "volume-3": "\f1d6", + "web": "\f1d7", + "webapp": "\f1d8", + "word-wrap": "\f1d9", + "zoom-in": "\f1da", + "zoom-out": "\f1db", ); .icon-active-sessions::before { @@ -323,6 +325,12 @@ $icons-map: ( .icon-bold::before { content: map.get($icons-map, "bold"); } +.icon-boost::before { + content: map.get($icons-map, "boost"); +} +.icon-boostcircle::before { + content: map.get($icons-map, "boostcircle"); +} .icon-bot-command::before { content: map.get($icons-map, "bot-command"); } diff --git a/src/styles/icons.woff b/src/styles/icons.woff index ee3396447..c0d7eefc6 100644 Binary files a/src/styles/icons.woff and b/src/styles/icons.woff differ diff --git a/src/styles/icons.woff2 b/src/styles/icons.woff2 index 76cbad0bf..93587410a 100644 Binary files a/src/styles/icons.woff2 and b/src/styles/icons.woff2 differ diff --git a/src/types/icons/font.ts b/src/types/icons/font.ts index 55b6f4ba1..affc52798 100644 --- a/src/types/icons/font.ts +++ b/src/types/icons/font.ts @@ -22,6 +22,8 @@ export type FontIconName = | 'avatar-deleted-account' | 'avatar-saved-messages' | 'bold' + | 'boost' + | 'boostcircle' | 'bot-command' | 'bot-commands-filled' | 'bots' diff --git a/src/util/dateFormat.ts b/src/util/dateFormat.ts index a6749856b..2ad7f67bd 100644 --- a/src/util/dateFormat.ts +++ b/src/util/dateFormat.ts @@ -363,6 +363,31 @@ export function formatDateAtTime( return lang('formatDateAtTime', [formattedDate, time]); } +export function formatDateInFuture( + lang: LangFn, + currentTime: number, + datetime: number, +) { + const diff = Math.ceil(datetime - currentTime); + if (diff < 0) { + return lang('RightNow'); + } + + if (diff < 60) { + return lang('Seconds', diff); + } + + if (diff < 60 * 60) { + return lang('Minutes', Math.ceil(diff / 60)); + } + + if (diff < 60 * 60 * 24) { + return lang('Hours', Math.ceil(diff / (60 * 60))); + } + + return lang('Days', Math.ceil(diff / (60 * 60 * 24))); +} + function isValidDate(day: number, month: number, year = 2021): boolean { if (month > (MAX_MONTH_IN_YEAR - 1) || day > MAX_DAY_IN_MONTH) { return false; diff --git a/src/util/deeplink.ts b/src/util/deeplink.ts index bbf37a3dd..9b8e8a433 100644 --- a/src/util/deeplink.ts +++ b/src/util/deeplink.ts @@ -7,7 +7,7 @@ import { IS_SAFARI } from './windowEnvironment'; type DeepLinkMethod = 'resolve' | 'login' | 'passport' | 'settings' | 'join' | 'addstickers' | 'addemoji' | 'setlanguage' | 'addtheme' | 'confirmphone' | 'socks' | 'proxy' | 'privatepost' | 'bg' | 'share' | 'msg' | 'msg_url' | -'invoice' | 'addlist'; +'invoice' | 'addlist' | 'boost'; export const processDeepLink = (url: string) => { const { @@ -28,6 +28,7 @@ export const processDeepLink = (url: string) => { openChatWithDraft, checkChatlistInvite, openStoryViewerByUsername, + processBoostParameters, } = getActions(); // Safari thinks the path in tg://path links is hostname for some reason @@ -43,6 +44,7 @@ export const processDeepLink = (url: string) => { const hasStartAttach = params.hasOwnProperty('startattach'); const hasStartApp = params.hasOwnProperty('startapp'); + const hasBoost = params.hasOwnProperty('boost'); const choose = parseChooseParameter(params.choose); const threadId = Number(thread) || Number(topic) || undefined; @@ -64,6 +66,8 @@ export const processDeepLink = (url: string) => { username: domain, inviteHash: voicechat || livestream, }); + } else if (hasBoost) { + processBoostParameters({ usernameOrId: domain }); } else if (phone) { openChatByPhoneNumber({ phoneNumber: phone, startAttach: startattach, attach }); } else if (story) { @@ -87,6 +91,13 @@ export const processDeepLink = (url: string) => { post, channel, } = params; + const hasBoost = params.hasOwnProperty('boost'); + + if (hasBoost) { + processBoostParameters({ usernameOrId: channel, isPrivate: true }); + return; + } + focusMessage({ chatId: `-${channel}`, messageId: Number(post), @@ -138,6 +149,14 @@ export const processDeepLink = (url: string) => { openInvoice({ slug }); break; } + + case 'boost': { + const { channel, domain } = params; + const isPrivate = Boolean(channel); + + processBoostParameters({ usernameOrId: channel || domain, isPrivate }); + break; + } default: // Unsupported deeplink