diff --git a/lib/client.js b/lib/client.js index e56cfb5..7e9b4ba 100644 --- a/lib/client.js +++ b/lib/client.js @@ -674,6 +674,7 @@ var EN = Object.freeze({ "\u8BBF\u95EE\u6A21\u5F0F": "Access mode", "\u8BBF\u95EE\u8BBE\u7F6E": "Access settings", "Telegram \u8BBF\u95EE\u6A21\u5F0F": "Telegram access mode", + "\u67E5\u770B Telegram \u8BBF\u95EE\u6A21\u5F0F\u8BF4\u660E": "View Telegram access mode details", "\u7FA4\u804A\u5168\u90E8\u5FFD\u7565\uFF0C\u79C1\u804A\u4EC5\u5141\u8BB8\u767D\u540D\u5355\u7528\u6237\u3002": "All group messages are ignored; only allowlisted users may send DMs.", "\u4FDD\u6301\u539F\u6709\u884C\u4E3A\uFF1A\u79C1\u804A\u76F4\u63A5\u54CD\u5E94\uFF0C\u7FA4\u804A\u5728\u88AB\u63D0\u53CA\u6216\u56DE\u590D\u65F6\u54CD\u5E94\u3002": "Keep the original behavior: respond to DMs and to group mentions or replies.", "\u5B89\u5168\u6A21\u5F0F": "Safe mode", @@ -6182,15 +6183,26 @@ var CSS7 = String.raw` .dtg-avatar { color: #fff; background: #229ed9; } .dtg-avatar svg { display: block; } .dtg-access { display: grid; gap: 10px; padding: 12px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 10px; background: var(--dsw-alias-bg-layer-2, #f7f8fa); } -.dtg-accessHeading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } -.dtg-accessHeading strong { font-size: 13px; } -.dtg-accessHeading p { margin: 3px 0 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 1.5; } +.dtg-accessHeading { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.dtg-accessHeading > strong { font-size: 13px; } +.dtg-accessStatus { min-width: 0; display: inline-flex; align-items: center; justify-content: flex-end; gap: 6px; } .dtg-accessBadge { flex: none; padding: 3px 8px; border-radius: 999px; color: #1687bd; background: #eaf7fd; font-size: 11px; font-weight: 700; } .dtg-accessBadge[data-mode="private-allowlist"] { color: #a15c00; background: #fff3d6; } +.dtg-accessHelp { position: relative; display: inline-flex; flex: none; } +.dtg-accessHelpButton { width: 20px; height: 20px; display: grid; place-items: center; padding: 0; border: 1px solid color-mix(in srgb, #229ed9 28%, var(--dsw-alias-border-l2, #dfe1e5)); border-radius: 50%; color: #1687bd; background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 12px; line-height: 1; font-weight: 750; cursor: help; transition: border-color .15s ease, color .15s ease, background .15s ease, box-shadow .15s ease; } +.dtg-accessHelpButton:hover { border-color: #229ed9; color: #1178a8; background: #eaf7fd; } +.dtg-accessHelpButton:focus-visible { outline: none; border-color: #229ed9; box-shadow: 0 0 0 3px color-mix(in srgb, #229ed9 18%, transparent); } +.dtg-accessTooltip { position: absolute; top: calc(100% + 8px); right: 0; z-index: 30; width: 260px; max-width: min(280px, calc(100vw - 48px)); display: grid; gap: 8px; padding: 10px 11px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 9px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-3, #fff); box-shadow: 0 10px 28px rgb(31 35 41 / 16%); opacity: 0; visibility: hidden; transform: translateY(-3px); pointer-events: none; transition: opacity .15s ease, transform .15s ease, visibility .15s ease; } +.dtg-accessTooltipItem { display: grid; gap: 2px; } +.dtg-accessTooltipItem + .dtg-accessTooltipItem { padding-top: 8px; border-top: 1px solid var(--dsw-alias-border-l2, #eef0f3); } +.dtg-accessTooltipItem strong { color: var(--dsw-alias-label-primary, #1f2329); font-size: 12px; line-height: 17px; font-weight: 700; } +.dtg-accessTooltipItem > span { color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 16px; font-weight: 400; } +.dtg-accessHelp:hover .dtg-accessTooltip, .dtg-accessHelp:focus-within .dtg-accessTooltip { opacity: 1; visibility: visible; transform: translateY(0); } .dtg-accessField { display: grid; gap: 5px; color: var(--dsw-alias-label-primary, #1f2329); font-size: 12px; font-weight: 600; } .dtg-accessField select, .dtg-accessField textarea { width: 100%; box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l1, #c9cdd4); border-radius: 7px; color: inherit; background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-weight: 400; } .dtg-accessField select { height: 34px; padding: 0 9px; } .dtg-accessField textarea { min-height: 68px; padding: 8px 9px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } +.dtg-accessField textarea:disabled { color: var(--dsw-alias-label-tertiary, #8f959e); background: var(--dsw-alias-bg-module-platform, #f2f3f5); cursor: not-allowed; resize: none; opacity: 1; } .dtg-accessField small { color: var(--dsw-alias-label-secondary, #646a73); font-weight: 400; } .dtg-accessWarning, .dtg-accessError { margin: 0; font-size: 12px; line-height: 1.5; } .dtg-accessWarning { color: #a15c00; } @@ -6228,6 +6240,7 @@ function allowedUsersFromText(value) { function TelegramAccessSettings({ account, busy = false, onSave }) { const policy = policyFor(account); const sourceUsers = policy.allowedUsers.join("\n"); + const accessHelpId = React14.useId(); const [accessMode, setAccessMode] = React14.useState(policy.accessMode); const [allowedUsers, setAllowedUsers] = React14.useState(sourceUsers); const [error, setError] = React14.useState(null); @@ -6256,16 +6269,45 @@ function TelegramAccessSettings({ account, busy = false, onSave }) { h2( "div", { className: "dtg-accessHeading" }, - h2( - "div", - null, - h2("strong", null, "\u8BBF\u95EE\u8BBE\u7F6E"), - h2("p", null, savedPrivateAllowlist ? "\u7FA4\u804A\u5168\u90E8\u5FFD\u7565\uFF0C\u79C1\u804A\u4EC5\u5141\u8BB8\u767D\u540D\u5355\u7528\u6237\u3002" : "\u4FDD\u6301\u539F\u6709\u884C\u4E3A\uFF1A\u79C1\u804A\u76F4\u63A5\u54CD\u5E94\uFF0C\u7FA4\u804A\u5728\u88AB\u63D0\u53CA\u6216\u56DE\u590D\u65F6\u54CD\u5E94\u3002") - ), + h2("strong", null, "\u8BBF\u95EE\u8BBE\u7F6E"), h2( "span", - { className: "dtg-accessBadge", "data-mode": policy.accessMode }, - savedPrivateAllowlist ? "\u5DF2\u751F\u6548\uFF1A\u5B89\u5168\u6A21\u5F0F" : "\u5DF2\u751F\u6548\uFF1A\u517C\u5BB9\u6A21\u5F0F" + { className: "dtg-accessStatus" }, + h2( + "span", + { className: "dtg-accessBadge", "data-mode": policy.accessMode }, + savedPrivateAllowlist ? "\u5DF2\u751F\u6548\uFF1A\u5B89\u5168\u6A21\u5F0F" : "\u5DF2\u751F\u6548\uFF1A\u517C\u5BB9\u6A21\u5F0F" + ), + h2( + "span", + { className: "dtg-accessHelp" }, + h2("button", { + type: "button", + className: "dtg-accessHelpButton", + "aria-label": "\u67E5\u770B Telegram \u8BBF\u95EE\u6A21\u5F0F\u8BF4\u660E", + "aria-describedby": accessHelpId + }, h2("span", { "aria-hidden": "true" }, "?")), + h2( + "span", + { + id: accessHelpId, + className: "dtg-accessTooltip", + role: "tooltip" + }, + h2( + "span", + { className: "dtg-accessTooltipItem" }, + h2("strong", null, "\u517C\u5BB9\u6A21\u5F0F"), + h2("span", null, "\u4FDD\u6301\u539F\u6709\u884C\u4E3A\uFF1A\u79C1\u804A\u76F4\u63A5\u54CD\u5E94\uFF0C\u7FA4\u804A\u5728\u88AB\u63D0\u53CA\u6216\u56DE\u590D\u65F6\u54CD\u5E94\u3002") + ), + h2( + "span", + { className: "dtg-accessTooltipItem" }, + h2("strong", null, "\u5B89\u5168\u6A21\u5F0F"), + h2("span", null, "\u7FA4\u804A\u5168\u90E8\u5FFD\u7565\uFF0C\u79C1\u804A\u4EC5\u5141\u8BB8\u767D\u540D\u5355\u7528\u6237\u3002") + ) + ) + ) ) ), h2( @@ -6293,7 +6335,7 @@ function TelegramAccessSettings({ account, busy = false, onSave }) { h2("span", null, "\u5141\u8BB8\u79C1\u804A\u7684 Telegram User ID"), h2("textarea", { value: allowedUsers, - disabled: busy, + disabled: busy || !privateAllowlist, rows: 3, placeholder: "\u6BCF\u884C\u4E00\u4E2A\u6570\u5B57 User ID", "aria-label": "\u5141\u8BB8\u79C1\u804A\u7684 Telegram User ID", diff --git a/plugin-src/client/channels/telegram/index.js b/plugin-src/client/channels/telegram/index.js index c2920a0..411c9c6 100644 --- a/plugin-src/client/channels/telegram/index.js +++ b/plugin-src/client/channels/telegram/index.js @@ -29,6 +29,7 @@ function allowedUsersFromText(value) { export function TelegramAccessSettings({ account, busy = false, onSave }) { const policy = policyFor(account); const sourceUsers = policy.allowedUsers.join('\n'); + const accessHelpId = React.useId(); const [accessMode, setAccessMode] = React.useState(policy.accessMode); const [allowedUsers, setAllowedUsers] = React.useState(sourceUsers); const [error, setError] = React.useState(null); @@ -56,13 +57,28 @@ export function TelegramAccessSettings({ account, busy = false, onSave }) { const emptyAllowlist = privateAllowlist && allowedUsers.trim() === ''; return h('form', { className: 'dtg-access', onSubmit: save }, h('div', { className: 'dtg-accessHeading' }, - h('div', null, - h('strong', null, '访问设置'), - h('p', null, savedPrivateAllowlist - ? '群聊全部忽略,私聊仅允许白名单用户。' - : '保持原有行为:私聊直接响应,群聊在被提及或回复时响应。')), - h('span', { className: 'dtg-accessBadge', 'data-mode': policy.accessMode }, - savedPrivateAllowlist ? '已生效:安全模式' : '已生效:兼容模式')), + h('strong', null, '访问设置'), + h('span', { className: 'dtg-accessStatus' }, + h('span', { className: 'dtg-accessBadge', 'data-mode': policy.accessMode }, + savedPrivateAllowlist ? '已生效:安全模式' : '已生效:兼容模式'), + h('span', { className: 'dtg-accessHelp' }, + h('button', { + type: 'button', + className: 'dtg-accessHelpButton', + 'aria-label': '查看 Telegram 访问模式说明', + 'aria-describedby': accessHelpId, + }, h('span', { 'aria-hidden': 'true' }, '?')), + h('span', { + id: accessHelpId, + className: 'dtg-accessTooltip', + role: 'tooltip', + }, + h('span', { className: 'dtg-accessTooltipItem' }, + h('strong', null, '兼容模式'), + h('span', null, '保持原有行为:私聊直接响应,群聊在被提及或回复时响应。')), + h('span', { className: 'dtg-accessTooltipItem' }, + h('strong', null, '安全模式'), + h('span', null, '群聊全部忽略,私聊仅允许白名单用户。')))))), h('label', { className: 'dtg-accessField' }, h('span', null, '模式'), h('select', { @@ -77,7 +93,7 @@ export function TelegramAccessSettings({ account, busy = false, onSave }) { h('span', null, '允许私聊的 Telegram User ID'), h('textarea', { value: allowedUsers, - disabled: busy, + disabled: busy || !privateAllowlist, rows: 3, placeholder: '每行一个数字 User ID', 'aria-label': '允许私聊的 Telegram User ID', diff --git a/plugin-src/client/channels/telegram/styles.js b/plugin-src/client/channels/telegram/styles.js index 136d5df..806c3f1 100644 --- a/plugin-src/client/channels/telegram/styles.js +++ b/plugin-src/client/channels/telegram/styles.js @@ -5,15 +5,26 @@ const CSS = String.raw` .dtg-avatar { color: #fff; background: #229ed9; } .dtg-avatar svg { display: block; } .dtg-access { display: grid; gap: 10px; padding: 12px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 10px; background: var(--dsw-alias-bg-layer-2, #f7f8fa); } -.dtg-accessHeading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } -.dtg-accessHeading strong { font-size: 13px; } -.dtg-accessHeading p { margin: 3px 0 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 1.5; } +.dtg-accessHeading { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.dtg-accessHeading > strong { font-size: 13px; } +.dtg-accessStatus { min-width: 0; display: inline-flex; align-items: center; justify-content: flex-end; gap: 6px; } .dtg-accessBadge { flex: none; padding: 3px 8px; border-radius: 999px; color: #1687bd; background: #eaf7fd; font-size: 11px; font-weight: 700; } .dtg-accessBadge[data-mode="private-allowlist"] { color: #a15c00; background: #fff3d6; } +.dtg-accessHelp { position: relative; display: inline-flex; flex: none; } +.dtg-accessHelpButton { width: 20px; height: 20px; display: grid; place-items: center; padding: 0; border: 1px solid color-mix(in srgb, #229ed9 28%, var(--dsw-alias-border-l2, #dfe1e5)); border-radius: 50%; color: #1687bd; background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 12px; line-height: 1; font-weight: 750; cursor: help; transition: border-color .15s ease, color .15s ease, background .15s ease, box-shadow .15s ease; } +.dtg-accessHelpButton:hover { border-color: #229ed9; color: #1178a8; background: #eaf7fd; } +.dtg-accessHelpButton:focus-visible { outline: none; border-color: #229ed9; box-shadow: 0 0 0 3px color-mix(in srgb, #229ed9 18%, transparent); } +.dtg-accessTooltip { position: absolute; top: calc(100% + 8px); right: 0; z-index: 30; width: 260px; max-width: min(280px, calc(100vw - 48px)); display: grid; gap: 8px; padding: 10px 11px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 9px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-3, #fff); box-shadow: 0 10px 28px rgb(31 35 41 / 16%); opacity: 0; visibility: hidden; transform: translateY(-3px); pointer-events: none; transition: opacity .15s ease, transform .15s ease, visibility .15s ease; } +.dtg-accessTooltipItem { display: grid; gap: 2px; } +.dtg-accessTooltipItem + .dtg-accessTooltipItem { padding-top: 8px; border-top: 1px solid var(--dsw-alias-border-l2, #eef0f3); } +.dtg-accessTooltipItem strong { color: var(--dsw-alias-label-primary, #1f2329); font-size: 12px; line-height: 17px; font-weight: 700; } +.dtg-accessTooltipItem > span { color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 16px; font-weight: 400; } +.dtg-accessHelp:hover .dtg-accessTooltip, .dtg-accessHelp:focus-within .dtg-accessTooltip { opacity: 1; visibility: visible; transform: translateY(0); } .dtg-accessField { display: grid; gap: 5px; color: var(--dsw-alias-label-primary, #1f2329); font-size: 12px; font-weight: 600; } .dtg-accessField select, .dtg-accessField textarea { width: 100%; box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l1, #c9cdd4); border-radius: 7px; color: inherit; background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-weight: 400; } .dtg-accessField select { height: 34px; padding: 0 9px; } .dtg-accessField textarea { min-height: 68px; padding: 8px 9px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } +.dtg-accessField textarea:disabled { color: var(--dsw-alias-label-tertiary, #8f959e); background: var(--dsw-alias-bg-module-platform, #f2f3f5); cursor: not-allowed; resize: none; opacity: 1; } .dtg-accessField small { color: var(--dsw-alias-label-secondary, #646a73); font-weight: 400; } .dtg-accessWarning, .dtg-accessError { margin: 0; font-size: 12px; line-height: 1.5; } .dtg-accessWarning { color: #a15c00; } diff --git a/plugin-src/client/i18n.js b/plugin-src/client/i18n.js index d8566a6..90d482c 100644 --- a/plugin-src/client/i18n.js +++ b/plugin-src/client/i18n.js @@ -284,6 +284,7 @@ const EN = Object.freeze({ '访问模式': 'Access mode', '访问设置': 'Access settings', 'Telegram 访问模式': 'Telegram access mode', + '查看 Telegram 访问模式说明': 'View Telegram access mode details', '群聊全部忽略,私聊仅允许白名单用户。': 'All group messages are ignored; only allowlisted users may send DMs.', '保持原有行为:私聊直接响应,群聊在被提及或回复时响应。': 'Keep the original behavior: respond to DMs and to group mentions or replies.', '安全模式': 'Safe mode', diff --git a/test/channels/telegram/client-ui.test.mjs b/test/channels/telegram/client-ui.test.mjs index e8a0aba..78a72d2 100644 --- a/test/channels/telegram/client-ui.test.mjs +++ b/test/channels/telegram/client-ui.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; import test from 'node:test'; import React from 'react'; @@ -62,13 +63,36 @@ test('Telegram access settings edits and saves one bot policy', async () => { }); const select = renderer.root.findByProps({ 'aria-label': 'Telegram 访问模式' }); - const textarea = renderer.root.findByProps({ + let textarea = renderer.root.findByProps({ 'aria-label': '允许私聊的 Telegram User ID', }); + assert.equal(textarea.props.disabled, true); await act(async () => { select.props.onChange({ target: { value: 'private-allowlist' } }); + }); + textarea = renderer.root.findByProps({ + 'aria-label': '允许私聊的 Telegram User ID', + }); + assert.equal(textarea.props.disabled, false); + await act(async () => { textarea.props.onChange({ target: { value: '6087707998\n1202499116\n6087707998' } }); }); + await act(async () => { + select.props.onChange({ target: { value: 'compatible' } }); + }); + textarea = renderer.root.findByProps({ + 'aria-label': '允许私聊的 Telegram User ID', + }); + assert.equal(textarea.props.disabled, true); + assert.equal(textarea.props.value, '6087707998\n1202499116\n6087707998'); + await act(async () => { + select.props.onChange({ target: { value: 'private-allowlist' } }); + }); + textarea = renderer.root.findByProps({ + 'aria-label': '允许私聊的 Telegram User ID', + }); + assert.equal(textarea.props.disabled, false); + assert.equal(textarea.props.value, '6087707998\n1202499116\n6087707998'); assert.deepEqual( renderer.root.findByProps({ className: 'dtg-accessBadge' }).children, ['已生效:兼容模式'], @@ -83,6 +107,50 @@ test('Telegram access settings edits and saves one bot policy', async () => { await act(async () => renderer.unmount()); }); +test('Telegram access settings keeps both mode descriptions in an accessible help tooltip', async () => { + let renderer; + await act(async () => { + renderer = TestRenderer.create(React.createElement(TelegramAccessSettings, { + account: { + botId: 'telegram_test', + accessPolicy: { accessMode: 'compatible', allowedUsers: ['111111111'] }, + }, + onSave() {}, + })); + }); + const helpButton = renderer.root.findByProps({ + 'aria-label': '查看 Telegram 访问模式说明', + }); + const tooltip = renderer.root.findByProps({ role: 'tooltip' }); + const heading = renderer.root.findByProps({ className: 'dtg-accessHeading' }); + assert.equal(helpButton.props.type, 'button'); + assert.ok(tooltip.props.id); + assert.equal(helpButton.props['aria-describedby'], tooltip.props.id); + assert.equal(heading.findAllByType('p').length, 0); + + const markup = renderToStaticMarkup(React.createElement(TelegramAccessSettings, { + account: { + botId: 'telegram_test', + accessPolicy: { accessMode: 'compatible', allowedUsers: ['111111111'] }, + }, + onSave() {}, + })); + assert.match(markup, />兼容模式<\/strong>/); + assert.match(markup, />安全模式<\/strong>/); + assert.match(markup, /保持原有行为:私聊直接响应,群聊在被提及或回复时响应。/); + assert.match(markup, /群聊全部忽略,私聊仅允许白名单用户。/); + await act(async () => renderer.unmount()); +}); + +test('Telegram access mode help opens for pointer hover and keyboard focus', async () => { + const styles = await readFile( + new URL('../../../plugin-src/client/channels/telegram/styles.js', import.meta.url), + 'utf8', + ); + assert.match(styles, /\.dtg-accessHelpButton:focus-visible \{/); + assert.match(styles, /\.dtg-accessHelp:hover \.dtg-accessTooltip, \.dtg-accessHelp:focus-within \.dtg-accessTooltip \{[^}]*opacity: 1;[^}]*visibility: visible;/); +}); + test('Telegram access settings warns when safe mode has an empty allowlist', () => { const markup = renderToStaticMarkup(React.createElement(TelegramAccessSettings, { account: {