diff --git a/apps/legacy/src/components/chat/compose/previewDiffPlanner.test.ts b/apps/legacy/src/components/chat/compose/previewDiffPlanner.test.ts new file mode 100644 index 000000000..5dee08ea8 --- /dev/null +++ b/apps/legacy/src/components/chat/compose/previewDiffPlanner.test.ts @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { PreviewPost } from '../../../api/events.ts'; +import { + buildPreviewDiffPlan, + SEND_KEYFRAME_INTERVAL_MS, + toPreviewSendState, +} from './previewDiffPlanner.ts'; + +const channelId = 'channel-1'; + +const makePreview = (overrides: Partial = {}): PreviewPost => ({ + id: 'preview-1', + channelId, + name: 'Alice', + mediaId: null, + inGame: true, + isAction: false, + text: 'hello', + clear: false, + entities: [{ type: 'Text', start: 0, len: 5 }], + editFor: null, + edit: null, + ...overrides, +}); + +test('legacy preview diff planner emits diff for incremental text change', () => { + const now = 1_000; + const currentPreview = makePreview(); + const currentState = toPreviewSendState(currentPreview, 1, now - 1_000); + + const result = buildPreviewDiffPlan({ + channelId, + currentSendState: currentState, + nextPreview: makePreview({ + text: 'hello world', + entities: [{ type: 'Text', start: 0, len: 11 }], + }), + now, + doNotBroadcast: false, + resetPreview: false, + }); + + assert.strictEqual(result.type, 'DIFF'); + if (result.type !== 'DIFF') return; + assert.deepStrictEqual(result.diff.op, [{ type: 'A', _: ' world' }]); + assert.strictEqual(result.diff.ref, 1); + assert.strictEqual(result.diff.v, 2); +}); + +test('legacy preview diff planner falls back to keyframe for non-broadcast preview', () => { + const currentState = toPreviewSendState(makePreview(), 1, 1_000); + + const result = buildPreviewDiffPlan({ + channelId, + currentSendState: currentState, + nextPreview: makePreview({ text: null, entities: [] }), + now: 2_000, + doNotBroadcast: true, + resetPreview: false, + }); + + assert.deepStrictEqual(result, { type: 'FALLBACK_TO_KEYFRAME' }); +}); + +test('legacy preview diff planner forces periodic keyframe refresh', () => { + const currentState = toPreviewSendState(makePreview(), 1, 1_000); + + const result = buildPreviewDiffPlan({ + channelId, + currentSendState: currentState, + nextPreview: makePreview({ text: 'hello!' }), + now: 1_000 + SEND_KEYFRAME_INTERVAL_MS, + doNotBroadcast: false, + resetPreview: false, + }); + + assert.deepStrictEqual(result, { type: 'FALLBACK_TO_KEYFRAME' }); +}); diff --git a/apps/legacy/src/components/chat/compose/previewDiffPlanner.ts b/apps/legacy/src/components/chat/compose/previewDiffPlanner.ts new file mode 100644 index 000000000..1458837ae --- /dev/null +++ b/apps/legacy/src/components/chat/compose/previewDiffPlanner.ts @@ -0,0 +1,252 @@ +import { type PreviewDiffOp, type PreviewDiffPost, type PreviewPost } from '../../../api/events.ts'; + +export const SEND_KEYFRAME_INTERVAL_MS = 30_000; +export const SEND_KEYFRAME_LARGE_CHANGE_RATIO = 0.7; +export const SEND_KEYFRAME_LARGE_CHANGE_MIN_CHARS = 64; + +const U16_MAX = 65_535; + +export type PreviewKeyframe = { + id: string; + version: number; + name: string; + text: string | null; + entities: PreviewPost['entities']; + inGame: boolean; + isAction: boolean; + edit: PreviewPost['edit']; +}; + +export type PreviewSendState = { + keyframe: PreviewKeyframe; + latestVersion: number; + lastKeyframeAt: number; +}; + +type TextChangeStats = { + changedChars: number; + baselineChars: number; +}; + +type DiffBuildResult = { + ops: PreviewDiffOp[]; + textChangeStats: TextChangeStats | null; +}; + +type PreviewDiffPlanInput = { + channelId: string; + currentSendState: PreviewSendState; + nextPreview: PreviewPost; + now: number; + doNotBroadcast: boolean; + resetPreview: boolean; +}; + +export type PreviewDiffPlan = + | { + type: 'DIFF'; + diff: PreviewDiffPost; + nextState: PreviewSendState; + } + | { + type: 'NOOP'; + } + | { + type: 'FALLBACK_TO_KEYFRAME'; + }; + +const isHighSurrogate = (code: number): boolean => code >= 0xd800 && code <= 0xdbff; +const isLowSurrogate = (code: number): boolean => code >= 0xdc00 && code <= 0xdfff; + +export const equalPreviewEdit = (a: PreviewPost['edit'], b: PreviewPost['edit']): boolean => { + if (a == null && b == null) return true; + if (a == null || b == null) return false; + return a.time === b.time && a.p === b.p && a.q === b.q; +}; + +export const buildDiffOps = ( + keyframe: PreviewKeyframe, + nextPreview: PreviewPost, +): DiffBuildResult | null => { + const buildSplice = ( + baseText: string, + nextText: string, + ): { op: PreviewDiffOp; changedChars: number; baselineChars: number } | null => { + let prefix = 0; + const minLen = Math.min(baseText.length, nextText.length); + while (prefix < minLen && baseText[prefix] === nextText[prefix]) { + prefix += 1; + } + if (prefix === baseText.length && prefix === nextText.length) { + return null; + } + let suffix = 0; + const baseRemain = baseText.length - prefix; + const nextRemain = nextText.length - prefix; + while ( + suffix < baseRemain && + suffix < nextRemain && + baseText[baseText.length - 1 - suffix] === nextText[nextText.length - 1 - suffix] + ) { + suffix += 1; + } + if (prefix > 0 && isHighSurrogate(baseText.charCodeAt(prefix - 1))) { + prefix -= 1; + } + if (suffix > 0 && isLowSurrogate(baseText.charCodeAt(baseText.length - suffix))) { + suffix -= 1; + } + const deleteCount = baseText.length - prefix - suffix; + const insertText = nextText.slice(prefix, nextText.length - suffix); + const changedChars = Math.max(deleteCount, insertText.length); + const baselineChars = Math.max(baseText.length, nextText.length, 1); + if (prefix === baseText.length && deleteCount === 0) { + return { + op: { type: 'A', _: insertText }, + changedChars, + baselineChars, + }; + } + return { + op: { + type: 'SPLICE', + i: prefix, + len: deleteCount, + _: insertText, + }, + changedChars, + baselineChars, + }; + }; + + const ops: PreviewDiffOp[] = []; + let textChangeStats: TextChangeStats | null = null; + if (keyframe.name !== nextPreview.name) { + ops.push({ type: 'NAME', name: nextPreview.name }); + } + if (keyframe.text !== nextPreview.text) { + const baseText = keyframe.text; + const nextText = nextPreview.text; + if (baseText == null || nextText == null) { + return null; + } + const splice = buildSplice(baseText, nextText); + if (splice != null) { + ops.push(splice.op); + textChangeStats = { + changedChars: splice.changedChars, + baselineChars: splice.baselineChars, + }; + } + } + return { ops, textChangeStats }; +}; + +export const shouldFallbackToKeyframe = ( + keyframe: PreviewKeyframe, + nextPreview: PreviewPost, + doNotBroadcast: boolean, + resetPreview: boolean, +): boolean => { + if (keyframe.id !== nextPreview.id) return true; + if (doNotBroadcast || resetPreview) return true; + if (keyframe.inGame !== nextPreview.inGame) return true; + if (keyframe.isAction !== nextPreview.isAction) return true; + if (!equalPreviewEdit(keyframe.edit, nextPreview.edit)) return true; + if (keyframe.text == null || nextPreview.text == null) return true; + return false; +}; + +export const shouldFallbackLargeTextChange = ( + textChangeStats: TextChangeStats | null, + ratio = SEND_KEYFRAME_LARGE_CHANGE_RATIO, + minChars = SEND_KEYFRAME_LARGE_CHANGE_MIN_CHARS, +): boolean => { + if (textChangeStats == null) return false; + return ( + textChangeStats.changedChars >= minChars && + textChangeStats.changedChars / textChangeStats.baselineChars >= ratio + ); +}; + +export const toKeyframe = (preview: PreviewPost, version: number): PreviewKeyframe => ({ + id: preview.id, + version, + name: preview.name, + text: preview.text, + entities: preview.entities, + inGame: preview.inGame ?? false, + isAction: preview.isAction ?? false, + edit: preview.edit, +}); + +export const nextKeyframeVersion = ( + currentSendState: PreviewSendState | null, + previewId: string, +): number => { + if (currentSendState == null || currentSendState.keyframe.id !== previewId) { + return 1; + } + return currentSendState.latestVersion + 1; +}; + +export const toPreviewSendState = ( + preview: PreviewPost, + version: number, + now: number, +): PreviewSendState => ({ + keyframe: toKeyframe(preview, version), + latestVersion: version, + lastKeyframeAt: now, +}); + +export const buildPreviewDiffPlan = ({ + channelId, + currentSendState, + nextPreview, + now, + doNotBroadcast, + resetPreview, +}: PreviewDiffPlanInput): PreviewDiffPlan => { + const shouldForceKeyframe = now - currentSendState.lastKeyframeAt >= SEND_KEYFRAME_INTERVAL_MS; + if ( + shouldForceKeyframe || + shouldFallbackToKeyframe(currentSendState.keyframe, nextPreview, doNotBroadcast, resetPreview) + ) { + return { type: 'FALLBACK_TO_KEYFRAME' }; + } + const diffResult = buildDiffOps(currentSendState.keyframe, nextPreview); + if (diffResult == null) { + return { type: 'FALLBACK_TO_KEYFRAME' }; + } + if (shouldFallbackLargeTextChange(diffResult.textChangeStats)) { + return { type: 'FALLBACK_TO_KEYFRAME' }; + } + if (diffResult.ops.length === 0) { + return { type: 'NOOP' }; + } + const version = currentSendState.latestVersion + 1; + if (version > U16_MAX || currentSendState.keyframe.version > U16_MAX) { + return { type: 'FALLBACK_TO_KEYFRAME' }; + } + for (const op of diffResult.ops) { + if (op.type === 'SPLICE' && (op.i > U16_MAX || op.len > U16_MAX)) { + return { type: 'FALLBACK_TO_KEYFRAME' }; + } + } + const diff: PreviewDiffPost = { + ch: channelId, + id: currentSendState.keyframe.id, + ref: currentSendState.keyframe.version, + v: version, + op: diffResult.ops, + }; + if (nextPreview.entities.length > 0) { + diff.xs = nextPreview.entities; + } + return { + type: 'DIFF', + diff, + nextState: { ...currentSendState, latestVersion: version }, + }; +}; diff --git a/apps/legacy/src/components/chat/compose/useSendPreview.ts b/apps/legacy/src/components/chat/compose/useSendPreview.ts index 15d219a9c..0af8f0cdd 100644 --- a/apps/legacy/src/components/chat/compose/useSendPreview.ts +++ b/apps/legacy/src/components/chat/compose/useSendPreview.ts @@ -1,13 +1,19 @@ -import { useEffect } from 'react'; -import { type PreviewPost } from '../../../api/events'; +import { useEffect, useRef } from 'react'; +import { type ClientEvent, type PreviewPost } from '../../../api/events'; import { useChannelId } from '../../../hooks/useChannelId'; import { useParse } from '../../../hooks/useParse'; -import { useSend } from '../../../hooks/useSend'; import { useSelector } from '../../../store'; +import { + buildPreviewDiffPlan, + nextKeyframeVersion, + type PreviewSendState, + toPreviewSendState, +} from './previewDiffPlanner'; + +const SEND_PREVIEW_TIMEOUT_MS = 200; export const useSendPreview = () => { const channelId = useChannelId(); - const send = useSend(); const initialized = useSelector((state) => state.chatStates.get(channelId)?.initialized ?? false); const parse = useParse(); const compose = useSelector((state) => state.chatStates.get(channelId)!.compose); @@ -15,11 +21,29 @@ export const useSendPreview = () => { const id = messageId; const nickname = useSelector((state) => state.profile?.user.nickname)!; const myMember = useSelector((state) => state.profile?.channels.get(channelId)?.member)!; + const connection = useSelector((state) => state.ui.connection); + const sendTimeoutRef = useRef(undefined); + const sendStateRef = useRef(null); + const previousConnectionRef = useRef(null); + + useEffect(() => { + if (connection == null) { + previousConnectionRef.current = null; + sendStateRef.current = null; + return; + } + if (previousConnectionRef.current !== connection) { + previousConnectionRef.current = connection; + sendStateRef.current = null; + } + }, [connection]); useEffect(() => { if (!initialized) return; + if (connection == null) return; if (!document.hasFocus()) return; - const handle = window.setTimeout(() => { + window.clearTimeout(sendTimeoutRef.current); + sendTimeoutRef.current = window.setTimeout(() => { let name = nickname; if (inGame) { if (inputName) { @@ -40,21 +64,56 @@ export const useSendPreview = () => { text: '', entities: [], }; + const doNotBroadcast = !broadcast || whisperTo != null; if (!broadcast && source.trim() === '') { // clear preview - } else if (!broadcast || whisperTo) { + } else if (doNotBroadcast) { preview.text = null; } else { const { text, entities } = parse(source); preview.text = text; preview.entities = entities; } - send({ type: 'PREVIEW', preview }); - }, 200); - return () => window.clearTimeout(handle); + const resetPreview = preview.text === '' || preview.entities.length === 0; + const currentSendState = sendStateRef.current; + const now = Date.now(); + if (currentSendState != null) { + const diffPlan = buildPreviewDiffPlan({ + channelId, + currentSendState, + nextPreview: preview, + now, + doNotBroadcast, + resetPreview, + }); + if (diffPlan.type === 'DIFF') { + const clientEvent: ClientEvent = { type: 'DIFF', preview: diffPlan.diff }; + if (connection.readyState !== WebSocket.OPEN) { + return; + } + connection.send(JSON.stringify(clientEvent)); + sendStateRef.current = diffPlan.nextState; + return; + } + if (diffPlan.type === 'NOOP') { + return; + } + } + + const keyframeVersion = nextKeyframeVersion(currentSendState, id); + const keyframePreview: PreviewPost = { ...preview, v: keyframeVersion }; + const clientEvent: ClientEvent = { type: 'PREVIEW', preview: keyframePreview }; + if (connection.readyState !== WebSocket.OPEN) { + return; + } + connection.send(JSON.stringify(clientEvent)); + sendStateRef.current = toPreviewSendState(keyframePreview, keyframeVersion, now); + }, SEND_PREVIEW_TIMEOUT_MS); + return () => window.clearTimeout(sendTimeoutRef.current); }, [ broadcast, channelId, + connection, id, inGame, edit, @@ -64,7 +123,6 @@ export const useSendPreview = () => { myMember.characterName, nickname, parse, - send, source, whisperTo, ]);