diff --git a/apps/web/src/app-shell.integration.test.tsx b/apps/web/src/app-shell.integration.test.tsx index 774fece..b56da51 100644 --- a/apps/web/src/app-shell.integration.test.tsx +++ b/apps/web/src/app-shell.integration.test.tsx @@ -7,6 +7,7 @@ import { AppShell, type InstanceContext } from './app-shell.js'; import type { AuditDataSource } from './audit/audit-page.js'; import type { EventStreamClient } from './events/event-stream-client.js'; import { type FleetDataSource, type FleetSnapshot } from './fleet/fleet-page.js'; +import type { FleetMessagesDataSource } from './fleet/fleet-messages-api-data-source.js'; import type { InstanceDataSource } from './instances/instance-api-data-source.js'; import type { JobsDataSource } from './jobs/jobs-page.js'; @@ -116,10 +117,27 @@ describe('React AppShell and Fleet vertical slice', () => { delete: remove, }; + const fleetMessagesDataSource: FleetMessagesDataSource = { + load: vi.fn(async (instanceId) => + instanceId === 'bravo' + ? { + latest: { + id: 'sms-1', + direction: 'incoming', + phoneNumber: '13900139000', + content: '这是一条用于聚合页展示的最新短信内容', + timestamp: '2026-07-19T08:30:00.000Z', + }, + } + : {}, + ), + }; + render( snapshot)} + fleetMessagesDataSource={fleetMessagesDataSource} instanceDataSource={instanceDataSource} />, ); @@ -139,8 +157,13 @@ describe('React AppShell and Fleet vertical slice', () => { expect(within(card).getByText('63.2%')).toBeTruthy(); expect(within(card).getByText('46.7 °C')).toBeTruthy(); expect(within(card).getByText('13800138000')).toBeTruthy(); - expect(within(card).getByRole('link', { name: '管理 Bravo' }).getAttribute('href')).toBe( - '/instances/bravo/overview', + expect(await within(card).findByText('收到')).toBeTruthy(); + expect(within(card).getByText('13900139000')).toBeTruthy(); + expect(within(card).getByText(/这是一条用于聚合页展示/)).toBeTruthy(); + expect(within(card).getByText(/2026/)).toBeTruthy(); + expect(within(card).queryByText('能力未知')).toBeNull(); + expect(within(card).getByRole('link', { name: '查看 Bravo 的短信' }).getAttribute('href')).toBe( + '/instances/bravo/messages', ); expect(within(card).getByRole('link', { name: '编辑 Bravo' }).getAttribute('href')).toBe( '/settings/instances/bravo', @@ -154,6 +177,20 @@ describe('React AppShell and Fleet vertical slice', () => { expect(within(card).getByRole('status').textContent).toContain('删除请求已提交'); }); + it('keeps message read failures separate from instance reachability and reports each once', async () => { + const messages: FleetMessagesDataSource = { load: vi.fn().mockRejectedValue(new Error('no')) }; + render( + snapshot)} + fleetMessagesDataSource={messages} + />, + ); + const bravo = await screen.findByRole('article', { name: 'Bravo 实例概览' }); + expect(await within(bravo).findAllByText('短信暂不可用')).toHaveLength(1); + expect(within(bravo).getByText('需要认证')).toBeTruthy(); + }); + it('keeps Jobs and Audit routes compatible without advertising them in global navigation', async () => { const jobsDataSource: JobsDataSource = { load: vi.fn().mockResolvedValue(emptyPage) }; const { rerender } = render( @@ -204,7 +241,7 @@ describe('React AppShell and Fleet vertical slice', () => { expect(screen.getByRole('status').textContent).toContain('正在加载实例'); expect(await screen.findByText('Bravo')).toBeTruthy(); - expect(screen.getByRole('heading', { name: '概览' })).toBeTruthy(); + expect(screen.getByRole('heading', { name: '设备信息' })).toBeTruthy(); expect(screen.getByRole('link', { name: '编辑实例' }).getAttribute('href')).toBe( '/settings/instances/bravo', ); @@ -315,7 +352,7 @@ describe('React AppShell and Fleet vertical slice', () => { const { rerender } = render( , ); - expect(screen.getByRole('heading', { name: '消息' })).toBeTruthy(); + expect(screen.getByRole('heading', { name: '短信' })).toBeTruthy(); expect(screen.getByText('Owner modem')).toBeTruthy(); expect(screen.getByRole('link', { name: '打开源站' }).getAttribute('href')).toBe( 'https://owner.example', diff --git a/apps/web/src/app-shell.tsx b/apps/web/src/app-shell.tsx index e8a01cc..5371de1 100644 --- a/apps/web/src/app-shell.tsx +++ b/apps/web/src/app-shell.tsx @@ -8,6 +8,10 @@ import { createEventStreamClient, type EventStreamClient } from './events/event- import { FleetPage, type FleetDataSource, type FleetSnapshot } from './fleet/fleet-page.js'; import { createFleetApiDataSource } from './fleet/fleet-api-data-source.js'; +import { + createFleetMessagesApiDataSource, + type FleetMessagesDataSource, +} from './fleet/fleet-messages-api-data-source.js'; import { AutomationModule, type AutomationDataSource } from './instances/automation-module.js'; import { CallsModule, type CallsDataSource } from './instances/calls-module.js'; import { CellularModule, type CellularDataSource } from './instances/cellular-module.js'; @@ -79,6 +83,7 @@ export interface AppShellProps { version?: string; instance?: InstanceContext; fleetDataSource?: FleetDataSource; + fleetMessagesDataSource?: FleetMessagesDataSource; fleetData?: FleetSnapshot; instanceDataSource?: InstanceDataSource; capabilities?: InstanceCapabilityMap; @@ -156,6 +161,7 @@ function section(route: ResolvedRoute): GlobalSection | undefined { function Page({ route, fleetDataSource, + fleetMessagesDataSource, fleetData, instance, instanceDataSource, @@ -177,6 +183,7 @@ function Page({ }: { route: ResolvedRoute; fleetDataSource: FleetDataSource | undefined; + fleetMessagesDataSource: FleetMessagesDataSource | undefined; fleetData: FleetSnapshot | undefined; instance: InstanceContext | undefined; instanceDataSource: InstanceDataSource | undefined; @@ -200,6 +207,7 @@ function Page({ return ( createEventStreamClient(), []); const defaultFleetDataSource = useMemo(() => createFleetApiDataSource(), []); + const defaultFleetMessagesDataSource = useMemo(() => createFleetMessagesApiDataSource(), []); const defaultInstanceDataSource = useMemo(() => createInstanceApiDataSource(), []); const defaultMessagesDataSource = useMemo(() => createMessagesApiDataSource(), []); const resolvedJobsDataSource = useMemo( @@ -512,6 +522,7 @@ export function AppShell({ { + it('loads only the newest message through the same-origin read endpoint', async () => { + const fetcher = vi.fn( + async () => + new Response( + JSON.stringify({ + messages: [ + { + id: 'sms-1', + direction: 'incoming', + phoneNumber: '13800138000', + content: '聚合页最新短信', + timestamp: '2026-07-19T08:30:00.000Z', + status: 'received', + }, + ], + }), + { status: 200 }, + ), + ); + + await expect( + createFleetMessagesApiDataSource(fetcher as typeof fetch).load('alpha'), + ).resolves.toEqual({ + latest: { + id: 'sms-1', + direction: 'incoming', + phoneNumber: '13800138000', + content: '聚合页最新短信', + timestamp: '2026-07-19T08:30:00.000Z', + }, + }); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(fetcher).toHaveBeenCalledWith( + '/api/v1/instances/alpha/messages?limit=1&offset=0', + expect.objectContaining({ + method: 'GET', + credentials: 'same-origin', + headers: { accept: 'application/json' }, + }), + ); + }); + + it('returns an empty summary and rejects malformed responses', async () => { + const emptyFetcher = vi.fn(async () => new Response(JSON.stringify({ messages: [] }))); + await expect( + createFleetMessagesApiDataSource(emptyFetcher as typeof fetch).load('alpha'), + ).resolves.toEqual({}); + + const malformedFetcher = vi.fn(async () => new Response(JSON.stringify({ data: [] }))); + await expect( + createFleetMessagesApiDataSource(malformedFetcher as typeof fetch).load('alpha'), + ).rejects.toThrow('Fleet messages response is invalid.'); + }); + + it('bounds fleet fan-out and stops scheduling new owners after abort', async () => { + let active = 0; + let peak = 0; + const releases: (() => void)[] = []; + const load = vi.fn(async (instanceId, signal) => { + active += 1; + peak = Math.max(peak, active); + await new Promise((resolve) => releases.push(resolve)); + active -= 1; + if (signal?.aborted) throw new DOMException('Aborted', 'AbortError'); + return { + latest: { + id: instanceId, + direction: 'incoming', + phoneNumber: '10086', + content: instanceId, + timestamp: '2026-07-19T08:30:00.000Z', + }, + }; + }); + const controller = new AbortController(); + const pending = loadFleetMessageSummaries( + { load }, + ['one', 'two', 'three', 'four', 'five', 'six'], + controller.signal, + ); + + await vi.waitFor(() => expect(load).toHaveBeenCalledTimes(4)); + expect(peak).toBe(4); + controller.abort(); + for (const release of releases) release(); + await expect(pending).resolves.toEqual(new Map()); + expect(load).toHaveBeenCalledTimes(4); + }); +}); diff --git a/apps/web/src/fleet/fleet-messages-api-data-source.ts b/apps/web/src/fleet/fleet-messages-api-data-source.ts new file mode 100644 index 0000000..46e5645 --- /dev/null +++ b/apps/web/src/fleet/fleet-messages-api-data-source.ts @@ -0,0 +1,99 @@ +export interface FleetMessageSummary { + readonly id: string; + readonly direction: string; + readonly phoneNumber: string; + readonly content: string; + readonly timestamp: string; +} + +export interface FleetMessagesSnapshot { + readonly latest?: FleetMessageSummary; +} + +export interface FleetMessagesDataSource { + load(instanceId: string, signal?: AbortSignal): Promise; +} + +export type FleetMessageLoadState = Readonly<{ + latest?: FleetMessageSummary; + unavailable?: true; +}>; + +const FLEET_MESSAGE_CONCURRENCY = 4; +export async function loadFleetMessageSummaries( + dataSource: FleetMessagesDataSource, + instanceIds: readonly string[], + signal: AbortSignal, +): Promise> { + const results = new Map(); + let cursor = 0; + async function worker(): Promise { + while (!signal.aborted) { + const index = cursor++; + const instanceId = instanceIds[index]; + if (instanceId === undefined) return; + try { + const result = await dataSource.load(instanceId, signal); + if (signal.aborted) return; + results.set(instanceId, result.latest ? { latest: result.latest } : {}); + } catch { + if (signal.aborted) return; + results.set(instanceId, { unavailable: true }); + } + } + } + await Promise.all( + Array.from({ length: Math.min(FLEET_MESSAGE_CONCURRENCY, instanceIds.length) }, () => worker()), + ); + return signal.aborted ? new Map() : results; +} + +function record(value: unknown): Record | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function parseMessage(value: unknown): FleetMessageSummary | undefined { + const item = record(value); + if ( + typeof item?.id !== 'string' || + typeof item.direction !== 'string' || + typeof item.phoneNumber !== 'string' || + typeof item.content !== 'string' || + typeof item.timestamp !== 'string' + ) + return undefined; + return { + id: item.id, + direction: item.direction, + phoneNumber: item.phoneNumber, + content: item.content, + timestamp: item.timestamp, + }; +} + +export function createFleetMessagesApiDataSource( + fetcher: typeof fetch = fetch, +): FleetMessagesDataSource { + return { + async load(instanceId, signal): Promise { + const response = await fetcher( + `/api/v1/instances/${encodeURIComponent(instanceId)}/messages?limit=1&offset=0`, + { + method: 'GET', + credentials: 'same-origin', + headers: { accept: 'application/json' }, + ...(signal ? { signal } : {}), + }, + ); + if (!response.ok) throw new Error(`Fleet messages request failed (${response.status}).`); + const root = record(await response.json()); + if (!Array.isArray(root?.messages)) throw new Error('Fleet messages response is invalid.'); + if (root.messages.length === 0) return {}; + const latest = parseMessage(root.messages[0]); + if (!latest) throw new Error('Fleet messages response is invalid.'); + return { latest }; + }, + }; +} diff --git a/apps/web/src/fleet/fleet-page.tsx b/apps/web/src/fleet/fleet-page.tsx index e4b4ee4..8b8773b 100644 --- a/apps/web/src/fleet/fleet-page.tsx +++ b/apps/web/src/fleet/fleet-page.tsx @@ -10,6 +10,11 @@ import { type SortDirection, } from './fleet-table-view-model.js'; import type { InstanceDataSource } from '../instances/instance-crud.js'; +import type { + FleetMessageLoadState, + FleetMessagesDataSource, +} from './fleet-messages-api-data-source.js'; +import { loadFleetMessageSummaries } from './fleet-messages-api-data-source.js'; export interface FleetSnapshot { readonly instances: readonly FleetInstance[]; @@ -20,10 +25,12 @@ export interface FleetDataSource { } export interface FleetPageProps { readonly dataSource?: FleetDataSource; + readonly messagesDataSource?: FleetMessagesDataSource; readonly instanceDataSource?: InstanceDataSource; readonly initialData?: FleetSnapshot; readonly refreshSignal?: number; } +type FleetMessageState = FleetMessageLoadState; const EMPTY_SNAPSHOT: FleetSnapshot = { instances: [], statuses: new Map() }; const STATUS_LABELS: Readonly> = { @@ -69,6 +76,18 @@ const percent = (value: number | undefined): string => value === undefined ? '暂未获取' : `${value.toFixed(1)}%`; const temperature = (value: number | undefined): string => value === undefined ? '暂未获取' : `${value.toFixed(1)} °C`; +const messageDirection = (value: string): string => + value === 'incoming' || value === 'received' + ? '收到' + : value === 'outgoing' || value === 'sent' + ? '发送' + : '未知方向'; +const messageExcerpt = (value: string): string => + value.length > 36 ? `${value.slice(0, 36)}…` : value; +const messageTime = (value: string): string => { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN'); +}; export function canonicalHttpOrigin(value: string): string | null { try { @@ -84,6 +103,7 @@ export function canonicalHttpOrigin(value: string): string | null { export function FleetPage({ dataSource, + messagesDataSource, instanceDataSource, initialData, refreshSignal = 0, @@ -114,6 +134,10 @@ export function FleetPage({ const [deleteStatus, setDeleteStatus] = useState>(); const selectAllRef = useRef(null); + const messagesOwner = useRef(0); + const [messageStates, setMessageStates] = useState>( + new Map(), + ); useEffect(() => { if (initialData && refreshSignal === 0) { @@ -141,6 +165,23 @@ export function FleetPage({ }; }, [attempt, dataSource, initialData, refreshSignal]); + useEffect(() => { + const request = ++messagesOwner.current; + const controller = new AbortController(); + const instances = snapshot?.instances; + setMessageStates(new Map()); + if (!messagesDataSource || !instances) return () => controller.abort(); + void loadFleetMessageSummaries( + messagesDataSource, + instances.map((instance) => instance.id), + controller.signal, + ).then((entries) => { + if (request === messagesOwner.current && !controller.signal.aborted) + setMessageStates(entries); + }); + return () => controller.abort(); + }, [messagesDataSource, snapshot]); + const model = useMemo( () => buildFleetTableViewModel(snapshot?.instances ?? [], snapshot?.statuses ?? new Map(), { @@ -445,21 +486,47 @@ export function FleetPage({
{temperature(row.status?.summary?.resources?.maxTemperatureCelsius)}
-
- {row.capabilities.length > 0 ? ( - row.capabilities.map((item) => ( + {row.capabilities.length > 0 ? ( +
+ {row.capabilities.map((item) => ( {capabilityLabel(item)} - )) + ))} +
+ ) : null} +
+

最近短信

+ {messageStates.get(row.id)?.unavailable ? ( +

短信暂不可用

+ ) : messageStates.get(row.id)?.latest ? ( +
+
+
方向
+
{messageDirection(messageStates.get(row.id)!.latest!.direction)}
+
+
+
号码
+
{messageStates.get(row.id)!.latest!.phoneNumber}
+
+
+
内容
+
{messageExcerpt(messageStates.get(row.id)!.latest!.content)}
+
+
+
时间
+
{messageTime(messageStates.get(row.id)!.latest!.timestamp)}
+
+
) : ( - 能力未知 +

暂无短信

)} -
+
- 管理 + 查看短信 { - it('keeps core overview and message modules actionable while explaining other capability states', () => { + it('converges primary navigation on messages and device information only', () => { render( { />, ); - expect(screen.getByRole('link', { name: '概览' }).getAttribute('href')).toBe( - '/instances/owner/overview', + const navigation = instanceNavigation(); + const links = navigation.getAllByRole('link'); + expect(links).toHaveLength(2); + expect(links.map((link) => [link.textContent, link.getAttribute('href')])).toEqual([ + ['短信', '/instances/owner/messages'], + ['设备信息', '/instances/owner/overview'], + ]); + expect(navigation.queryByText(/蜂窝网络|设备网络|通话|eSIM|通知|自动化|OTA/)).toBeNull(); + expect(screen.getByRole('heading', { name: '设备信息' })).toBeTruthy(); + expect(screen.queryByText('能力状态未知。')).toBeNull(); + expect(screen.queryByText('此调制解调器不支持语音功能。')).toBeNull(); + expect(screen.queryByText('能力探测结果未包含 eSIM。')).toBeNull(); + }); + + it('uses the business titles for both primary pages', () => { + const { rerender } = render( + , ); - expect(screen.getByRole('link', { name: '消息' }).getAttribute('href')).toBe( - '/instances/owner/messages', + expect(screen.getByRole('heading', { name: '短信' })).toBeTruthy(); + expect(screen.queryByRole('heading', { name: '消息' })).toBeNull(); + + rerender( + , ); - expect(screen.queryByText('消息历史记录为只读。')).toBeNull(); - expect(screen.getByText('此调制解调器不支持语音功能。')).toBeTruthy(); - expect(screen.getByText('能力探测结果未包含 eSIM。')).toBeTruthy(); - expect(screen.getAllByText('能力状态未知。').length).toBeGreaterThan(0); + expect(screen.getByRole('heading', { name: '设备信息' })).toBeTruthy(); + expect(screen.queryByRole('heading', { name: '概览' })).toBeNull(); + }); + + it('does not invent an unknown capability placeholder when capability data is absent', () => { + render( + , + ); + + expect(instanceNavigation().getAllByRole('link')).toHaveLength(2); + expect(screen.queryByText('能力状态未知。')).toBeNull(); + }); + + it('hides unknown instance status but keeps known status context', () => { + const unknownOwner: InstanceContext = { ...owner, status: 'unknown' }; + const { rerender } = render( + , + ); + expect(screen.queryByText('状态')).toBeNull(); + expect(screen.queryByText('未知')).toBeNull(); + + rerender( + , + ); + expect(screen.getByText('状态')).toBeTruthy(); + expect(screen.getByText('在线')).toBeTruthy(); }); it('never renders or loads context when the direct-route owner does not match', () => { @@ -100,7 +159,7 @@ describe('InstanceDetail', () => { expect(pending.get('owner')?.signal.aborted).toBe(true); pending.get('second')?.resolve({ overview: { state: 'supported' } }); - expect(await screen.findByRole('link', { name: '概览' })).toBeTruthy(); + expect(await instanceNavigation().findByRole('link', { name: '设备信息' })).toBeTruthy(); pending .get('owner') ?.resolve({ overview: { state: 'unsupported', explanation: 'Stale owner result' } }); @@ -111,17 +170,24 @@ describe('InstanceDetail', () => { it.each<[InstanceModule, string]>([ ['cellular', '蜂窝网络'], ['device-network', '设备网络'], + ['calls', '通话'], + ['esim', 'eSIM'], + ['notifications', '通知'], ['automation', '自动化'], ['ota', 'OTA'], - ])('uses the existing module contract for %s', (module, label) => { + ])('keeps the active legacy deep link and content compatible for %s', (module, label) => { render( 旧深链接内容:{label}

} />, ); + expect(screen.getByRole('heading', { name: label })).toBeTruthy(); + expect(screen.getByText(`旧深链接内容:${label}`)).toBeTruthy(); + expect(instanceNavigation().queryByRole('link', { name: label })).toBeNull(); }); }); diff --git a/apps/web/src/instances/instance-detail.tsx b/apps/web/src/instances/instance-detail.tsx index b256db6..fdf3630 100644 --- a/apps/web/src/instances/instance-detail.tsx +++ b/apps/web/src/instances/instance-detail.tsx @@ -28,10 +28,10 @@ export interface InstanceDetailProps { } export const INSTANCE_MODULE_LABELS: Readonly> = { - overview: '概览', + overview: '设备信息', cellular: '蜂窝网络', 'device-network': '设备网络', - messages: '消息', + messages: '短信', calls: '通话', esim: 'eSIM', notifications: '通知', @@ -39,7 +39,7 @@ export const INSTANCE_MODULE_LABELS: Readonly> = ota: 'OTA', }; -const MODULES = Object.keys(INSTANCE_MODULE_LABELS) as readonly InstanceModule[]; +const PRIMARY_MODULES = ['messages', 'overview'] as const satisfies readonly InstanceModule[]; const DEFAULT_EXPLANATIONS: Readonly, string>> = { degraded: '此模块可用,但功能受限。', @@ -47,20 +47,18 @@ const DEFAULT_EXPLANATIONS: Readonly(['overview', 'messages']); -function canOpen(module: InstanceModule, capability: InstanceCapability): boolean { - return CORE_MODULES.has(module) || canRender(capability); +function canOpen(module: InstanceModule, capability: InstanceCapability | undefined): boolean { + return capability ? canRender(capability) : CORE_MODULES.has(module); } -function explanation(capability: InstanceCapability): string | null { +function explanation(capability: InstanceCapability | undefined): string | null { + if (!capability) return null; if (capability.state === 'supported') return null; return capability.explanation?.trim() || DEFAULT_EXPLANATIONS[capability.state]; } @@ -124,19 +122,21 @@ export function InstanceDetail({ const map = capabilities ?? loadedCapabilities ?? {}; const origin = canonicalHttpOrigin(instance.origin); - const activeCapability = capabilityFor(map, module); + const activeCapability = map[module]; return (
diff --git a/apps/web/src/instances/messages-module.test.tsx b/apps/web/src/instances/messages-module.test.tsx index 758cfd2..e55632f 100644 --- a/apps/web/src/instances/messages-module.test.tsx +++ b/apps/web/src/instances/messages-module.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { cleanup, render, screen } from '@testing-library/react'; +import { cleanup, render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -40,7 +40,7 @@ describe('MessagesModule', () => { expect(document.body.textContent).not.toContain('secret-pdu'); }); - it('provides an intentional send form and submits exactly once', async () => { + it('requires an explicit review step before it submits exactly once', async () => { const user = userEvent.setup(); const send = vi.fn().mockResolvedValue(undefined); const dataSource: MessagesDataSource = { @@ -51,6 +51,11 @@ describe('MessagesModule', () => { await user.type(screen.getByRole('textbox', { name: '手机号' }), '10086'); await user.type(screen.getByRole('textbox', { name: '短信内容' }), 'CXLL'); await user.click(screen.getByRole('button', { name: '发送短信' })); + expect(send).not.toHaveBeenCalled(); + const confirmation = screen.getByRole('region', { name: '确认发送短信' }); + expect(confirmation.textContent).toContain('10086'); + expect(confirmation.textContent).toContain('CXLL'); + await user.click(within(confirmation).getByRole('button', { name: '确认并发送' })); expect(send).toHaveBeenCalledTimes(1); expect(send).toHaveBeenCalledWith('alpha', { phoneNumber: '10086', content: 'CXLL' }); expect((await screen.findByRole('status')).textContent).toContain('短信已提交发送'); diff --git a/apps/web/src/instances/messages-module.tsx b/apps/web/src/instances/messages-module.tsx index c9d74ef..22b6b36 100644 --- a/apps/web/src/instances/messages-module.tsx +++ b/apps/web/src/instances/messages-module.tsx @@ -41,6 +41,7 @@ export function MessagesModule({ instance, dataSource, refreshSignal }: Messages const [retry, setRetry] = useState(0); const [phoneNumber, setPhoneNumber] = useState(''); const [content, setContent] = useState(''); + const [pendingSend, setPendingSend] = useState(); const [sending, setSending] = useState(false); const [sendState, setSendState] = useState<'success' | 'error'>(); @@ -61,13 +62,14 @@ export function MessagesModule({ instance, dataSource, refreshSignal }: Messages return () => controller.abort(); }, [dataSource, instance.id, refreshSignal, retry]); - async function send(): Promise { + async function send(input: SendMessageInput): Promise { if (!dataSource || sending) return; setSending(true); setSendState(undefined); try { - await dataSource.send(instance.id, { phoneNumber, content }); + await dataSource.send(instance.id, input); setContent(''); + setPendingSend(undefined); setSendState('success'); setRetry((value) => value + 1); } catch { @@ -92,7 +94,7 @@ export function MessagesModule({ instance, dataSource, refreshSignal }: Messages
{ event.preventDefault(); - void send(); + setPendingSend({ phoneNumber: phoneNumber.trim(), content: content.trim() }); }} >