diff --git a/CLAUDE.md b/CLAUDE.md index 440df53d3..68ab89c9a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,6 +51,16 @@ You are an expert in TypeScript, JavaScript, HTML, SCSS and Teact with deep expe style={{ transform: `translateX(${value}%)` }} style={{ '--custom-prop': value } as React.CSSProperties} ``` + - **IMPORTANT: Font weights in CSS** - Always use existing CSS variables for font-weight. Never use numeric values or custom values. + ```scss + // ✅ CORRECT + font-weight: var(--font-weight-medium); + font-weight: var(--font-weight-bold); + + // ❌ WRONG + font-weight: 600; + font-weight: bold; + ``` - **Localization & Text Rules:** - **ALWAYS** use `lang()` for all text content - never hardcode strings. diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index 6ca778ce3..b4f21b072 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -22,6 +22,7 @@ import type { ApiPreparedInlineMessage, ApiQuickReply, ApiReplyInfo, + ApiSearchPostsFlood, ApiSponsoredMessage, ApiSticker, ApiStory, @@ -820,3 +821,17 @@ export function buildPreparedInlineMessage( cacheTime: result.cacheTime, }; } + +export function buildApiSearchPostsFlood( + searchFlood: GramJs.SearchPostsFlood, + query?: string, +): ApiSearchPostsFlood { + return { + query, + queryIsFree: searchFlood.queryIsFree, + totalDaily: searchFlood.totalDaily, + remains: searchFlood.remains, + waitTill: searchFlood.waitTill, + starsAmount: searchFlood.starsAmount.toJSNumber(), + }; +} diff --git a/src/api/gramjs/apiBuilders/payments.ts b/src/api/gramjs/apiBuilders/payments.ts index 85f42abe6..9812162ae 100644 --- a/src/api/gramjs/apiBuilders/payments.ts +++ b/src/api/gramjs/apiBuilders/payments.ts @@ -549,7 +549,7 @@ export function buildApiStarsTransaction(transaction: GramJs.StarsTransaction): const { date, id, peer, amount, description, photo, title, refund, extendedMedia, failed, msgId, pending, gift, reaction, subscriptionPeriod, stargift, giveawayPostId, starrefCommissionPermille, stargiftUpgrade, paidMessages, - stargiftResale, + stargiftResale, postsSearch, } = transaction; if (photo) { @@ -588,6 +588,7 @@ export function buildApiStarsTransaction(transaction: GramJs.StarsTransaction): isGiftUpgrade: stargiftUpgrade, isGiftResale: stargiftResale, paidMessages, + isPostsSearch: postsSearch, }; } diff --git a/src/api/gramjs/methods/messages.ts b/src/api/gramjs/methods/messages.ts index 76a29de4c..942f814f5 100644 --- a/src/api/gramjs/methods/messages.ts +++ b/src/api/gramjs/methods/messages.ts @@ -24,6 +24,7 @@ import type { ApiPeer, ApiPoll, ApiReaction, + ApiSearchPostsFlood, ApiSendMessageAction, ApiTodoItem, ApiUser, @@ -66,6 +67,7 @@ import { buildApiMessage, buildApiQuickReply, buildApiReportResult, + buildApiSearchPostsFlood, buildApiSponsoredMessage, buildApiThreadInfo, buildLocalForwardedMessage, @@ -128,6 +130,7 @@ type SearchResults = { nextOffsetRate?: number; nextOffsetPeerId?: string; nextOffsetId?: number; + searchFlood?: ApiSearchPostsFlood; }; export async function fetchMessages({ @@ -1537,6 +1540,16 @@ export async function searchMessagesGlobal({ minDate?: number; maxDate?: number; }): Promise { + if (type === 'publicPosts') { + return searchPublicPosts({ + query, + offsetRate, + offsetPeer, + offsetId, + limit, + }); + } + let filter; switch (type) { case 'media': @@ -1613,22 +1626,32 @@ export async function searchMessagesGlobal({ }; } -export async function searchHashtagPosts({ - hashtag, offsetRate, offsetPeer, offsetId, limit, +export async function searchPublicPosts({ + hashtag, query, offsetRate, offsetPeer, offsetId, limit, }: { - hashtag: string; + hashtag?: string; + query?: string; offsetRate?: number; offsetPeer?: ApiPeer; offsetId?: number; limit?: number; }): Promise { const peer = (offsetPeer && buildInputPeer(offsetPeer.id, offsetPeer.accessHash)) || new GramJs.InputPeerEmpty(); + + const resultFlood = await checkSearchPostsFlood(query); + + if (!resultFlood) { + return undefined; + } + const result = await invokeRequest(new GramJs.channels.SearchPosts({ hashtag, + query, offsetRate: offsetRate ?? DEFAULT_PRIMITIVES.INT, offsetId: offsetId ?? DEFAULT_PRIMITIVES.INT, offsetPeer: peer, limit: limit ?? DEFAULT_PRIMITIVES.INT, + allowPaidStars: BigInt(resultFlood.starsAmount), })); if (!result || result instanceof GramJs.messages.MessagesNotModified) { @@ -1650,6 +1673,10 @@ export async function searchHashtagPosts({ const nextOffsetRate = 'nextRate' in result && result.nextRate ? result.nextRate : undefined; const nextOffsetId = lastMessage?.id; + const searchFlood = result instanceof GramJs.messages.MessagesSlice && result.searchFlood + ? buildApiSearchPostsFlood(result.searchFlood, query) + : undefined; + return { messages, userStatusesById, @@ -1657,9 +1684,20 @@ export async function searchHashtagPosts({ nextOffsetRate, nextOffsetPeerId, nextOffsetId, + searchFlood, }; } +export async function checkSearchPostsFlood(query?: string) { + const result = await invokeRequest(new GramJs.channels.CheckSearchPostsFlood({ query })); + + if (!result) { + return undefined; + } + + return buildApiSearchPostsFlood(result, query); +} + export async function fetchWebPagePreview({ text, }: { diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index df72eb111..ddbbd0776 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -919,7 +919,8 @@ export type ApiTranscription = { }; export type ApiMessageSearchType = 'text' | 'media' | 'documents' | 'links' | 'audio' | 'voice' | 'profilePhoto'; -export type ApiGlobalMessageSearchType = 'text' | 'channels' | 'media' | 'documents' | 'links' | 'audio' | 'voice'; +export type ApiGlobalMessageSearchType = 'text' | + 'channels' | 'media' | 'documents' | 'links' | 'audio' | 'voice' | 'publicPosts'; export type ApiMessageSearchContext = 'all' | 'users' | 'groups' | 'channels'; export type ApiReportReason = 'spam' | 'violence' | 'pornography' | 'childAbuse' @@ -992,6 +993,15 @@ export type ApiPreparedInlineMessage = { cacheTime: number; }; +export type ApiSearchPostsFlood = { + query?: string; + queryIsFree?: boolean; + totalDaily: number; + remains: number; + waitTill?: number; + starsAmount: number; +}; + export const MAIN_THREAD_ID = -1; // `Symbol` can not be transferred from worker diff --git a/src/api/types/stars.ts b/src/api/types/stars.ts index 4055ec8f6..07299692a 100644 --- a/src/api/types/stars.ts +++ b/src/api/types/stars.ts @@ -239,6 +239,7 @@ export interface ApiStarsTransaction { isGiftUpgrade?: true; isGiftResale?: true; paidMessages?: number; + isPostsSearch?: true; } export interface ApiStarsSubscription { diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index c9f38d161..cc722e643 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -1639,6 +1639,7 @@ "SearchTabMusic" = "Music"; "SearchTabVoice" = "Voice"; "SearchTabMessages" = "Messages"; +"SearchTabPublicPosts" = "Posts"; "StarsTransactionsAll" = "All Transactions"; "StarsTransactionsIncoming" = "Incoming"; "StarsTransactionsOutgoing" = "Outgoing"; @@ -1657,6 +1658,7 @@ "ProfileTabSharedGroups" = "Groups"; "ProfileTabSimilarChannels" = "Similar Channels"; "ProfileTabSimilarBots" = "Similar Bots"; +"ProfileTabPublicPosts" = "Public Posts"; "ActionUnsupportedTitle" = "Action not supported yet"; "ActionUnsupportedDescription" = "Please use one of our apps to complete this action."; "LocationPermissionText" = "**{name}** requests access to set your **location**. You will be able to revoke this access in the profile page of **{name}**."; @@ -2171,4 +2173,21 @@ "PriceChanged" = "Price Changed"; "PayNewPrice" = "Pay New Price"; "PriceChangedText" = "The price has already changed from **{originalAmount}** to **{newAmount}**. Do you want to pay the new price?"; +"GlobalSearch" = "Global Search"; +"DescriptionPublicPostsSearch" = "Type a keyword to search for posts from public channels."; +"ButtonSearchPublicPosts" = "Search {query}"; +"RemainingPublicPostsSearch_one" = "{count} free search remaining today."; +"RemainingPublicPostsSearch_other" = "{count} free searches remaining today."; +"PublicPosts" = "Public Posts"; +"PublicPostsLimitReached" = "Limit Reached"; +"HintPublicPostsSearchQuota_one" = "You can make up to {count} search query per day."; +"HintPublicPostsSearchQuota_other" = "You can make up to {count} search queries per day."; +"PublicPostsSearchForStars" = "Search for {stars}"; +"UnlockTimerPublicPostsSearch" = "free search unlocks in {time}"; +"PublicPostsPremiumFeatureDescription" = "Type a keyword to search for posts from public channels."; +"PublicPostsPremiumFeatureSubtitle" = "Global search is a Premium feature."; +"PublicPostsSubscribeToPremium" = "Subscribe to Premium"; +"NotificationPaidExtraSearch" = "{stars} spent on extra search."; +"PostsSearchTransaction" = "Posts Search"; + diff --git a/src/assets/tgs-previews/DuckNothingFound.svg b/src/assets/tgs-previews/DuckNothingFound.svg new file mode 100644 index 000000000..5288391e5 --- /dev/null +++ b/src/assets/tgs-previews/DuckNothingFound.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/tgs-previews/Search.svg b/src/assets/tgs-previews/Search.svg new file mode 100644 index 000000000..8156ca297 --- /dev/null +++ b/src/assets/tgs-previews/Search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/tgs/DuckNothingFound.tgs b/src/assets/tgs/DuckNothingFound.tgs new file mode 100644 index 000000000..62a21279f Binary files /dev/null and b/src/assets/tgs/DuckNothingFound.tgs differ diff --git a/src/assets/tgs/Search.tgs b/src/assets/tgs/Search.tgs new file mode 100644 index 000000000..83f9cd05c Binary files /dev/null and b/src/assets/tgs/Search.tgs differ diff --git a/src/components/common/NothingFound.scss b/src/components/common/NothingFound.scss index f30b26de7..979c54bd9 100644 --- a/src/components/common/NothingFound.scss +++ b/src/components/common/NothingFound.scss @@ -4,10 +4,15 @@ justify-content: center; color: var(--color-text-meta); - &.with-description { + &.with-description, + &.with-sticker { flex-direction: column; } + .sticker { + margin-bottom: 1rem; + } + .AnimatedSticker { margin: 0 auto; } diff --git a/src/components/common/NothingFound.tsx b/src/components/common/NothingFound.tsx index 89261ec41..3eea99b39 100644 --- a/src/components/common/NothingFound.tsx +++ b/src/components/common/NothingFound.tsx @@ -2,26 +2,45 @@ import type { FC } from '../../lib/teact/teact'; import { memo } from '../../lib/teact/teact'; import buildClassName from '../../util/buildClassName'; +import { LOCAL_TGS_PREVIEW_URLS, LOCAL_TGS_URLS } from './helpers/animatedAssets'; import renderText from './helpers/renderText'; import useOldLang from '../../hooks/useOldLang'; import useShowTransitionDeprecated from '../../hooks/useShowTransitionDeprecated'; +import AnimatedIconWithPreview from './AnimatedIconWithPreview'; + import './NothingFound.scss'; interface OwnProps { text?: string; description?: string; + withSticker?: boolean; } const DEFAULT_TEXT = 'Nothing found.'; -const NothingFound: FC = ({ text = DEFAULT_TEXT, description }) => { +const NothingFound: FC = ({ text = DEFAULT_TEXT, description, withSticker }) => { const lang = useOldLang(); const { transitionClassNames } = useShowTransitionDeprecated(true); return ( -
+
+ {withSticker && ( + + )} {text} {description &&

{renderText(lang(description), ['br'])}

}
diff --git a/src/components/common/helpers/animatedAssets.ts b/src/components/common/helpers/animatedAssets.ts index 57870b913..1a81e8c42 100644 --- a/src/components/common/helpers/animatedAssets.ts +++ b/src/components/common/helpers/animatedAssets.ts @@ -9,6 +9,7 @@ import VoiceMini from '../../../assets/tgs/calls/VoiceMini.tgs'; import VoiceMuted from '../../../assets/tgs/calls/VoiceMuted.tgs'; import VoiceOutlined from '../../../assets/tgs/calls/VoiceOutlined.tgs'; import Diamond from '../../../assets/tgs/Diamond.tgs'; +import DuckNothingFound from '../../../assets/tgs/DuckNothingFound.tgs'; import Flame from '../../../assets/tgs/general/Flame.tgs'; import Fragment from '../../../assets/tgs/general/Fragment.tgs'; import Mention from '../../../assets/tgs/general/Mention.tgs'; @@ -22,6 +23,7 @@ import MonkeyPeek from '../../../assets/tgs/monkeys/TwoFactorSetupMonkeyPeek.tgs import MonkeyTracking from '../../../assets/tgs/monkeys/TwoFactorSetupMonkeyTracking.tgs'; import ReadTime from '../../../assets/tgs/ReadTime.tgs'; import Report from '../../../assets/tgs/Report.tgs'; +import Search from '../../../assets/tgs/Search.tgs'; import SearchingDuck from '../../../assets/tgs/SearchingDuck.tgs'; import Congratulations from '../../../assets/tgs/settings/Congratulations.tgs'; import DiscussionGroups from '../../../assets/tgs/settings/DiscussionGroupsDucks.tgs'; @@ -33,6 +35,13 @@ import Lock from '../../../assets/tgs/settings/Lock.tgs'; import StarReaction from '../../../assets/tgs/stars/StarReaction.tgs'; import StarReactionEffect from '../../../assets/tgs/stars/StarReactionEffect.tgs'; import Unlock from '../../../assets/tgs/Unlock.tgs'; +import DuckNothingFoundPreview from '../../../assets/tgs-previews/DuckNothingFound.svg'; +import SearchPreview from '../../../assets/tgs-previews/Search.svg'; + +export const LOCAL_TGS_PREVIEW_URLS = { + DuckNothingFound: DuckNothingFoundPreview, + Search: SearchPreview, +}; export const LOCAL_TGS_URLS = { MonkeyIdle, @@ -70,4 +79,6 @@ export const LOCAL_TGS_URLS = { SearchingDuck, BannedDuck, Diamond, + Search, + DuckNothingFound, }; diff --git a/src/components/left/LeftColumn.tsx b/src/components/left/LeftColumn.tsx index c26ee1f1a..e41f58027 100644 --- a/src/components/left/LeftColumn.tsx +++ b/src/components/left/LeftColumn.tsx @@ -17,6 +17,7 @@ import { IS_APP, IS_FIREFOX, IS_MAC_OS, IS_TOUCH_ENV, LAYERS_ANIMATION_NAME, } from '../../util/browser/windowEnvironment'; import captureEscKeyListener from '../../util/captureEscKeyListener'; +import { debounce } from '../../util/schedulers'; import { captureControlledSwipe } from '../../util/swipeController'; import useFoldersReducer from '../../hooks/reducers/useFoldersReducer'; @@ -110,6 +111,10 @@ function LeftColumn({ const [contactsFilter, setContactsFilter] = useState(''); const [foldersState, foldersDispatch] = useFoldersReducer(); + const debouncedSetGlobalSearchQuery = useMemo(() => debounce((query: string) => { + setGlobalSearchQuery({ query }); + }, 200, false, true), [setGlobalSearchQuery]); + // Used to reset child components in background. const [lastResetTime, setLastResetTime] = useState(0); @@ -375,7 +380,7 @@ function LeftColumn({ openLeftColumnContent({ contentKey: LeftColumnContent.GlobalSearch }); if (query !== searchQuery) { - setGlobalSearchQuery({ query }); + debouncedSetGlobalSearchQuery(query); } }); diff --git a/src/components/left/main/LeftMainHeader.tsx b/src/components/left/main/LeftMainHeader.tsx index a8da9d301..26d437a45 100644 --- a/src/components/left/main/LeftMainHeader.tsx +++ b/src/components/left/main/LeftMainHeader.tsx @@ -114,6 +114,7 @@ const LeftMainHeader: FC = ({ setGlobalSearchChatId, lockScreen, openSettingsScreen, + searchMessagesGlobal, } = getActions(); const oldLang = useOldLang(); @@ -193,6 +194,15 @@ const LeftMainHeader: FC = ({ lockScreen(); }); + const handleSearchEnter = useLastCallback(() => { + if (searchQuery && content === LeftColumnContent.GlobalSearch) { + searchMessagesGlobal({ + type: 'publicPosts', + shouldResetResultsByType: true, + }); + } + }); + const isSearchRelevant = Boolean(globalSearchChatId) || content === LeftColumnContent.GlobalSearch || content === LeftColumnContent.Contacts; @@ -295,6 +305,7 @@ const LeftMainHeader: FC = ({ onReset={onReset} onFocus={handleSearchFocus} onSpinnerClick={connectionStatusPosition === 'minimized' ? toggleConnectionStatus : undefined} + onEnter={handleSearchEnter} > {searchContent} ( return { searchQuery, - isLoading: fetchingStatus ? Boolean(fetchingStatus.chats || fetchingStatus.messages) : false, + isLoading: fetchingStatus ? Boolean(fetchingStatus.chats + || fetchingStatus.messages || fetchingStatus.publicPosts) : false, globalSearchChatId: chatId, searchDate: minDate, theme: selectTheme(global), diff --git a/src/components/left/search/AudioResults.tsx b/src/components/left/search/AudioResults.tsx index aa691a466..9b630ca3b 100644 --- a/src/components/left/search/AudioResults.tsx +++ b/src/components/left/search/AudioResults.tsx @@ -134,6 +134,7 @@ const AudioResults: FC = ({ {!canRenderContents && } {canRenderContents && (!foundIds || foundIds.length === 0) && ( diff --git a/src/components/left/search/BotAppResults.tsx b/src/components/left/search/BotAppResults.tsx index 49c181463..b93f40758 100644 --- a/src/components/left/search/BotAppResults.tsx +++ b/src/components/left/search/BotAppResults.tsx @@ -89,6 +89,7 @@ const BotAppResults: FC = ({ {!canRenderContents && } {canRenderContents && !filteredFoundIds?.length && ( diff --git a/src/components/left/search/ChatMessageResults.tsx b/src/components/left/search/ChatMessageResults.tsx index 7f694dac8..f0f5d5e9c 100644 --- a/src/components/left/search/ChatMessageResults.tsx +++ b/src/components/left/search/ChatMessageResults.tsx @@ -132,6 +132,7 @@ const ChatMessageResults: FC = ({ )} {nothingFound && ( diff --git a/src/components/left/search/ChatResults.tsx b/src/components/left/search/ChatResults.tsx index 2b367c195..33d90c36a 100644 --- a/src/components/left/search/ChatResults.tsx +++ b/src/components/left/search/ChatResults.tsx @@ -374,6 +374,7 @@ const ChatResults: FC = ({ )} {nothingFound && ( diff --git a/src/components/left/search/FileResults.tsx b/src/components/left/search/FileResults.tsx index 256ea8b98..2c55f8a89 100644 --- a/src/components/left/search/FileResults.tsx +++ b/src/components/left/search/FileResults.tsx @@ -139,6 +139,7 @@ const FileResults: FC = ({ {!canRenderContents && } {canRenderContents && (!foundIds || foundIds.length === 0) && ( diff --git a/src/components/left/search/LeftSearch.tsx b/src/components/left/search/LeftSearch.tsx index d7abb7103..e2fef4d01 100644 --- a/src/components/left/search/LeftSearch.tsx +++ b/src/components/left/search/LeftSearch.tsx @@ -1,6 +1,7 @@ import type { FC } from '../../../lib/teact/teact'; import { memo, + useEffect, useMemo, useRef, useState, @@ -27,6 +28,7 @@ import ChatResults from './ChatResults'; import FileResults from './FileResults'; import LinkResults from './LinkResults'; import MediaResults from './MediaResults'; +import PublicPostsResults from './PublicPostsResults'; import './LeftSearch.scss'; @@ -51,6 +53,7 @@ const TABS: TabInfo[] = [ { type: GlobalSearchContent.ChatList, key: 'SearchTabChats' }, { type: GlobalSearchContent.ChannelList, key: 'SearchTabChannels' }, { type: GlobalSearchContent.BotApps, key: 'SearchTabApps' }, + { type: GlobalSearchContent.PublicPosts, key: 'SearchTabPublicPosts' }, { type: GlobalSearchContent.Media, key: 'SearchTabMedia' }, { type: GlobalSearchContent.Links, key: 'SearchTabLinks' }, { type: GlobalSearchContent.Files, key: 'SearchTabFiles' }, @@ -74,12 +77,19 @@ const LeftSearch: FC = ({ const { setGlobalSearchContent, setGlobalSearchDate, + checkSearchPostsFlood, } = getActions(); const lang = useLang(); const [activeTab, setActiveTab] = useState(currentContent); const dateSearchQuery = useMemo(() => parseDateString(searchQuery), [searchQuery]); + useEffect(() => { + if (isActive) { + checkSearchPostsFlood({}); + } + }, [isActive]); + const tabs = useMemo(() => { const arr = chatId ? CHAT_TABS : TABS; return arr.map((tab) => ({ @@ -166,6 +176,13 @@ const LeftSearch: FC = ({ searchQuery={searchQuery} /> ); + case GlobalSearchContent.PublicPosts: + return ( + + ); default: return undefined; } diff --git a/src/components/left/search/LinkResults.tsx b/src/components/left/search/LinkResults.tsx index 53375904b..f3f27da67 100644 --- a/src/components/left/search/LinkResults.tsx +++ b/src/components/left/search/LinkResults.tsx @@ -132,6 +132,7 @@ const LinkResults: FC = ({ {!canRenderContents && } {canRenderContents && (!foundIds || foundIds.length === 0) && ( diff --git a/src/components/left/search/MediaResults.tsx b/src/components/left/search/MediaResults.tsx index 250630509..11cc0651f 100644 --- a/src/components/left/search/MediaResults.tsx +++ b/src/components/left/search/MediaResults.tsx @@ -133,6 +133,7 @@ const MediaResults: FC = ({ {!canRenderContents && } {canRenderContents && (!foundIds || foundIds.length === 0) && ( diff --git a/src/components/left/search/PublicPostsResults.tsx b/src/components/left/search/PublicPostsResults.tsx new file mode 100644 index 000000000..e6202cb52 --- /dev/null +++ b/src/components/left/search/PublicPostsResults.tsx @@ -0,0 +1,163 @@ +import { memo, useCallback, useMemo } from '../../../lib/teact/teact'; +import { getActions, withGlobal } from '../../../global'; +import { getGlobal } from '../../../global'; + +import type { ApiMessage, ApiSearchPostsFlood } from '../../../api/types'; +import { LoadMoreDirection } from '../../../types'; + +import { selectTabState } from '../../../global/selectors'; +import { parseSearchResultKey, type SearchResultKey } from '../../../util/keys/searchResultKey'; +import { MEMO_EMPTY_ARRAY } from '../../../util/memo'; +import { throttle } from '../../../util/schedulers'; +import { renderMessageSummary } from '../../common/helpers/renderMessageText'; + +import useLang from '../../../hooks/useLang'; +import useLastCallback from '../../../hooks/useLastCallback'; +import useOldLang from '../../../hooks/useOldLang'; + +import NothingFound from '../../common/NothingFound'; +import InfiniteScroll from '../../ui/InfiniteScroll'; +import Transition from '../../ui/Transition'; +import ChatMessage from './ChatMessage'; +import PublicPostsSearchLauncher from './PublicPostsSearchLauncher.tsx'; + +export type OwnProps = { + searchQuery?: string; +}; + +type StateProps = { + foundIds?: SearchResultKey[]; + globalMessagesByChatId?: Record }>; + searchFlood?: ApiSearchPostsFlood; + shouldShowSearchLauncher?: boolean; + isNothingFound?: boolean; +}; + +const runThrottled = throttle((cb) => cb(), 500, true); + +const PublicPostsResults = ({ + searchQuery, + foundIds, + globalMessagesByChatId, + searchFlood, + shouldShowSearchLauncher, + isNothingFound, +}: OwnProps & StateProps) => { + const { searchMessagesGlobal } = getActions(); + + const lang = useLang(); + const oldLang = useOldLang(); + + const handleSearch = useLastCallback(() => { + if (!searchQuery) return; + + searchMessagesGlobal({ + type: 'publicPosts', + shouldResetResultsByType: true, + }); + }); + + const handleLoadMore = useCallback(({ direction }: { direction: LoadMoreDirection }) => { + if (direction === LoadMoreDirection.Backwards) { + runThrottled(() => { + searchMessagesGlobal({ + type: 'publicPosts', + }); + }); + } + }, []); + + const foundMessages = useMemo(() => { + if (!foundIds || foundIds.length === 0) { + return MEMO_EMPTY_ARRAY; + } + + return foundIds + .map((id) => { + const [chatId, messageId] = parseSearchResultKey(id); + return globalMessagesByChatId?.[chatId]?.byId[messageId]; + }) + .filter(Boolean); + }, [foundIds, globalMessagesByChatId]); + + function renderFoundMessage(message: ApiMessage) { + const chatsById = getGlobal().chats.byId; + + const text = renderMessageSummary(oldLang, message); + const chat = chatsById[message.chatId]; + + if (!text || !chat) { + return undefined; + } + + return ( + + ); + } + + return ( + + {shouldShowSearchLauncher ? ( + + ) : ( +
+ + {isNothingFound && ( + + )} + {Boolean(foundMessages.length) && ( +
+

+ {lang('PublicPosts')} +

+ {foundMessages.map(renderFoundMessage)} +
+ )} +
+
+ )} +
+ ); +}; + +export default memo(withGlobal( + (global): StateProps => { + const { messages: { byChatId: globalMessagesByChatId } } = global; + const { resultsByType, searchFlood } = selectTabState(global).globalSearch; + + const publicPostsResult = resultsByType?.publicPosts; + const { foundIds } = publicPostsResult || {}; + const shouldShowSearchLauncher = !publicPostsResult; + const isNothingFound = publicPostsResult && !foundIds?.length; + + return { + foundIds, + globalMessagesByChatId, + searchFlood, + shouldShowSearchLauncher, + isNothingFound, + }; + }, +)(PublicPostsResults)); diff --git a/src/components/left/search/PublicPostsSearchLauncher.module.scss b/src/components/left/search/PublicPostsSearchLauncher.module.scss new file mode 100644 index 000000000..54db58b01 --- /dev/null +++ b/src/components/left/search/PublicPostsSearchLauncher.module.scss @@ -0,0 +1,129 @@ +.container { + display: flex; + flex-direction: column; + align-items: center; + + height: 100%; + padding: 2rem 1.5rem; +} + +.content { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + max-width: 20rem; + + text-align: center; +} + +.searchButtonContent { + display: flex; + align-items: center; + justify-content: center; + height: 100%; +} + +.sticker { + margin-bottom: 1.5rem; +} + +.title { + margin-bottom: 0.75rem; + font-size: 1.25rem; + font-weight: 500; + color: var(--color-text); +} + +.description { + margin-bottom: 1.5rem; + font-size: 0.875rem; + line-height: 1.3125rem; + color: var(--color-text-secondary); +} + +.searchButton { + overflow: hidden; + + width: 100%; + min-width: 0; + margin-bottom: 1rem; + + text-overflow: ellipsis; + white-space: nowrap; +} + +.remainingSearches { + font-size: 0.8125rem; + color: var(--color-text-secondary); +} + +.searchIcon { + margin-right: 0.25rem; +} + +.searchQuery { + overflow: hidden; + display: inline-block; + + max-width: 10rem; + margin-inline-start: 0.25rem; + + color: var(--color-white); + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: bottom; + + opacity: 0.75; +} + +.limitTitle { + margin-bottom: 0.75rem; + font-size: 1.5rem; + font-weight: var(--font-weight-medium); + color: var(--color-text); +} + +.limitDescription { + margin-bottom: 1.5rem; + font-size: 0.9375rem; + line-height: 1.375rem; + color: var(--color-text-secondary); +} + +.paidSearchButton { + width: 100%; + margin-bottom: 1rem; + font-weight: var(--font-weight-medium); +} + +.freeSearchUnlock { + font-size: 0.875rem; + color: var(--color-text-secondary); +} + +.premiumTitle { + margin-bottom: 0.5rem; + font-size: 1.25rem; + font-weight: var(--font-weight-medium); + color: var(--color-text); +} + +.premiumDescription { + margin-bottom: 1.5rem; + font-size: 0.875rem; + line-height: 1.4; + color: var(--color-text-secondary); +} + +.subscribePremiumButton { + width: 100%; + margin-bottom: 1rem; +} + +.premiumSubtitle { + font-size: 0.8125rem; + color: var(--color-text-secondary); + text-align: center; +} diff --git a/src/components/left/search/PublicPostsSearchLauncher.tsx b/src/components/left/search/PublicPostsSearchLauncher.tsx new file mode 100644 index 000000000..5da01b803 --- /dev/null +++ b/src/components/left/search/PublicPostsSearchLauncher.tsx @@ -0,0 +1,237 @@ +import { memo, useEffect } from '../../../lib/teact/teact'; +import { getActions, withGlobal } from '../../../global'; + +import type { ApiSearchPostsFlood } from '../../../api/types'; + +import { + PUBLIC_POSTS_SEARCH_DEFAULT_STARS_AMOUNT, + PUBLIC_POSTS_SEARCH_DEFAULT_TOTAL_DAILY, +} from '../../../config'; +import { selectIsCurrentUserPremium } from '../../../global/selectors'; +import { formatStarsAsIcon } from '../../../util/localization/format'; +import { getServerTime } from '../../../util/serverTime'; +import { LOCAL_TGS_PREVIEW_URLS, LOCAL_TGS_URLS } from '../../common/helpers/animatedAssets'; + +import { useTransitionActiveKey } from '../../../hooks/animations/useTransitionActiveKey'; +import useLang from '../../../hooks/useLang'; +import useLastCallback from '../../../hooks/useLastCallback'; + +import AnimatedIconWithPreview from '../../common/AnimatedIconWithPreview'; +import Icon from '../../common/icons/Icon'; +import Button from '../../ui/Button'; +import TextTimer from '../../ui/TextTimer'; +import Transition from '../../ui/Transition'; + +import styles from './PublicPostsSearchLauncher.module.scss'; + +type OwnProps = { + searchQuery?: string; + searchFlood?: ApiSearchPostsFlood; + onSearch: () => void; +}; + +type StateProps = { + isCurrentUserPremium?: boolean; + starsBalance: number; +}; + +const WAIT_DELAY = 2; + +const PublicPostsSearchLauncher = ({ + searchQuery, + searchFlood, + onSearch, + isCurrentUserPremium, + starsBalance, +}: OwnProps & StateProps) => { + const lang = useLang(); + const queryIsFree = searchFlood?.queryIsFree; + const queryFromFlood = searchFlood?.query; + + const searchButtonActiveKey = useTransitionActiveKey([searchQuery?.slice(0, 18).trimEnd()]); + + const handleSearchClick = useLastCallback(() => { + onSearch(); + }); + + useEffect(() => { + if (queryIsFree && searchQuery && queryFromFlood === searchQuery) { + onSearch(); + } + }, [queryIsFree, searchQuery, queryFromFlood, onSearch]); + + const handlePaidSearchClick = useLastCallback(() => { + const starsAmount = searchFlood?.starsAmount || 0; + const currentBalance = starsBalance; + + if (currentBalance < starsAmount) { + openStarsBalanceModal({ + topup: { + balanceNeeded: starsAmount, + }, + }); + } else { + onSearch(); + } + }); + + const { + checkSearchPostsFlood, + openPremiumModal, + openStarsBalanceModal, + } = getActions(); + + const onCheckFlood = useLastCallback(() => { + checkSearchPostsFlood({}); + }); + + const handleSubscribePremiumClick = useLastCallback(() => { + openPremiumModal(); + }); + + const renderLimitReached = () => { + const waitTill = searchFlood?.waitTill; + const starsAmount = searchFlood?.starsAmount || PUBLIC_POSTS_SEARCH_DEFAULT_STARS_AMOUNT; + const totalDaily = searchFlood?.totalDaily || PUBLIC_POSTS_SEARCH_DEFAULT_TOTAL_DAILY; + + return ( +
+
+ +
+ {lang('PublicPostsLimitReached')} +
+
+ {lang('HintPublicPostsSearchQuota', { count: totalDaily }, { pluralValue: totalDaily })} +
+ + {Boolean(waitTill) && ( +
+ +
+ )} +
+
+ ); + }; + + const renderSearchButton = () => { + const remainingSearches = searchFlood?.remains || 0; + + return ( +
+
+ +
+ {lang('GlobalSearch')} +
+
+ {lang('DescriptionPublicPostsSearch')} +
+ +
+ {lang('RemainingPublicPostsSearch', { count: remainingSearches }, { pluralValue: remainingSearches })} +
+
+
+ ); + }; + + const renderPremiumRequired = () => { + return ( +
+
+
+ {lang('GlobalSearch')} +
+
+ {lang('PublicPostsPremiumFeatureDescription')} +
+ +
+ {lang('PublicPostsPremiumFeatureSubtitle')} +
+
+
+ ); + }; + + if (!isCurrentUserPremium) { + return renderPremiumRequired(); + } + + const serverTime = getServerTime(); + const shouldRenderPaidScreen = searchFlood?.remains === 0 + || (searchFlood?.waitTill && searchFlood.waitTill > serverTime); + + return ( + + {shouldRenderPaidScreen ? renderLimitReached() : renderSearchButton()} + + ); +}; + +export default memo(withGlobal((global): StateProps => ({ + isCurrentUserPremium: selectIsCurrentUserPremium(global), + starsBalance: global.stars?.balance?.amount || 0, +}))(PublicPostsSearchLauncher)); diff --git a/src/components/modals/stars/helpers/transaction.ts b/src/components/modals/stars/helpers/transaction.ts index 8918af192..9774ca519 100644 --- a/src/components/modals/stars/helpers/transaction.ts +++ b/src/components/modals/stars/helpers/transaction.ts @@ -2,7 +2,7 @@ import type { ApiStarsAmount, ApiStarsTransaction, ApiTypeCurrencyAmount } from import type { OldLangFn } from '../../../../hooks/useOldLang'; import { STARS_CURRENCY_CODE, TON_CURRENCY_CODE } from '../../../../config'; -import { buildStarsTransactionCustomPeer } from '../../../../global/helpers/payments'; +import { buildStarsTransactionCustomPeer, shouldUseCustomPeer } from '../../../../global/helpers/payments'; import { type LangFn, } from '../../../../util/localization'; @@ -25,6 +25,9 @@ export function getTransactionTitle(oldLang: OldLangFn, lang: LangFn, transactio ? lang('StarGiftSaleTransaction') : lang('StarGiftPurchaseTransaction'); } + if (transaction.isPostsSearch) { + return lang('PostsSearchTransaction'); + } if (transaction.starRefCommision) { return oldLang('StarTransactionCommission', formatPercent(transaction.starRefCommision)); @@ -45,8 +48,8 @@ export function getTransactionTitle(oldLang: OldLangFn, lang: LangFn, transactio return isNegativeAmount(transaction.amount) ? oldLang('Gift2TransactionSent') : oldLang('Gift2ConvertedTitle'); } - const customPeer = (transaction.peer && transaction.peer.type !== 'peer' - && buildStarsTransactionCustomPeer(transaction.peer)) || undefined; + const customPeer = (transaction.peer && shouldUseCustomPeer(transaction) + && buildStarsTransactionCustomPeer(transaction)) || undefined; if (customPeer) return customPeer.title || oldLang(customPeer.titleKey!); diff --git a/src/components/modals/stars/transaction/StarsTransactionItem.tsx b/src/components/modals/stars/transaction/StarsTransactionItem.tsx index 6b00d4177..567df7cd9 100644 --- a/src/components/modals/stars/transaction/StarsTransactionItem.tsx +++ b/src/components/modals/stars/transaction/StarsTransactionItem.tsx @@ -9,7 +9,9 @@ import type { GlobalState } from '../../../../global/types'; import type { CustomPeer } from '../../../../types'; import { STARS_CURRENCY_CODE, TON_CURRENCY_CODE } from '../../../../config'; -import { buildStarsTransactionCustomPeer, formatStarsTransactionAmount } from '../../../../global/helpers/payments'; +import { buildStarsTransactionCustomPeer, + formatStarsTransactionAmount, + shouldUseCustomPeer } from '../../../../global/helpers/payments'; import { getPeerTitle } from '../../../../global/helpers/peers'; import { selectPeer } from '../../../../global/selectors'; import buildClassName from '../../../../util/buildClassName'; @@ -71,14 +73,11 @@ const StarsTransactionItem = ({ transaction, className }: OwnProps) => { let status: string | undefined; let avatarPeer: ApiPeer | CustomPeer | undefined; - if (transaction.peer.type === 'peer') { + if (!shouldUseCustomPeer(transaction)) { description = peer && getPeerTitle(oldLang, peer); avatarPeer = peer || CUSTOM_PEER_PREMIUM; } else { - const customPeer = buildStarsTransactionCustomPeer( - transaction.peer, - transaction.amount.currency === TON_CURRENCY_CODE, - ); + const customPeer = buildStarsTransactionCustomPeer(transaction); title = customPeer.title || oldLang(customPeer.titleKey!); description = oldLang(customPeer.subtitleKey!); avatarPeer = customPeer; @@ -92,6 +91,11 @@ const StarsTransactionItem = ({ transaction, className }: OwnProps) => { description = lang('GiftUnique', { title: transaction.starGift.title, number: transaction.starGift.number }); } + if (transaction.isPostsSearch) { + title = getTransactionTitle(oldLang, lang, transaction); + description = undefined; + } + if (transaction.photo) { avatarPeer = undefined; } diff --git a/src/components/modals/stars/transaction/StarsTransactionModal.tsx b/src/components/modals/stars/transaction/StarsTransactionModal.tsx index 62fe4cbc3..62b06196a 100644 --- a/src/components/modals/stars/transaction/StarsTransactionModal.tsx +++ b/src/components/modals/stars/transaction/StarsTransactionModal.tsx @@ -14,6 +14,7 @@ import { getMessageLink } from '../../../../global/helpers'; import { buildStarsTransactionCustomPeer, formatStarsTransactionAmount, + shouldUseCustomPeer, } from '../../../../global/helpers/payments'; import { selectCanPlayAnimatedEmojis, @@ -97,8 +98,8 @@ const StarsTransactionModal: FC = ({ const giftAttributes = isUniqueGift ? getGiftAttributes(gift) : undefined; - const customPeer = (transaction.peer && transaction.peer.type !== 'peer' - && buildStarsTransactionCustomPeer(transaction.peer)) || undefined; + const customPeer = (transaction.peer && shouldUseCustomPeer(transaction) + && buildStarsTransactionCustomPeer(transaction)) || undefined; const peerId = transaction.peer?.type === 'peer' ? transaction.peer.id : undefined; const toName = transaction.peer && oldLang(getStarsPeerTitleKey(transaction.peer)); @@ -123,8 +124,8 @@ const StarsTransactionModal: FC = ({ || (isGiftUpgrade && starGift?.type === 'starGiftUnique' ? starGift.title : undefined) || (media ? mediaText : undefined); - const shouldDisplayAvatar = !media && !sticker; - const avatarPeer = !photo ? (peer || customPeer) : undefined; + const shouldDisplayAvatar = !media && !sticker && !transaction.isPostsSearch; + const avatarPeer = !photo ? ((!shouldUseCustomPeer(transaction) && peer) || customPeer) : undefined; const uniqueGiftHeader = isUniqueGift && (
@@ -161,7 +162,7 @@ const StarsTransactionModal: FC = ({ {shouldDisplayAvatar && ( )} - {!sticker && ( + {!sticker && !transaction.isPostsSearch && ( = ({ peerLabel = oldLang('Stars.Transaction.Via'); } - tableData.push([ - peerLabel, - peerId ? { chatId: peerId } : toName || '', - ]); + if (!transaction.isPostsSearch) { + tableData.push([ + peerLabel, + peerId ? { chatId: peerId } : toName || '', + ]); + } if (transaction.starRefCommision && transaction.paidMessages) { tableData.push([ @@ -329,8 +332,8 @@ export default memo(withGlobal( const currencyAmount = modal?.transaction.amount; const starsGiftSticker = modal?.transaction.isGift - && currencyAmount?.currency === STARS_CURRENCY_CODE ? selectGiftStickerForStars(global, currencyAmount?.amount) - : selectGiftStickerForTon(global, currencyAmount?.amount); + ? (currencyAmount?.currency === STARS_CURRENCY_CODE ? selectGiftStickerForStars(global, currencyAmount?.amount) + : selectGiftStickerForTon(global, currencyAmount?.amount)) : undefined; return { peer, diff --git a/src/components/ui/Button.scss b/src/components/ui/Button.scss index e749a46ec..acade3d31 100644 --- a/src/components/ui/Button.scss +++ b/src/components/ui/Button.scss @@ -53,7 +53,7 @@ background-size: cover; outline: none !important; - transition: background-color 0.15s, color 0.15s; + transition: background-color 0.15s, color 0.15s, opacity 0.15s; // @optimization &:active, diff --git a/src/components/ui/SearchInput.tsx b/src/components/ui/SearchInput.tsx index 613475421..1f45b8165 100644 --- a/src/components/ui/SearchInput.tsx +++ b/src/components/ui/SearchInput.tsx @@ -49,6 +49,7 @@ type OwnProps = { onUpClick?: (event: React.MouseEvent) => void; onDownClick?: (event: React.MouseEvent) => void; onSpinnerClick?: NoneToVoidFunction; + onEnter?: NoneToVoidFunction; }; const SearchInput: FC = ({ @@ -80,6 +81,7 @@ const SearchInput: FC = ({ onUpClick, onDownClick, onSpinnerClick, + onEnter, }) => { let inputRef = useRef(); if (ref) { @@ -125,8 +127,22 @@ const SearchInput: FC = ({ } const handleKeyDown = useLastCallback((e: React.KeyboardEvent) => { - if (!resultsItemSelector) return; - if (e.key === 'ArrowDown' || e.key === 'Enter') { + if (e.key === 'Enter') { + if (onEnter) { + e.preventDefault(); + onEnter(); + return; + } + + if (resultsItemSelector) { + const element = document.querySelector(resultsItemSelector) as HTMLElement; + if (element) { + element.focus(); + } + } + } + + if (resultsItemSelector && e.key === 'ArrowDown') { const element = document.querySelector(resultsItemSelector) as HTMLElement; if (element) { element.focus(); diff --git a/src/components/ui/TextTimer.tsx b/src/components/ui/TextTimer.tsx index e1257da4c..157aeb2c2 100644 --- a/src/components/ui/TextTimer.tsx +++ b/src/components/ui/TextTimer.tsx @@ -5,8 +5,11 @@ import { getServerTime } from '../../util/serverTime'; import useInterval from '../../hooks/schedulers/useInterval'; import useForceUpdate from '../../hooks/useForceUpdate'; +import useLang from '../../hooks/useLang'; import useOldLang from '../../hooks/useOldLang'; +import AnimatedCounter from '../common/AnimatedCounter'; + type OwnProps = { langKey: string; endsAt: number; @@ -16,7 +19,8 @@ type OwnProps = { const UPDATE_FREQUENCY = 500; // Sometimes second gets skipped if using 1000 const TextTimer: FC = ({ langKey, endsAt, onEnd }) => { - const lang = useOldLang(); + const lang = useLang(); + const oldLang = useOldLang(); const forceUpdate = useForceUpdate(); const serverTime = getServerTime(); @@ -32,11 +36,33 @@ const TextTimer: FC = ({ langKey, endsAt, onEnd }) => { if (!isActive) return undefined; const timeLeft = endsAt - serverTime; - const formattedTime = formatMediaDuration(timeLeft); + const time = formatMediaDuration(timeLeft); + + const timeParts = time.split(':'); + const timeCounter = ( + + {timeParts.map((part, index) => ( + <> + {index > 0 && ':'} + + + ))} + + ); + + const isTypedKey = langKey === 'UnlockTimerPublicPostsSearch'; + + if (isTypedKey) { + return ( + + {lang(langKey, { time: timeCounter }, { withNodes: true })} + + ); + } return ( - {lang(langKey, formattedTime)} + {oldLang(langKey, time)} ); }; diff --git a/src/config.ts b/src/config.ts index 699a80c85..58b18491d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -110,6 +110,10 @@ export const TODO_ITEMS_LIMIT = 30; export const TODO_TITLE_LENGTH_LIMIT = 32; export const TODO_ITEM_LENGTH_LIMIT = 64; +// Public Posts Search defaults +export const PUBLIC_POSTS_SEARCH_DEFAULT_STARS_AMOUNT = 10; +export const PUBLIC_POSTS_SEARCH_DEFAULT_TOTAL_DAILY = 2; + // Suggested Posts defaults export const STARS_SUGGESTED_POST_AMOUNT_MAX = 100000; export const STARS_SUGGESTED_POST_AMOUNT_MIN = 5; diff --git a/src/global/actions/api/globalSearch.ts b/src/global/actions/api/globalSearch.ts index 75e409b68..824316e52 100644 --- a/src/global/actions/api/globalSearch.ts +++ b/src/global/actions/api/globalSearch.ts @@ -1,5 +1,7 @@ +import { getActions } from '../../../global'; + import type { - ApiChat, ApiGlobalMessageSearchType, ApiMessage, ApiMessageSearchContext, ApiPeer, ApiTopic, + ApiChat, ApiGlobalMessageSearchType, ApiMessage, ApiMessageSearchContext, ApiPeer, ApiSearchPostsFlood, ApiTopic, ApiUserStatus, } from '../../../api/types'; import type { ActionReturnType, GlobalState, TabArgs } from '../../types'; @@ -9,6 +11,8 @@ import { timestampPlusDay } from '../../../util/dates/dateFormat'; import { isDeepLink, tryParseDeepLink } from '../../../util/deepLinkParser'; import { toChannelId } from '../../../util/entities/ids'; import { getCurrentTabId } from '../../../util/establishMultitabRole'; +import { getTranslationFn } from '../../../util/localization'; +import { formatStarsAsText } from '../../../util/localization/format'; import { throttle } from '../../../util/schedulers'; import { callApi } from '../../../api/gramjs'; import { isChatChannel, isChatGroup } from '../../helpers/chats'; @@ -158,6 +162,23 @@ addActionHandler('searchPopularBotApps', async (global, actions, payload): Promi setGlobal(global); }); +addActionHandler('checkSearchPostsFlood', async (global, actions, payload): Promise => { + const { query, tabId = getCurrentTabId() } = payload; + + const result = await callApi('checkSearchPostsFlood', query); + + global = getGlobal(); + if (!result) { + return; + } + + global = updateGlobalSearch(global, { + searchFlood: result, + }, tabId); + + setGlobal(global); +}); + async function searchMessagesGlobal(global: T, params: { query?: string; type: ApiGlobalMessageSearchType; @@ -175,6 +196,12 @@ async function searchMessagesGlobal(global: T, params: { query = '', type, context, offsetRate, offsetId, offsetPeer, peer, maxDate, minDate, shouldResetResultsByType, tabId = getCurrentTabId(), } = params; + + if (type === 'publicPosts') { + global = updateGlobalSearchFetchingStatus(global, { publicPosts: true }, tabId); + setGlobal(global); + } + let result: { messages: ApiMessage[]; userStatusesById?: Record; @@ -184,10 +211,13 @@ async function searchMessagesGlobal(global: T, params: { nextOffsetRate?: number; nextOffsetId?: number; nextOffsetPeerId?: string; + searchFlood?: ApiSearchPostsFlood; } | undefined; let messageLink: ApiMessage | undefined; + const previousSearchFlood = selectTabState(global, tabId).globalSearch.searchFlood; + if (peer) { const inChatResultRequest = callApi('searchMessagesInChat', { peer, @@ -256,7 +286,7 @@ async function searchMessagesGlobal(global: T, params: { } const currentSearchQuery = selectCurrentGlobalSearchQuery(global, tabId); if (!result || (query !== '' && query !== currentSearchQuery)) { - global = updateGlobalSearchFetchingStatus(global, { messages: false }, tabId); + global = updateGlobalSearchFetchingStatus(global, { messages: false, publicPosts: false }, tabId); setGlobal(global); return; } @@ -269,6 +299,8 @@ async function searchMessagesGlobal(global: T, params: { messages, userStatusesById, totalCount, nextOffsetRate, nextOffsetId, nextOffsetPeerId, } = result; + const searchFlood = result.searchFlood || previousSearchFlood; + if (userStatusesById) { global = addUserStatuses(global, userStatusesById); } @@ -285,6 +317,7 @@ async function searchMessagesGlobal(global: T, params: { nextOffsetRate, nextOffsetId, nextOffsetPeerId, + searchFlood, tabId, ); @@ -298,6 +331,20 @@ async function searchMessagesGlobal(global: T, params: { }, tabId); setGlobal(global); + + if (type === 'publicPosts' && searchFlood && !searchFlood.queryIsFree && !offsetId + && previousSearchFlood?.remains === 0) { + const lang = getTranslationFn(); + getActions().showNotification({ + icon: 'star', + message: { + key: 'NotificationPaidExtraSearch', + variables: { + stars: formatStarsAsText(lang, searchFlood.starsAmount), + }, + }, + }); + } } async function getMessageByPublicLink(global: GlobalState, link: { username: string; messageId: number }) { diff --git a/src/global/actions/api/middleSearch.ts b/src/global/actions/api/middleSearch.ts index 20ed21609..470ecc4f5 100644 --- a/src/global/actions/api/middleSearch.ts +++ b/src/global/actions/api/middleSearch.ts @@ -113,7 +113,7 @@ addActionHandler('performMiddleSearch', async (global, actions, payload): Promis } if (type === 'channels') { - result = await callApi('searchHashtagPosts', { + result = await callApi('searchPublicPosts', { hashtag: query!, limit: MESSAGE_SEARCH_SLICE, offsetId, diff --git a/src/global/actions/ui/globalSearch.ts b/src/global/actions/ui/globalSearch.ts index 12d44b42c..b4411091f 100644 --- a/src/global/actions/ui/globalSearch.ts +++ b/src/global/actions/ui/globalSearch.ts @@ -12,9 +12,12 @@ addActionHandler('setGlobalSearchQuery', (global, actions, payload): ActionRetur const { query, tabId = getCurrentTabId() } = payload; const { chatId, currentContent } = selectTabState(global, tabId).globalSearch; - const fetchingStatus = query && currentContent !== GlobalSearchContent.BotApps + const fetchingStatus = query + && currentContent !== GlobalSearchContent.BotApps && currentContent !== GlobalSearchContent.PublicPosts ? { chats: !chatId, messages: true } : undefined; + actions.checkSearchPostsFlood({ query, tabId }); + return updateGlobalSearch(global, { globalResults: {}, localResults: {}, diff --git a/src/global/helpers/payments.ts b/src/global/helpers/payments.ts index 4351398a7..3e5a31c3f 100644 --- a/src/global/helpers/payments.ts +++ b/src/global/helpers/payments.ts @@ -6,8 +6,6 @@ import type { ApiRequestInputSavedStarGift, ApiStarsAmount, ApiStarsTransaction, - ApiStarsTransactionPeer, - ApiStarsTransactionPeerPeer, ApiTypeCurrencyAmount, } from '../../api/types'; import type { CustomPeer } from '../../types'; @@ -259,10 +257,25 @@ export function getRequestInputSavedStarGift( return undefined; } +export function shouldUseCustomPeer(transaction: ApiStarsTransaction) { + return transaction.peer.type !== 'peer' || Boolean(transaction.isPostsSearch); +} + export function buildStarsTransactionCustomPeer( - peer: Exclude, - isForTon?: boolean, + transaction: ApiStarsTransaction, ): CustomPeer { + const { peer } = transaction; + const isForTon = transaction.amount.currency === TON_CURRENCY_CODE; + + if (transaction.isPostsSearch) { + return { + avatarIcon: 'search', + isCustomPeer: true, + title: '', + peerColorId: 5, + }; + } + if (peer.type === 'appStore') { return { avatarIcon: 'star', diff --git a/src/global/reducers/globalSearch.ts b/src/global/reducers/globalSearch.ts index 0fbfb4130..f8b9666ff 100644 --- a/src/global/reducers/globalSearch.ts +++ b/src/global/reducers/globalSearch.ts @@ -1,4 +1,4 @@ -import type { ApiGlobalMessageSearchType, ApiMessage } from '../../api/types'; +import type { ApiGlobalMessageSearchType, ApiMessage, ApiSearchPostsFlood } from '../../api/types'; import type { GlobalSearchContent } from '../../types'; import type { GlobalState, TabArgs, TabState } from '../types'; @@ -37,6 +37,7 @@ export function updateGlobalSearchResults( nextOffsetRate?: number, nextOffsetId?: number, nextOffsetPeerId?: string, + searchFlood?: ApiSearchPostsFlood, ...[tabId = getCurrentTabId()]: TabArgs ): T { const { resultsByType } = selectTabState(global, tabId).globalSearch || {}; @@ -52,8 +53,12 @@ export function updateGlobalSearchResults( (newId) => foundIdsForType.includes(getSearchResultKey(newFoundMessagesById[newId])), ) ) { - global = updateGlobalSearchFetchingStatus(global, { messages: false }, tabId); + global = updateGlobalSearchFetchingStatus(global, { + messages: false, + publicPosts: false, + }, tabId); return updateGlobalSearch(global, { + searchFlood, resultsByType: { ...(selectTabState(global, tabId).globalSearch || {}).resultsByType, [type]: { @@ -74,9 +79,13 @@ export function updateGlobalSearchResults( const foundIds = Array.prototype.concat(prevFoundIds, newFoundIds); const foundOrPrevFoundIds = areSortedArraysEqual(prevFoundIds, foundIds) ? prevFoundIds : foundIds; - global = updateGlobalSearchFetchingStatus(global, { messages: false }, tabId); + global = updateGlobalSearchFetchingStatus(global, { + messages: false, + publicPosts: false, + }, tabId); return updateGlobalSearch(global, { + searchFlood, resultsByType: { ...(selectTabState(global, tabId).globalSearch || {}).resultsByType, [type]: { @@ -91,7 +100,7 @@ export function updateGlobalSearchResults( } export function updateGlobalSearchFetchingStatus( - global: T, newState: { chats?: boolean; messages?: boolean; botApps?: boolean }, + global: T, newState: { chats?: boolean; messages?: boolean; botApps?: boolean; publicPosts?: boolean }, ...[tabId = getCurrentTabId()]: TabArgs ): T { return updateGlobalSearch(global, { diff --git a/src/global/types/actions.ts b/src/global/types/actions.ts index 3305628f8..5a76942d6 100644 --- a/src/global/types/actions.ts +++ b/src/global/types/actions.ts @@ -418,6 +418,9 @@ export interface ActionPayloads { shouldCheckFetchingMessagesStatus?: boolean; } & WithTabId; searchPopularBotApps: WithTabId | undefined; + checkSearchPostsFlood: { + query?: string; + } & WithTabId; addRecentlyFoundChatId: { id: string; }; diff --git a/src/global/types/tabState.ts b/src/global/types/tabState.ts index 061f4bb51..2dfb05f69 100644 --- a/src/global/types/tabState.ts +++ b/src/global/types/tabState.ts @@ -36,6 +36,7 @@ import type { ApiReceiptRegular, ApiSavedGifts, ApiSavedStarGift, + ApiSearchPostsFlood, ApiSponsoredPeer, ApiStarGift, ApiStarGiftAttribute, @@ -249,10 +250,12 @@ export type TabState = { chatId?: string; foundTopicIds?: number[]; sponsoredPeer?: ApiSponsoredPeer; + searchFlood?: ApiSearchPostsFlood; fetchingStatus?: { chats?: boolean; messages?: boolean; botApps?: boolean; + publicPosts?: boolean; }; isClosing?: boolean; localResults?: { diff --git a/src/lib/gramjs/tl/api.d.ts b/src/lib/gramjs/tl/api.d.ts index 7da54d461..cd2d7821b 100644 --- a/src/lib/gramjs/tl/api.d.ts +++ b/src/lib/gramjs/tl/api.d.ts @@ -16515,6 +16515,7 @@ namespace Api { stargiftUpgrade?: true; businessTransfer?: true; stargiftResale?: true; + postsSearch?: true; id: string; amount: Api.TypeStarsAmount; date: int; @@ -16548,6 +16549,7 @@ namespace Api { stargiftUpgrade?: true; businessTransfer?: true; stargiftResale?: true; + postsSearch?: true; id: string; amount: Api.TypeStarsAmount; date: int; diff --git a/src/lib/gramjs/tl/apiTl.ts b/src/lib/gramjs/tl/apiTl.ts index c51b28785..4a3061953 100644 --- a/src/lib/gramjs/tl/apiTl.ts +++ b/src/lib/gramjs/tl/apiTl.ts @@ -1368,7 +1368,7 @@ starsTransactionPeer#d80da15d peer:Peer = StarsTransactionPeer; starsTransactionPeerAds#60682812 = StarsTransactionPeer; starsTransactionPeerAPI#f9677aad = StarsTransactionPeer; starsTopupOption#bd915c0 flags:# extended:flags.1?true stars:long store_product:flags.0?string currency:string amount:long = StarsTopupOption; -starsTransaction#13659eb0 flags:# refund:flags.3?true pending:flags.4?true failed:flags.6?true gift:flags.10?true reaction:flags.11?true stargift_upgrade:flags.18?true business_transfer:flags.21?true stargift_resale:flags.22?true id:string amount:StarsAmount date:int peer:StarsTransactionPeer title:flags.0?string description:flags.1?string photo:flags.2?WebDocument transaction_date:flags.5?int transaction_url:flags.5?string bot_payload:flags.7?bytes msg_id:flags.8?int extended_media:flags.9?Vector subscription_period:flags.12?int giveaway_post_id:flags.13?int stargift:flags.14?StarGift floodskip_number:flags.15?int starref_commission_permille:flags.16?int starref_peer:flags.17?Peer starref_amount:flags.17?StarsAmount paid_messages:flags.19?int premium_gift_months:flags.20?int ads_proceeds_from_date:flags.23?int ads_proceeds_to_date:flags.23?int = StarsTransaction; +starsTransaction#13659eb0 flags:# refund:flags.3?true pending:flags.4?true failed:flags.6?true gift:flags.10?true reaction:flags.11?true stargift_upgrade:flags.18?true business_transfer:flags.21?true stargift_resale:flags.22?true posts_search:flags.24?true id:string amount:StarsAmount date:int peer:StarsTransactionPeer title:flags.0?string description:flags.1?string photo:flags.2?WebDocument transaction_date:flags.5?int transaction_url:flags.5?string bot_payload:flags.7?bytes msg_id:flags.8?int extended_media:flags.9?Vector subscription_period:flags.12?int giveaway_post_id:flags.13?int stargift:flags.14?StarGift floodskip_number:flags.15?int starref_commission_permille:flags.16?int starref_peer:flags.17?Peer starref_amount:flags.17?StarsAmount paid_messages:flags.19?int premium_gift_months:flags.20?int ads_proceeds_from_date:flags.23?int ads_proceeds_to_date:flags.23?int = StarsTransaction; payments.starsStatus#6c9ce8ed flags:# balance:StarsAmount subscriptions:flags.1?Vector subscriptions_next_offset:flags.2?string subscriptions_missing_balance:flags.4?long history:flags.3?Vector next_offset:flags.0?string chats:Vector users:Vector = payments.StarsStatus; foundStory#e87acbc0 peer:Peer story:StoryItem = FoundStory; stories.foundStories#e2de7737 flags:# count:int stories:Vector next_offset:flags.0?string chats:Vector users:Vector = stories.FoundStories; @@ -1751,6 +1751,7 @@ channels.getChannelRecommendations#25a71742 flags:# channel:flags.0?InputChannel channels.searchPosts#f2c4f24d flags:# hashtag:flags.0?string query:flags.1?string offset_rate:int offset_peer:InputPeer offset_id:int limit:int allow_paid_stars:flags.2?long = messages.Messages; channels.updatePaidMessagesPrice#4b12327b flags:# broadcast_messages_allowed:flags.0?true channel:InputChannel send_paid_messages_stars:long = Updates; channels.toggleAutotranslation#167fc0a1 channel:InputChannel enabled:Bool = Updates; +channels.checkSearchPostsFlood#22567115 flags:# query:flags.0?string = SearchPostsFlood; bots.setBotInfo#10cf3123 flags:# bot:flags.2?InputUser lang_code:string name:flags.3?string about:flags.0?string description:flags.1?string = Bool; bots.canSendMessage#1359f4e6 bot:InputUser = Bool; bots.allowSendMessage#f132e3ef bot:InputUser = Updates; diff --git a/src/lib/gramjs/tl/static/api.json b/src/lib/gramjs/tl/static/api.json index 5d760ca0b..9fb883c73 100644 --- a/src/lib/gramjs/tl/static/api.json +++ b/src/lib/gramjs/tl/static/api.json @@ -284,6 +284,7 @@ "channels.searchPosts", "channels.reportSpam", "channels.updatePaidMessagesPrice", + "channels.checkSearchPostsFlood", "channels.toggleAutotranslation", "bots.getBotRecommendations", "bots.canSendMessage", diff --git a/src/lib/gramjs/tl/static/api.tl b/src/lib/gramjs/tl/static/api.tl index 051f1ba62..cf284a780 100644 --- a/src/lib/gramjs/tl/static/api.tl +++ b/src/lib/gramjs/tl/static/api.tl @@ -1853,7 +1853,7 @@ starsTransactionPeerAPI#f9677aad = StarsTransactionPeer; starsTopupOption#bd915c0 flags:# extended:flags.1?true stars:long store_product:flags.0?string currency:string amount:long = StarsTopupOption; -starsTransaction#13659eb0 flags:# refund:flags.3?true pending:flags.4?true failed:flags.6?true gift:flags.10?true reaction:flags.11?true stargift_upgrade:flags.18?true business_transfer:flags.21?true stargift_resale:flags.22?true id:string amount:StarsAmount date:int peer:StarsTransactionPeer title:flags.0?string description:flags.1?string photo:flags.2?WebDocument transaction_date:flags.5?int transaction_url:flags.5?string bot_payload:flags.7?bytes msg_id:flags.8?int extended_media:flags.9?Vector subscription_period:flags.12?int giveaway_post_id:flags.13?int stargift:flags.14?StarGift floodskip_number:flags.15?int starref_commission_permille:flags.16?int starref_peer:flags.17?Peer starref_amount:flags.17?StarsAmount paid_messages:flags.19?int premium_gift_months:flags.20?int ads_proceeds_from_date:flags.23?int ads_proceeds_to_date:flags.23?int = StarsTransaction; +starsTransaction#13659eb0 flags:# refund:flags.3?true pending:flags.4?true failed:flags.6?true gift:flags.10?true reaction:flags.11?true stargift_upgrade:flags.18?true business_transfer:flags.21?true stargift_resale:flags.22?true posts_search:flags.24?true id:string amount:StarsAmount date:int peer:StarsTransactionPeer title:flags.0?string description:flags.1?string photo:flags.2?WebDocument transaction_date:flags.5?int transaction_url:flags.5?string bot_payload:flags.7?bytes msg_id:flags.8?int extended_media:flags.9?Vector subscription_period:flags.12?int giveaway_post_id:flags.13?int stargift:flags.14?StarGift floodskip_number:flags.15?int starref_commission_permille:flags.16?int starref_peer:flags.17?Peer starref_amount:flags.17?StarsAmount paid_messages:flags.19?int premium_gift_months:flags.20?int ads_proceeds_from_date:flags.23?int ads_proceeds_to_date:flags.23?int = StarsTransaction; payments.starsStatus#6c9ce8ed flags:# balance:StarsAmount subscriptions:flags.1?Vector subscriptions_next_offset:flags.2?string subscriptions_missing_balance:flags.4?long history:flags.3?Vector next_offset:flags.0?string chats:Vector users:Vector = payments.StarsStatus; diff --git a/src/types/index.ts b/src/types/index.ts index f785207a0..a2820aecf 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -313,6 +313,7 @@ export enum GlobalSearchContent { ChatList, ChannelList, BotApps, + PublicPosts, Media, Links, Files, diff --git a/src/types/language.d.ts b/src/types/language.d.ts index dff70377d..70839cd80 100644 --- a/src/types/language.d.ts +++ b/src/types/language.d.ts @@ -1333,6 +1333,7 @@ export interface LangPair { 'SearchTabMusic': undefined; 'SearchTabVoice': undefined; 'SearchTabMessages': undefined; + 'SearchTabPublicPosts': undefined; 'StarsTransactionsAll': undefined; 'StarsTransactionsIncoming': undefined; 'StarsTransactionsOutgoing': undefined; @@ -1351,6 +1352,7 @@ export interface LangPair { 'ProfileTabSharedGroups': undefined; 'ProfileTabSimilarChannels': undefined; 'ProfileTabSimilarBots': undefined; + 'ProfileTabPublicPosts': undefined; 'ActionUnsupportedTitle': undefined; 'ActionUnsupportedDescription': undefined; 'UnlockMoreSimilarBots': undefined; @@ -1620,6 +1622,14 @@ export interface LangPair { 'LabelPayInTON': undefined; 'PriceChanged': undefined; 'PayNewPrice': undefined; + 'GlobalSearch': undefined; + 'DescriptionPublicPostsSearch': undefined; + 'PublicPosts': undefined; + 'PublicPostsLimitReached': undefined; + 'PublicPostsPremiumFeatureDescription': undefined; + 'PublicPostsPremiumFeatureSubtitle': undefined; + 'PublicPostsSubscribeToPremium': undefined; + 'PostsSearchTransaction': undefined; } export interface LangPairWithVariables { @@ -2816,6 +2826,18 @@ export interface LangPairWithVariables { 'originalAmount': V; 'newAmount': V; }; + 'ButtonSearchPublicPosts': { + 'query': V; + }; + 'PublicPostsSearchForStars': { + 'stars': V; + }; + 'UnlockTimerPublicPostsSearch': { + 'time': V; + }; + 'NotificationPaidExtraSearch': { + 'stars': V; + }; } export interface LangPairPlural { @@ -3137,6 +3159,12 @@ export interface LangPairPluralWithVariables { 'TextAgeVerificationModal': { 'count': V; }; + 'RemainingPublicPostsSearch': { + 'count': V; + }; + 'HintPublicPostsSearchQuota': { + 'count': V; + }; } export type RegularLangKey = keyof LangPair; export type RegularLangKeyWithVariables = keyof LangPairWithVariables;