Compare commits

..

3 Commits

Author SHA1 Message Date
7c53aa3872 Use hand-stop icon for Message Filters menu entry 2026-06-11 23:47:42 -04:00
ffb1ba8108 Harden filter import: item-level validation and user feedback
importFromAyuGram now skips individual malformed entries (missing id/text)
instead of crashing, and validates the top-level structure properly.
Import shows a toast on success, empty result, or parse failure.
2026-06-11 23:35:43 -04:00
1913174e1c Add AyuLike settings screen with filter import/export/clear
- New SettingsScreens.AyuLikeSettings screen wired into Settings.tsx
- SettingsAyuLike: toggle hideSponsoredMessages, import JSON from AyuGram
  Desktop, export back to same format, clear all rules with confirmation
- messageFilters util: importFromAyuGram / exportToAyuGram supporting
  AyuGram v2 export format (text=regex, dialogId, caseInsensitive, reversed)
- Entry in SettingsMain under existing menu section
2026-06-11 23:33:34 -04:00
5 changed files with 223 additions and 0 deletions

View File

@ -23,6 +23,7 @@ import SettingsCustomEmoji from './SettingsCustomEmoji';
import SettingsDataStorage from './SettingsDataStorage';
import SettingsDoNotTranslate from './SettingsDoNotTranslate';
import SettingsEditProfile from './SettingsEditProfile';
import SettingsAyuLike from './SettingsAyuLike';
import SettingsExperimental from './SettingsExperimental';
import SettingsGeneral from './SettingsGeneral';
import SettingsGeneralBackground from './SettingsGeneralBackground';
@ -307,6 +308,10 @@ const Settings: FC<OwnProps> = ({
return (
<SettingsStickers isActive={isScreenActive} onReset={handleReset} />
);
case SettingsScreens.AyuLikeSettings:
return (
<SettingsAyuLike isActive={isScreenActive} onReset={handleReset} />
);
case SettingsScreens.Experimental:
return (
<SettingsExperimental isActive={isScreenActive} onReset={handleReset} />

View File

@ -0,0 +1,159 @@
import { memo, useRef } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { MessageFilterRule } from '../../../global/types/sharedState';
import { selectSharedSettings } from '../../../global/selectors/sharedState';
import download from '../../../util/download';
import { exportToAyuGram, importFromAyuGram } from '../../../util/ayuLike/messageFilters';
import useHistoryBack from '../../../hooks/useHistoryBack';
import useLastCallback from '../../../hooks/useLastCallback';
import Island from '../../gili/layout/Island';
import Checkbox from '../../ui/Checkbox';
import ListItem from '../../ui/ListItem';
import ConfirmDialog from '../../ui/ConfirmDialog';
import useFlag from '../../../hooks/useFlag';
type OwnProps = {
isActive?: boolean;
onReset: () => void;
};
type StateProps = {
hideSponsoredMessages: boolean;
messageFilters: MessageFilterRule[];
};
const EXPORT_FILENAME = 'ayu-filters.json';
const SettingsAyuLike = ({
isActive,
hideSponsoredMessages,
messageFilters,
onReset,
}: OwnProps & StateProps) => {
const { setSharedSettingOption, showNotification } = getActions();
const fileInputRef = useRef<HTMLInputElement>();
const [isClearConfirmOpen, openClearConfirm, closeClearConfirm] = useFlag(false);
useHistoryBack({ isActive, onBack: onReset });
const handleExport = useLastCallback(() => {
const json = exportToAyuGram(messageFilters);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
download(url, EXPORT_FILENAME);
setTimeout(() => URL.revokeObjectURL(url), 10_000);
});
const handleImportClick = useLastCallback(() => {
fileInputRef.current?.click();
});
const handleFileChange = useLastCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const imported = importFromAyuGram(JSON.parse(reader.result as string));
if (imported.length === 0) {
showNotification({ message: 'No valid filter rules found in file' });
} else {
setSharedSettingOption({
ayuLike: { hideSponsoredMessages, messageFilters: imported },
});
showNotification({ message: `Imported ${imported.length} filter rules` });
}
} catch {
showNotification({ message: 'Invalid file: not a valid AyuGram filter export' });
}
// Reset so the same file can be re-imported
e.target.value = '';
};
reader.readAsText(file);
});
const handleClearConfirmed = useLastCallback(() => {
setSharedSettingOption({
ayuLike: { hideSponsoredMessages, messageFilters: [] },
});
closeClearConfirm();
});
return (
<div className="settings-content custom-scroll">
<div className="settings-content-header no-border">
<p className="settings-item-description pt-3" dir="auto">
Local message filters and ad settings. Changes take effect immediately and are stored locally.
</p>
</div>
<Island>
<Checkbox
label="Hide Sponsored Messages"
checked={hideSponsoredMessages}
onCheck={(checked) => setSharedSettingOption({
ayuLike: { hideSponsoredMessages: checked, messageFilters },
})}
/>
</Island>
<Island>
<ListItem
icon="download"
onClick={handleImportClick}
>
<div className="title">Import Filters from AyuGram</div>
{messageFilters.length > 0 && (
<span className="settings-item__current-value">{messageFilters.length} rules</span>
)}
</ListItem>
<ListItem
icon="document"
disabled={messageFilters.length === 0}
onClick={handleExport}
>
<div className="title">Export Filters</div>
</ListItem>
<ListItem
icon="delete"
disabled={messageFilters.length === 0}
onClick={openClearConfirm}
>
<div className="title">Clear All Filters</div>
</ListItem>
</Island>
<input
ref={fileInputRef}
type="file"
accept=".json,application/json"
style="display: none"
onChange={handleFileChange}
/>
<ConfirmDialog
isOpen={isClearConfirmOpen}
title="Clear All Filters"
text={`Remove all ${messageFilters.length} filter rules?`}
confirmLabel="Clear"
confirmIsDestructive
onClose={closeClearConfirm}
confirmHandler={handleClearConfirmed}
/>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global): Complete<StateProps> => {
const { ayuLike } = selectSharedSettings(global);
return {
hideSponsoredMessages: ayuLike.hideSponsoredMessages,
messageFilters: ayuLike.messageFilters,
};
},
)(SettingsAyuLike));

View File

@ -167,6 +167,13 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
>
{lang('MenuStickers')}
</ListItem>
<ListItem
icon="hand-stop"
narrow
onClick={() => openSettingsScreen({ screen: SettingsScreens.AyuLikeSettings })}
>
Message Filters
</ListItem>
</Island>
<Island>
{canBuyPremium && (

View File

@ -273,6 +273,7 @@ export enum SettingsScreens {
PasscodeTurnOff,
PasscodeCongratulations,
Experimental,
AyuLikeSettings,
Stickers,
QuickReaction,
CustomEmoji,

View File

@ -31,6 +31,57 @@ function getMessageMediaTypes(message: ApiMessage): string[] {
].filter(Boolean) as string[];
}
// AyuGram Desktop export format (version 2)
interface AyuGramExport {
filters: {
id: string;
text: string;
enabled: boolean;
reversed: boolean;
caseInsensitive: boolean;
dialogId: string | null;
}[];
exclusions?: unknown[];
version?: number;
}
export function importFromAyuGram(json: unknown): MessageFilterRule[] {
const data = json as AyuGramExport;
if (!data || typeof data !== 'object' || !Array.isArray(data.filters)) {
throw new Error('Invalid AyuGram export format');
}
return data.filters.flatMap((f) => {
if (!f || typeof f !== 'object') return [];
if (typeof f.id !== 'string' || !f.id) return [];
if (typeof f.text !== 'string' || !f.text) return [];
return [{
id: f.id,
enabled: Boolean(f.enabled),
reversed: Boolean(f.reversed),
caseInsensitive: Boolean(f.caseInsensitive),
regex: f.text,
chatIds: f.dialogId ? [String(f.dialogId)] : undefined,
}];
});
}
export function exportToAyuGram(rules: MessageFilterRule[]): string {
const data: AyuGramExport = {
exclusions: [],
filters: rules.map((r) => ({
id: r.id,
text: r.regex ?? r.keyword ?? '',
enabled: r.enabled,
reversed: Boolean(r.reversed),
caseInsensitive: Boolean(r.caseInsensitive),
dialogId: r.chatIds?.[0] ?? null,
})),
version: 2,
};
return JSON.stringify(data, null, 2);
}
export function shouldHideMessageByRules(
message: ApiMessage,
rules: MessageFilterRule[] = [],