diff --git a/src/components/middle/MessageList.tsx b/src/components/middle/MessageList.tsx index f10537260..8fb630859 100644 --- a/src/components/middle/MessageList.tsx +++ b/src/components/middle/MessageList.tsx @@ -52,6 +52,9 @@ import { } from '../../global/selectors'; import { selectIsChatRestricted } from '../../global/selectors/chats'; import { selectActiveRestrictionReasons, selectCurrentMessageList } from '../../global/selectors/messages'; +import type { MessageFilterRule } from '../../global/types/sharedState'; +import { selectSharedSettings } from '../../global/selectors/sharedState'; +import { shouldHideMessageByRules } from '../../util/ayuLike/messageFilters'; import { selectLastScrollOffset, selectScrollOffset, @@ -143,6 +146,8 @@ type StateProps = { currentUserId: string; isAccountFrozen?: boolean; areAdsEnabled?: boolean; + hideSponsoredMessages?: boolean; + messageFilters?: MessageFilterRule[]; channelJoinInfo?: ApiChatFullInfo['joinInfo']; isChatProtected?: boolean; hasCustomGreeting?: boolean; @@ -243,6 +248,8 @@ const MessageList = ({ isContactRequirePremium, paidMessagesStars, areAdsEnabled, + hideSponsoredMessages, + messageFilters, channelJoinInfo, isChatProtected, isAccountFrozen, @@ -387,10 +394,10 @@ const MessageList = ({ useEffect(() => { const canHaveAds = isChannelChat || isBot; - if (areAdsEnabled && canHaveAds && isSynced && isReady && isAppConfigLoaded) { + if (!hideSponsoredMessages && areAdsEnabled && canHaveAds && isSynced && isReady && isAppConfigLoaded) { loadSponsoredMessages({ peerId: chatId }); } - }, [chatId, isSynced, isReady, isChannelChat, isBot, areAdsEnabled, isAppConfigLoaded]); + }, [chatId, isSynced, isReady, isChannelChat, isBot, areAdsEnabled, isAppConfigLoaded, hideSponsoredMessages]); // Updated only once when messages are loaded (as we want the unread divider to keep its position) useSyncEffect(() => { @@ -425,6 +432,10 @@ const MessageList = ({ return; } + if (shouldHideMessageByRules(message, messageFilters)) { + return; + } + const { shouldAppendJoinMessage, shouldAppendJoinMessageAfterCurrent } = (() => { if (!channelJoinInfo || type !== 'thread') return undefined; if (prevMessage @@ -487,7 +498,8 @@ const MessageList = ({ }, [withUsers, messageIds, messagesById, type, isServiceNotificationsChat, isForum, - threadId, isChatWithSelf, channelJoinInfo, effectiveLiveTailStartOriginalId]); + threadId, isChatWithSelf, channelJoinInfo, effectiveLiveTailStartOriginalId, + messageFilters]); const currentLastMessageId = messageIds?.[messageIds.length - 1]; const currentLastMessage = currentLastMessageId !== undefined ? messagesById?.[currentLastMessageId] : undefined; @@ -1146,7 +1158,7 @@ const MessageList = ({ /> ) : activeKey === Content.MessageList ? ( ( const isCurrentUserPremium = selectIsCurrentUserPremium(global); const areAdsEnabled = !isCurrentUserPremium || selectUserFullInfo(global, currentUserId)?.areAdsEnabled; + const { ayuLike } = selectSharedSettings(global); const isAccountFrozen = selectIsCurrentUserFrozen(global); const hasCustomGreeting = Boolean(userFullInfo?.businessIntro); @@ -1277,6 +1290,8 @@ export default memo(withGlobal( return { isActive, areAdsEnabled, + hideSponsoredMessages: ayuLike?.hideSponsoredMessages, + messageFilters: ayuLike?.messageFilters, isChatLoaded: true, isRestricted, restrictionReasons, diff --git a/src/util/ayuLike/messageFilters.ts b/src/util/ayuLike/messageFilters.ts new file mode 100644 index 000000000..5b496ed29 --- /dev/null +++ b/src/util/ayuLike/messageFilters.ts @@ -0,0 +1,68 @@ +import type { ApiMessage } from '../../api/types'; +import type { MessageFilterRule } from '../../global/types/sharedState'; + +const compiledRegexCache = new Map(); + +function getCompiledRegex(pattern: string, caseInsensitive?: boolean): RegExp | null { + const key = `${caseInsensitive ? 'i' : ''}:${pattern}`; + if (!compiledRegexCache.has(key)) { + try { + compiledRegexCache.set(key, new RegExp(pattern, caseInsensitive ? 'i' : '')); + } catch { + compiledRegexCache.set(key, null); + } + } + return compiledRegexCache.get(key) ?? null; +} + +function getMessageMediaTypes(message: ApiMessage): string[] { + const { content } = message; + return [ + content.text && 'text', + content.photo && 'photo', + content.video && 'video', + content.document && 'document', + content.sticker && 'sticker', + content.voice && 'voice', + content.audio && 'audio', + content.webPage && 'webPage', + content.pollId && 'poll', + content.storyData && 'story', + ].filter(Boolean) as string[]; +} + +export function shouldHideMessageByRules( + message: ApiMessage, + rules: MessageFilterRule[] = [], +): boolean { + if (!rules.length) return false; + + // Never filter action/service messages + if (message.content.action) return false; + + const text = message.content.text?.text ?? ''; + const mediaTypes = getMessageMediaTypes(message); + + return rules.some((rule) => { + if (!rule.enabled) return false; + + if (rule.chatIds?.length && !rule.chatIds.includes(message.chatId)) return false; + if (rule.senderIds?.length && (!message.senderId || !rule.senderIds.includes(message.senderId))) return false; + if (rule.mediaTypes?.length && !rule.mediaTypes.some((t) => mediaTypes.includes(t))) return false; + + let matched = false; + + if (rule.keyword) { + matched = rule.caseInsensitive + ? text.toLowerCase().includes(rule.keyword.toLowerCase()) + : text.includes(rule.keyword); + } else if (rule.regex) { + const re = getCompiledRegex(rule.regex, rule.caseInsensitive); + matched = re ? re.test(text) : false; + } else if (rule.mediaTypes?.length) { + matched = true; + } + + return rule.reversed ? !matched : matched; + }); +}