Add local sponsored message hiding and message filter engine

- MessageList: gate loadSponsoredMessages and canShowAds on hideSponsoredMessages
- messageFilters util: keyword/regex/media-type/sender/chat rules with compiled regex cache
- shouldHideMessageByRules inserted in listedMessages useMemo, skips action messages
- reversed and caseInsensitive fields supported per AyuGram filter model
This commit is contained in:
Tianrong Zhang 2026-06-11 22:15:50 -04:00
parent 32153e86de
commit 2879188020
2 changed files with 87 additions and 4 deletions

View File

@ -52,6 +52,9 @@ import {
} from '../../global/selectors'; } from '../../global/selectors';
import { selectIsChatRestricted } from '../../global/selectors/chats'; import { selectIsChatRestricted } from '../../global/selectors/chats';
import { selectActiveRestrictionReasons, selectCurrentMessageList } from '../../global/selectors/messages'; 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 { import {
selectLastScrollOffset, selectLastScrollOffset,
selectScrollOffset, selectScrollOffset,
@ -143,6 +146,8 @@ type StateProps = {
currentUserId: string; currentUserId: string;
isAccountFrozen?: boolean; isAccountFrozen?: boolean;
areAdsEnabled?: boolean; areAdsEnabled?: boolean;
hideSponsoredMessages?: boolean;
messageFilters?: MessageFilterRule[];
channelJoinInfo?: ApiChatFullInfo['joinInfo']; channelJoinInfo?: ApiChatFullInfo['joinInfo'];
isChatProtected?: boolean; isChatProtected?: boolean;
hasCustomGreeting?: boolean; hasCustomGreeting?: boolean;
@ -243,6 +248,8 @@ const MessageList = ({
isContactRequirePremium, isContactRequirePremium,
paidMessagesStars, paidMessagesStars,
areAdsEnabled, areAdsEnabled,
hideSponsoredMessages,
messageFilters,
channelJoinInfo, channelJoinInfo,
isChatProtected, isChatProtected,
isAccountFrozen, isAccountFrozen,
@ -387,10 +394,10 @@ const MessageList = ({
useEffect(() => { useEffect(() => {
const canHaveAds = isChannelChat || isBot; const canHaveAds = isChannelChat || isBot;
if (areAdsEnabled && canHaveAds && isSynced && isReady && isAppConfigLoaded) { if (!hideSponsoredMessages && areAdsEnabled && canHaveAds && isSynced && isReady && isAppConfigLoaded) {
loadSponsoredMessages({ peerId: chatId }); 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) // Updated only once when messages are loaded (as we want the unread divider to keep its position)
useSyncEffect(() => { useSyncEffect(() => {
@ -425,6 +432,10 @@ const MessageList = ({
return; return;
} }
if (shouldHideMessageByRules(message, messageFilters)) {
return;
}
const { shouldAppendJoinMessage, shouldAppendJoinMessageAfterCurrent } = (() => { const { shouldAppendJoinMessage, shouldAppendJoinMessageAfterCurrent } = (() => {
if (!channelJoinInfo || type !== 'thread') return undefined; if (!channelJoinInfo || type !== 'thread') return undefined;
if (prevMessage if (prevMessage
@ -487,7 +498,8 @@ const MessageList = ({
}, [withUsers, }, [withUsers,
messageIds, messagesById, type, messageIds, messagesById, type,
isServiceNotificationsChat, isForum, isServiceNotificationsChat, isForum,
threadId, isChatWithSelf, channelJoinInfo, effectiveLiveTailStartOriginalId]); threadId, isChatWithSelf, channelJoinInfo, effectiveLiveTailStartOriginalId,
messageFilters]);
const currentLastMessageId = messageIds?.[messageIds.length - 1]; const currentLastMessageId = messageIds?.[messageIds.length - 1];
const currentLastMessage = currentLastMessageId !== undefined ? messagesById?.[currentLastMessageId] : undefined; const currentLastMessage = currentLastMessageId !== undefined ? messagesById?.[currentLastMessageId] : undefined;
@ -1146,7 +1158,7 @@ const MessageList = ({
/> />
) : activeKey === Content.MessageList ? ( ) : activeKey === Content.MessageList ? (
<MessageListContent <MessageListContent
canShowAds={areAdsEnabled && isChannelChat} canShowAds={!hideSponsoredMessages && areAdsEnabled && isChannelChat}
chatId={chatId} chatId={chatId}
isComments={isComments} isComments={isComments}
isChannelChat={isChannelChat} isChannelChat={isChannelChat}
@ -1253,6 +1265,7 @@ export default memo(withGlobal<OwnProps>(
const isCurrentUserPremium = selectIsCurrentUserPremium(global); const isCurrentUserPremium = selectIsCurrentUserPremium(global);
const areAdsEnabled = !isCurrentUserPremium || selectUserFullInfo(global, currentUserId)?.areAdsEnabled; const areAdsEnabled = !isCurrentUserPremium || selectUserFullInfo(global, currentUserId)?.areAdsEnabled;
const { ayuLike } = selectSharedSettings(global);
const isAccountFrozen = selectIsCurrentUserFrozen(global); const isAccountFrozen = selectIsCurrentUserFrozen(global);
const hasCustomGreeting = Boolean(userFullInfo?.businessIntro); const hasCustomGreeting = Boolean(userFullInfo?.businessIntro);
@ -1277,6 +1290,8 @@ export default memo(withGlobal<OwnProps>(
return { return {
isActive, isActive,
areAdsEnabled, areAdsEnabled,
hideSponsoredMessages: ayuLike?.hideSponsoredMessages,
messageFilters: ayuLike?.messageFilters,
isChatLoaded: true, isChatLoaded: true,
isRestricted, isRestricted,
restrictionReasons, restrictionReasons,

View File

@ -0,0 +1,68 @@
import type { ApiMessage } from '../../api/types';
import type { MessageFilterRule } from '../../global/types/sharedState';
const compiledRegexCache = new Map<string, RegExp | null>();
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;
});
}