fix(messages): dismiss sync warnings and fill available page height
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { Bubble, Conversations, Sender } from '@ant-design/x';
|
||||
import { Alert, App, Button, Card, Empty, Flex, Form, Input, Modal, Select, Spin, Splitter, Tooltip, Typography } from 'antd';
|
||||
import { CheckCircleOutlined, CloseCircleOutlined, LoadingOutlined, PlusOutlined, QuestionCircleOutlined } from '@ant-design/icons';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { creatorCreate, creatorGet } from '@/services/api';
|
||||
import { conflictMessage, dateTime } from '@/utils/helpers';
|
||||
import {
|
||||
@@ -36,6 +36,9 @@ export default function PrivateMessagesPage() {
|
||||
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||
const accountsRef = useRef<Account[]>([]);
|
||||
const [statuses, setStatuses] = useState<SyncStatus[]>([]);
|
||||
const [dismissedSyncErrors, setDismissedSyncErrors] = useState<Record<string, string>>({});
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const [cardHeight, setCardHeight] = useState<number>();
|
||||
const [accountId, setAccountId] = useState<string>();
|
||||
const [contacts, setContacts] = useState<Conversation[]>([]);
|
||||
const [contactTotal, setContactTotal] = useState(0);
|
||||
@@ -69,6 +72,25 @@ export default function PrivateMessagesPage() {
|
||||
const [sendForm] = Form.useForm<SendValues>();
|
||||
const [newChatError, setNewChatError] = useState('');
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const card = cardRef.current;
|
||||
if (!card?.parentElement) return;
|
||||
const parent = card.parentElement;
|
||||
const resize = () => {
|
||||
const bottomPadding = Number.parseFloat(getComputedStyle(parent).paddingBottom);
|
||||
const top = card.getBoundingClientRect().top + window.scrollY;
|
||||
setCardHeight(Math.max(0, window.innerHeight - top - bottomPadding));
|
||||
};
|
||||
resize();
|
||||
const observer = new ResizeObserver(resize);
|
||||
observer.observe(parent);
|
||||
window.addEventListener('resize', resize);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.removeEventListener('resize', resize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function changeDraft(key: string, text: string) {
|
||||
draftsRef.current = updateDraft(draftsRef.current, key, text);
|
||||
setDrafts(draftsRef.current);
|
||||
@@ -128,6 +150,9 @@ export default function PrivateMessagesPage() {
|
||||
accountsRef.current = accountRows;
|
||||
setAccounts(accountRows);
|
||||
setStatuses(statusRows);
|
||||
setDismissedSyncErrors((previous) => Object.fromEntries(
|
||||
Object.entries(previous).filter(([id, error]) => statusRows.some((status) => status.account_id === id && status.error === error)),
|
||||
));
|
||||
const pages = await Promise.all(Array.from({ length: contactPages.current }, (_, index) =>
|
||||
readPage<Conversation>(`/creator/private-messages/conversations?${conversationQuery({ accountId }, index + 1, PAGE_SIZE)}`)));
|
||||
if (stopped) return;
|
||||
@@ -306,17 +331,22 @@ export default function PrivateMessagesPage() {
|
||||
&& enabledAccounts.some((account) => account.id === contact.account_id));
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Flex vertical gap={16}>
|
||||
<Flex justify="space-between" align="center" gap={16}>
|
||||
<Card ref={cardRef} style={{ height: cardHeight, display: 'flex', flexDirection: 'column' }} styles={{ body: { flex: 1, minHeight: 0 } }}>
|
||||
<Flex vertical gap={16} style={{ height: '100%', minHeight: 0 }}>
|
||||
<Flex justify="space-between" align="center" gap={16} style={{ flexShrink: 0 }}>
|
||||
<Typography.Text type="secondary">{contactTotal} 个私信会话</Typography.Text>
|
||||
<Button type="primary" icon={<PlusOutlined />} disabled={!enabledAccounts.length} onClick={() => { setNewChatError(''); setSendOpen(true); }}>新建私信</Button>
|
||||
</Flex>
|
||||
{contactError && <Alert type="error" showIcon title="私信会话加载失败" description={contactError} />}
|
||||
{statuses.filter((status) => status.error).map((status) => <Alert key={status.account_id} type="warning" showIcon title={`${accountName(status.account_name)}:私信同步异常`} description={status.error} />)}
|
||||
<Splitter style={{ height: 600 }}>
|
||||
{statuses.filter((status) => status.error && dismissedSyncErrors[status.account_id] !== status.error).map((status) => <Alert
|
||||
key={`${status.account_id}:${status.error}`} type="warning" showIcon
|
||||
title={`${accountName(status.account_name)}:私信同步异常`} description={status.error}
|
||||
closable={{ onClose: () => setDismissedSyncErrors((previous) => ({ ...previous, [status.account_id]: status.error })) }}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>)}
|
||||
<Splitter style={{ flex: 1, minHeight: 0 }}>
|
||||
<Splitter.Panel defaultSize="35%" min={240}>
|
||||
<Flex vertical gap={12} style={{ height: '100%', padding: 16 }}>
|
||||
<Flex vertical gap={12} style={{ height: '100%', minHeight: 0, padding: 16 }}>
|
||||
<Select aria-label="所属账号" allowClear placeholder="全部账号" value={accountId} options={accountOptions} onChange={setAccountId} />
|
||||
{contactLoading && <Spin />}
|
||||
{visibleContacts.length ? <Conversations
|
||||
@@ -336,7 +366,7 @@ export default function PrivateMessagesPage() {
|
||||
</Flex>
|
||||
</Splitter.Panel>
|
||||
<Splitter.Panel min={300}>
|
||||
<Flex vertical gap={12} style={{ height: '100%', padding: 16 }}>
|
||||
<Flex vertical gap={12} style={{ height: '100%', minHeight: 0, padding: 16 }}>
|
||||
{active ? <>
|
||||
<Flex justify="space-between" align="center" gap={12}>
|
||||
<Typography.Text strong>{active.peer_name?.trim() || '昵称未获取'}</Typography.Text>
|
||||
@@ -373,7 +403,7 @@ export default function PrivateMessagesPage() {
|
||||
})}
|
||||
style={{ flex: 1, minHeight: 0 }}
|
||||
/>
|
||||
<Sender
|
||||
<Sender style={{ flexShrink: 0 }}
|
||||
value={drafts[conversationKey(active)]?.text || ''}
|
||||
onChange={(value) => changeDraft(conversationKey(active), value)}
|
||||
onSubmit={(value) => sendText(active, value)} submitType="enter"
|
||||
|
||||
@@ -54,6 +54,15 @@ function harness(options = {}) {
|
||||
const file = resolve(__dirname, '../src/pages/creator/messages/index.tsx');
|
||||
const loaded = new Module(file, module);
|
||||
loaded.filename = file;
|
||||
function queueEffect(callback, deps) {
|
||||
const index = cursor++;
|
||||
const prior = effects[index];
|
||||
if (!prior || deps.some((v, i) => !Object.is(v, prior.deps[i]))) {
|
||||
prior?.cleanup?.();
|
||||
effects[index] = { deps };
|
||||
queued.push(() => { effects[index].cleanup = callback(); });
|
||||
}
|
||||
}
|
||||
loaded.require = (id) => {
|
||||
if (id === 'react') return {
|
||||
useRef(initial) {
|
||||
@@ -66,15 +75,8 @@ function harness(options = {}) {
|
||||
if (!(index in states)) states[index] = typeof initial === 'function' ? initial() : initial;
|
||||
return [states[index], (value) => { states[index] = typeof value === 'function' ? value(states[index]) : value; }];
|
||||
},
|
||||
useEffect(callback, deps) {
|
||||
const index = cursor++;
|
||||
const prior = effects[index];
|
||||
if (!prior || deps.some((v, i) => !Object.is(v, prior.deps[i]))) {
|
||||
prior?.cleanup?.();
|
||||
effects[index] = { deps };
|
||||
queued.push(() => { effects[index].cleanup = callback(); });
|
||||
}
|
||||
},
|
||||
useEffect: queueEffect,
|
||||
useLayoutEffect: queueEffect,
|
||||
};
|
||||
if (id === 'react/jsx-runtime') return require(id);
|
||||
if (id === 'antd') return antd;
|
||||
@@ -109,19 +111,25 @@ function harness(options = {}) {
|
||||
};
|
||||
// Timers and UUIDs are isolated per harness; polling never opens a browser.
|
||||
function globals(callback) {
|
||||
const prior = [globalThis.__dmTimers, globalThis.__dmCrypto, globalThis.__dmConsole];
|
||||
const prior = [globalThis.__dmTimers, globalThis.__dmCrypto, globalThis.__dmConsole, globalThis.__dmDOM];
|
||||
globalThis.__dmTimers = { set(fn) { const id = ++timerID; timers.set(id, fn); return id; }, clear(id) { timers.delete(id); } };
|
||||
globalThis.__dmCrypto = { randomUUID: () => `request-${++uuid}` };
|
||||
globalThis.__dmConsole = { error: (...args) => logs.push(args) };
|
||||
globalThis.__dmDOM = options.dom;
|
||||
try { return callback(); } finally {
|
||||
[globalThis.__dmTimers, globalThis.__dmCrypto, globalThis.__dmConsole] = prior;
|
||||
[globalThis.__dmTimers, globalThis.__dmCrypto, globalThis.__dmConsole, globalThis.__dmDOM] = prior;
|
||||
}
|
||||
}
|
||||
// Recompile with the per-instance globals installed so asynchronous callbacks retain them.
|
||||
globals(() => loaded._compile(`const setTimeout = globalThis.__dmTimers.set; const clearTimeout = globalThis.__dmTimers.clear; const crypto = globalThis.__dmCrypto; const console = globalThis.__dmConsole;\n${transpile(file)}`, file));
|
||||
globals(() => loaded._compile(`const setTimeout = globalThis.__dmTimers.set; const clearTimeout = globalThis.__dmTimers.clear; const crypto = globalThis.__dmCrypto; const console = globalThis.__dmConsole; const window = globalThis.__dmDOM?.window; const ResizeObserver = globalThis.__dmDOM?.ResizeObserver; const getComputedStyle = globalThis.__dmDOM?.getComputedStyle;\n${transpile(file)}`, file));
|
||||
const h = {
|
||||
antd, x, calls, sendCalls, logs, notices,
|
||||
render() { cursor = 0; return loaded.exports.default(); },
|
||||
render() {
|
||||
cursor = 0;
|
||||
const tree = loaded.exports.default();
|
||||
if (tree.props.ref && options.cardElement) tree.props.ref.current = options.cardElement;
|
||||
return tree;
|
||||
},
|
||||
async flush() {
|
||||
let tree;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
@@ -209,6 +217,74 @@ test('same peer on different accounts has distinct stable keys, source names and
|
||||
assert.equal(query.get('account_id'), 'b'); assert.equal(query.get('peer_uid'), '100');
|
||||
});
|
||||
|
||||
test('sync warnings can be dismissed per account without hiding changed or recurring errors', async (t) => {
|
||||
let statuses = [{ account_id: 'a', account_name: '账号甲', error: '连接失败' }, { account_id: 'b', account_name: '', error: '需要登录' }];
|
||||
const h = harness({ get: (path) => path === '/creator/private-messages/status' ? statuses : undefined });
|
||||
t.after(h.dispose);
|
||||
const warnings = (tree) => all(tree, (node) => node.type === h.antd.Alert && node.props.title.endsWith('私信同步异常'));
|
||||
let tree = await h.flush();
|
||||
assert.equal(warnings(tree).length, 2);
|
||||
assert.equal(typeof warnings(tree)[0].props.closable?.onClose, 'function');
|
||||
warnings(tree)[0].props.closable.onClose();
|
||||
tree = await h.flush();
|
||||
assert.equal(warnings(tree).length, 1);
|
||||
assert.match(warnings(tree)[0].props.description, /需要登录/);
|
||||
tree = await h.poll();
|
||||
assert.equal(warnings(tree).length, 1);
|
||||
statuses = [{ ...statuses[0], error: '网关不可用' }, statuses[1]];
|
||||
tree = await h.poll();
|
||||
assert.equal(warnings(tree).length, 2);
|
||||
assert.match(warnings(tree)[0].props.description, /网关不可用/);
|
||||
warnings(tree)[0].props.closable.onClose();
|
||||
tree = await h.flush();
|
||||
assert.equal(warnings(tree).length, 1);
|
||||
statuses = [{ ...statuses[0], error: '' }, statuses[1]];
|
||||
await h.poll();
|
||||
statuses = [{ ...statuses[0], error: '网关不可用' }, statuses[1]];
|
||||
tree = await h.poll();
|
||||
assert.equal(warnings(tree).length, 2);
|
||||
assert.equal(statuses[0].error, '网关不可用');
|
||||
assert.equal(h.sendCalls.length, 0);
|
||||
});
|
||||
|
||||
test('chat Card fills available height, responds to viewport and layout changes, and cleans up observers', async (t) => {
|
||||
let top = 200, callback, disconnected = false;
|
||||
const listeners = new Map();
|
||||
const parentElement = {};
|
||||
const dom = {
|
||||
window: { innerHeight: 1000, scrollY: 0, addEventListener: (name, fn) => listeners.set(name, fn), removeEventListener: (name) => listeners.delete(name) },
|
||||
getComputedStyle: (element) => { assert.equal(element, parentElement); return { paddingBottom: '32px' }; },
|
||||
ResizeObserver: class {
|
||||
constructor(fn) { callback = fn; }
|
||||
observe(element) { assert.equal(element, parentElement); }
|
||||
disconnect() { disconnected = true; }
|
||||
},
|
||||
};
|
||||
const h = harness({ dom, cardElement: { parentElement, getBoundingClientRect: () => ({ top }) } });
|
||||
t.after(() => { h.dispose(); assert.equal(listeners.size, 0); assert.equal(disconnected, true); });
|
||||
let tree = await h.flush();
|
||||
assert.equal(tree.props.style.height, 768);
|
||||
assert.equal(tree.props.styles.body.flex, 1);
|
||||
assert.equal(tree.props.styles.body.minHeight, 0);
|
||||
const splitter = find(tree, h.antd.Splitter);
|
||||
assert.equal(splitter.props.style.flex, 1);
|
||||
assert.equal(splitter.props.style.minHeight, 0);
|
||||
assert.equal(splitter.props.style.height, undefined);
|
||||
assert.equal(conversations(h, tree).props.style.overflowY, 'auto');
|
||||
tree = await choose(h, 'a:100');
|
||||
assert.equal(list(h, tree).props.style.minHeight, 0);
|
||||
assert.equal(sender(h, tree).props.style.flexShrink, 0);
|
||||
dom.window.innerHeight = 1200;
|
||||
listeners.get('resize')(); tree = await h.flush();
|
||||
assert.equal(tree.props.style.height, 968);
|
||||
top = 240; callback(); tree = await h.flush();
|
||||
assert.equal(tree.props.style.height, 928);
|
||||
dom.window.scrollY = 8; top = 232; callback(); tree = await h.flush();
|
||||
assert.equal(tree.props.style.height, 928);
|
||||
dom.window.innerHeight = 250; listeners.get('resize')(); tree = await h.flush();
|
||||
assert.equal(tree.props.style.height, 0);
|
||||
});
|
||||
|
||||
test('conversation list and message header show real nicknames and never use UID as a missing-name placeholder', async (t) => {
|
||||
const h = harness({ contacts: [{ ...contacts[0], peer_name: '真实昵称' }, { ...contacts[1], peer_name: '' }] });
|
||||
t.after(h.dispose);
|
||||
|
||||
Reference in New Issue
Block a user