feat(sub): add template variables to subscription metadata (#6163)

This commit is contained in:
isultanov99
2026-08-15 17:38:29 +02:00
committed by GitHub
parent 8c8556ab32
commit 2d669fa4b7
22 changed files with 457 additions and 108 deletions
@@ -1,10 +1,11 @@
import { useRef } from 'react';
import { Button, Input, Popover, Tooltip } from 'antd';
import type { InputRef } from 'antd';
import type { TextAreaRef } from 'antd/es/input/TextArea';
import { CodeOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { hasRemarkTokens, previewRemark, wrapToken } from '@/lib/remark/remarkVariables';
import { hasRemarkTokens, previewRemark, SUBSCRIPTION_METADATA_VARIABLES, wrapToken } from '@/lib/remark/remarkVariables';
import RemarkVarPicker from './RemarkVarPicker';
interface RemarkTemplateFieldProps {
@@ -13,19 +14,31 @@ interface RemarkTemplateFieldProps {
onChange?: (value: string) => void;
maxLength?: number;
placeholder?: string;
multiline?: boolean;
rows?: number;
metadataOnly?: boolean;
}
/**
* RemarkTemplateField is a text input augmented with a {{VAR}} template picker
* (insert-at-caret) and a live, sample-based preview of the expanded result.
* Used for the global subscription Remark Template.
* Used for subscription text fields that support Remark Template variables.
*/
export default function RemarkTemplateField({ value = '', onChange, maxLength, placeholder }: RemarkTemplateFieldProps) {
export default function RemarkTemplateField({ value = '', onChange, maxLength, placeholder, multiline = false, rows, metadataOnly = false }: RemarkTemplateFieldProps) {
const { t } = useTranslation();
const inputRef = useRef<InputRef>(null);
const textAreaRef = useRef<TextAreaRef>(null);
const variables = metadataOnly ? SUBSCRIPTION_METADATA_VARIABLES : undefined;
function getTextElement() {
if (multiline) {
return textAreaRef.current?.resizableTextArea?.textArea ?? null;
}
return inputRef.current?.input ?? null;
}
function insertToken(token: string) {
const el = inputRef.current?.input;
const el = getTextElement();
const start = el?.selectionStart ?? value.length;
const end = el?.selectionEnd ?? value.length;
const insert = wrapToken(token);
@@ -39,31 +52,47 @@ export default function RemarkTemplateField({ value = '', onChange, maxLength, p
});
}
const pickerButton = (
<Popover
content={<RemarkVarPicker onPick={insertToken} variables={variables} />}
trigger="click"
placement="bottomRight"
title={t('pages.hosts.remarkVars.title')}
>
<Tooltip title={t('pages.hosts.remarkVars.title')}>
<Button type="text" size="small" icon={<CodeOutlined />} aria-label={t('pages.hosts.remarkVars.title')} style={{ marginInlineEnd: -7 }} />
</Tooltip>
</Popover>
);
return (
<div>
<Input
ref={inputRef}
value={value}
maxLength={maxLength}
placeholder={placeholder}
onChange={(e) => onChange?.(e.target.value)}
suffix={
<Popover
content={<RemarkVarPicker onPick={insertToken} />}
trigger="click"
placement="bottomRight"
title={t('pages.hosts.remarkVars.title')}
>
<Tooltip title={t('pages.hosts.remarkVars.title')}>
<Button type="text" size="small" icon={<CodeOutlined />} aria-label={t('pages.hosts.remarkVars.title')} style={{ marginInlineEnd: -7 }} />
</Tooltip>
</Popover>
}
/>
{multiline ? (
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
<Input.TextArea
ref={textAreaRef}
value={value}
maxLength={maxLength}
placeholder={placeholder}
rows={rows}
onChange={(e) => onChange?.(e.target.value)}
/>
{pickerButton}
</div>
) : (
<Input
ref={inputRef}
value={value}
maxLength={maxLength}
placeholder={placeholder}
onChange={(e) => onChange?.(e.target.value)}
suffix={pickerButton}
/>
)}
{hasRemarkTokens(value) && (
<div style={{ fontSize: 12, marginTop: 4, opacity: 0.7 }}>
{t('pages.hosts.remarkVars.preview')}:{' '}
<span style={{ fontFamily: 'monospace' }}>{previewRemark(value) || '—'}</span>
<span style={{ fontFamily: 'monospace' }}>{previewRemark(value, variables, metadataOnly) || '—'}</span>
</div>
)}
</div>
@@ -2,31 +2,33 @@ import { Tag, Tooltip, Typography } from 'antd';
import { useTranslation } from 'react-i18next';
import { REMARK_VARIABLES, REMARK_VAR_GROUPS, wrapToken } from '@/lib/remark/remarkVariables';
import type { RemarkVar } from '@/lib/remark/remarkVariables';
import { activateOnKey } from '@/utils/a11y';
interface RemarkVarPickerProps {
/** Called with the bare token (e.g. "EMAIL") when a chip is clicked. */
onPick: (token: string) => void;
variables?: RemarkVar[];
}
/**
* RemarkVarPicker is the grouped, tooltipped chip list of {{VAR}} tokens used by
* the global remark-template field.
*/
export default function RemarkVarPicker({ onPick }: RemarkVarPickerProps) {
export default function RemarkVarPicker({ onPick, variables = REMARK_VARIABLES }: RemarkVarPickerProps) {
const { t } = useTranslation();
return (
<div style={{ maxWidth: 460, maxHeight: 'min(70vh, 640px)', overflowY: 'auto' }}>
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginBottom: 8 }}>
{t('pages.hosts.remarkVars.intro')}
</Typography.Paragraph>
{REMARK_VAR_GROUPS.map((group) => (
{REMARK_VAR_GROUPS.filter((group) => variables.some((v) => v.group === group)).map((group) => (
<div key={group} style={{ marginBottom: 8 }}>
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', opacity: 0.6, marginBottom: 4 }}>
{t(`pages.hosts.remarkVars.groups.${group}`)}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{REMARK_VARIABLES.filter((v) => v.group === group).map((v) => (
{variables.filter((v) => v.group === group).map((v) => (
<Tooltip key={v.token} title={t(`pages.hosts.remarkVars.desc${v.token}`)}>
<Tag
role="button"
+16 -3
View File
@@ -51,6 +51,14 @@ export const REMARK_VARIABLES: RemarkVar[] = [
{ token: 'SECURITY', group: 'connection', sample: 'TLS' },
];
export const SUBSCRIPTION_METADATA_VARIABLES: RemarkVar[] = REMARK_VARIABLES.filter((v) => (
v.token === 'EMAIL'
|| v.token === 'ID'
|| v.token === 'SHORT_ID'
|| v.token === 'TELEGRAM_ID'
|| v.token === 'SUB_ID'
));
const SAMPLE_BY_TOKEN: Record<string, string> = Object.fromEntries(
REMARK_VARIABLES.map((v) => [v.token, v.sample]),
);
@@ -70,9 +78,14 @@ export function hasRemarkTokens(template: string): boolean {
/**
* previewRemark renders a template against the sample values, mirroring the
* backend substitution closely enough for an at-a-glance preview. Unknown
* tokens collapse to empty, just like the server.
* tokens collapse to empty by default; metadata fields can keep unsupported
* tokens literal because the backend does the same for backwards compatibility.
*/
export function previewRemark(template: string): string {
export function previewRemark(template: string, variables: RemarkVar[] = REMARK_VARIABLES, keepUnknown = false): string {
if (!hasRemarkTokens(template)) return template;
return template.replace(TOKEN_RE, (_m, tok: string) => SAMPLE_BY_TOKEN[tok] ?? '');
const allowed = new Set(variables.map((v) => v.token));
return template.replace(TOKEN_RE, (match, tok: string) => {
if (!allowed.has(tok)) return keepUnknown ? match : '';
return SAMPLE_BY_TOKEN[tok] ?? '';
});
}
@@ -118,19 +118,36 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.subTitle')} description={t('pages.settings.subTitleDesc')}>
<Input value={allSetting.subTitle} onChange={(e) => updateSetting({ subTitle: e.target.value })} />
<RemarkTemplateField
value={allSetting.subTitle}
onChange={(v) => updateSetting({ subTitle: v })}
metadataOnly
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subSupportUrl')} description={t('pages.settings.subSupportUrlDesc')}>
<Input value={allSetting.subSupportUrl} placeholder="https://example.com"
onChange={(e) => updateSetting({ subSupportUrl: e.target.value })} />
<RemarkTemplateField
value={allSetting.subSupportUrl}
placeholder="https://example.com"
onChange={(v) => updateSetting({ subSupportUrl: v })}
metadataOnly
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subProfileUrl')} description={t('pages.settings.subProfileUrlDesc')}>
<Input value={allSetting.subProfileUrl} placeholder="https://example.com"
onChange={(e) => updateSetting({ subProfileUrl: e.target.value })} />
<RemarkTemplateField
value={allSetting.subProfileUrl}
placeholder="https://example.com"
onChange={(v) => updateSetting({ subProfileUrl: v })}
metadataOnly
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subAnnounce')} description={t('pages.settings.subAnnounceDesc')}>
<Input.TextArea value={allSetting.subAnnounce}
onChange={(e) => updateSetting({ subAnnounce: e.target.value })} />
<RemarkTemplateField
value={allSetting.subAnnounce}
onChange={(v) => updateSetting({ subAnnounce: v })}
multiline
rows={3}
metadataOnly
/>
</SettingListItem>
<SettingListItem
paddings="small"
@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import RemarkTemplateField from '@/components/form/RemarkTemplateField';
import { previewRemark, SUBSCRIPTION_METADATA_VARIABLES } from '@/lib/remark/remarkVariables';
describe('RemarkTemplateField', () => {
it('inserts a {{TOKEN}} when a variable chip is clicked', async () => {
@@ -23,4 +24,32 @@ describe('RemarkTemplateField', () => {
// Sample expansion of {{EMAIL}} is "john".
expect(screen.getByText('john')).toBeTruthy();
});
it('supports token insertion in multiline fields', async () => {
const onChange = vi.fn();
render(<RemarkTemplateField value="Hello " onChange={onChange} multiline rows={3} />);
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement;
textarea.focus();
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
fireEvent.click(screen.getByRole('button'));
fireEvent.click(await screen.findByText('{{SUB_ID}}'));
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange.mock.calls[0][0]).toBe('Hello {{SUB_ID}}');
});
it('limits the picker to client identity tokens for metadata fields', async () => {
render(<RemarkTemplateField value="" onChange={() => {}} metadataOnly />);
fireEvent.click(screen.getByRole('button'));
expect(await screen.findByText('{{EMAIL}}')).toBeTruthy();
expect(screen.queryByText('{{INBOUND}}')).toBeNull();
expect(screen.queryByText('{{TRAFFIC_LEFT}}')).toBeNull();
});
it('previews metadata fields with metadata-safe tokens only', () => {
expect(previewRemark('{{EMAIL}}/{{TRAFFIC_LEFT}}', SUBSCRIPTION_METADATA_VARIABLES, true)).toBe('john/{{TRAFFIC_LEFT}}');
});
});