🧹 perf: Share Voices Store, Gate Timestamp Ticker, Stabilize Greeting Springs (#14335)

This commit is contained in:
Danny Avila 2026-07-20 22:43:29 -04:00 committed by GitHub
parent eeb4ea226c
commit f4a0e0c194
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 118 additions and 76 deletions

View file

@ -19,6 +19,11 @@ import { useLocalize, useAuthContext } from '~/hooks';
const containerClassName =
'shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white dark:bg-presentation dark:text-white text-black dark:after:shadow-none ';
/** Stable references: fresh literals re-initialized SplitText's springs and
* re-rendered every grapheme span on each Landing render. */
const greetingAnimationFrom = { opacity: 0, transform: 'translate3d(0,50px,0)' };
const greetingAnimationTo = { opacity: 1, transform: 'translate3d(0,0,0)' };
function getTextSizeClass(text: string | undefined | null) {
if (!text) {
return 'text-xl sm:text-2xl';
@ -202,8 +207,8 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
className={`${getTextSizeClass(name)} font-medium text-text-primary`}
delay={50}
textAlign="center"
animationFrom={{ opacity: 0, transform: 'translate3d(0,50px,0)' }}
animationTo={{ opacity: 1, transform: 'translate3d(0,0,0)' }}
animationFrom={greetingAnimationFrom}
animationTo={greetingAnimationTo}
easing={easings.easeOutCubic}
threshold={0}
rootMargin="0px"
@ -217,8 +222,8 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
className={`${getTextSizeClass(greetingText)} font-medium text-text-primary`}
delay={50}
textAlign="center"
animationFrom={{ opacity: 0, transform: 'translate3d(0,50px,0)' }}
animationTo={{ opacity: 1, transform: 'translate3d(0,0,0)' }}
animationFrom={greetingAnimationFrom}
animationTo={greetingAnimationTo}
easing={easings.easeOutCubic}
threshold={0}
rootMargin="0px"

View file

@ -2,23 +2,9 @@ import { useTranslation } from 'react-i18next';
import useTimeTick from '~/hooks/useTimeTick';
import { getMessageTimestamp } from '~/utils';
/**
* Inline message timestamp shown next to the author name in the message header.
* On hover-capable pointers it reveals on row hover/focus; on touch and other
* non-hover devices it stays visible. Recent messages show the relative form
* ("10 minutes ago") with the absolute date on hover; older messages show the
* absolute date directly.
*/
export default function MessageTimestamp({ value }: { value?: string | null }) {
const { i18n } = useTranslation();
// Re-render on a shared interval so relative labels stay current while idle.
useTimeTick();
const timestamp = getMessageTimestamp(value, i18n.language);
if (!timestamp) {
return null;
}
type Timestamp = NonNullable<ReturnType<typeof getMessageTimestamp>>;
function TimestampText({ timestamp }: { timestamp: Timestamp }) {
return (
<time
dateTime={timestamp.iso}
@ -29,3 +15,38 @@ export default function MessageTimestamp({ value }: { value?: string | null }) {
</time>
);
}
/** Only recent timestamps subscribe to the shared minute ticker, so the
* per-minute sweep re-renders a handful of rows instead of every message. */
function RecentTimestamp({ value, language }: { value?: string | null; language: string }) {
useTimeTick();
const timestamp = getMessageTimestamp(value, language);
if (!timestamp) {
return null;
}
return <TimestampText timestamp={timestamp} />;
}
/**
* Inline message timestamp shown next to the author name in the message header.
* On hover-capable pointers it reveals on row hover/focus; on touch and other
* non-hover devices it stays visible. Recent messages show the relative form
* ("10 minutes ago") with the absolute date on hover; older messages show the
* absolute date directly.
*/
export default function MessageTimestamp({ value }: { value?: string | null }) {
const { i18n } = useTranslation();
const timestamp = getMessageTimestamp(value, i18n.language);
if (!timestamp) {
return null;
}
if (timestamp.isRecent) {
return <RecentTimestamp value={value} language={i18n.language} />;
}
return <TimestampText timestamp={timestamp} />;
}

View file

@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { useMemo, useSyncExternalStore } from 'react';
import { useRecoilValue } from 'recoil';
import type { VoiceOption } from '~/common';
import { subscribeSpeechVoices, getSpeechVoicesSnapshot } from '~/utils';
import store from '~/store';
function useTextToSpeechBrowser({
@ -9,63 +10,19 @@ function useTextToSpeechBrowser({
setIsSpeaking: React.Dispatch<React.SetStateAction<boolean>>;
}) {
const voiceName = useRecoilValue(store.voice);
const [voices, setVoices] = useState<VoiceOption[]>([]);
const cloudBrowserVoices = useRecoilValue(store.cloudBrowserVoices);
const [isSpeechSynthesisSupported, setIsSpeechSynthesisSupported] = useState(true);
const { voices: availableVoices, supported: isSpeechSynthesisSupported } = useSyncExternalStore(
subscribeSpeechVoices,
getSpeechVoicesSnapshot,
getSpeechVoicesSnapshot,
);
const updateVoices = useCallback(() => {
const synth = window.speechSynthesis as SpeechSynthesis | undefined;
if (!synth) {
setIsSpeechSynthesisSupported(false);
return;
}
try {
const availableVoices = synth.getVoices();
if (!Array.isArray(availableVoices)) {
console.error('getVoices() did not return an array');
return;
}
const filteredVoices = availableVoices.filter(
(v) => cloudBrowserVoices || v.localService === true,
);
const voiceOptions: VoiceOption[] = filteredVoices.map((v) => ({
value: v.name,
label: v.name,
}));
setVoices(voiceOptions);
} catch (error) {
console.error('Error updating voices:', error);
setIsSpeechSynthesisSupported(false);
}
}, [cloudBrowserVoices]);
useEffect(() => {
const synth = window.speechSynthesis as SpeechSynthesis | undefined;
if (!synth) {
setIsSpeechSynthesisSupported(false);
return;
}
try {
if (synth.getVoices().length) {
updateVoices();
} else {
synth.onvoiceschanged = updateVoices;
}
} catch (error) {
console.error('Error in useEffect:', error);
setIsSpeechSynthesisSupported(false);
}
return () => {
if (synth.onvoiceschanged) {
synth.onvoiceschanged = null;
}
};
}, [updateVoices]);
const voices = useMemo(() => {
const filteredVoices = availableVoices.filter(
(v) => cloudBrowserVoices || v.localService === true,
);
return filteredVoices.map((v): VoiceOption => ({ value: v.name, label: v.name }));
}, [availableVoices, cloudBrowserVoices]);
const generateSpeechLocal = (text: string) => {
if (!isSpeechSynthesisSupported) {

View file

@ -20,6 +20,7 @@ export * from './convos';
export * from './routes';
export * from './presets';
export * from './prompts';
export * from './voices';
export * from './textarea';
export * from './messages';
export * from './focus';

View file

@ -0,0 +1,58 @@
export type SpeechVoicesSnapshot = {
voices: SpeechSynthesisVoice[];
supported: boolean;
};
let snapshot: SpeechVoicesSnapshot = { voices: [], supported: true };
const listeners = new Set<() => void>();
let initialized = false;
const notify = () => {
listeners.forEach((listener) => listener());
};
const readVoices = (synth: SpeechSynthesis) => {
try {
const voices = synth.getVoices();
if (!Array.isArray(voices)) {
console.error('getVoices() did not return an array');
return;
}
snapshot = { voices, supported: true };
notify();
} catch (error) {
console.error('Error updating voices:', error);
snapshot = { voices: [], supported: false };
notify();
}
};
/**
* Module-level speech-synthesis voices store. Every message row mounts a TTS
* button; per-instance `getVoices()` state guaranteed one post-mount re-render
* per row and the instances clobbered each other's `onvoiceschanged` handler
* (last mount won, any unmount nulled it for the rest). One shared listener
* feeds all subscribers instead.
*/
export const subscribeSpeechVoices = (onStoreChange: () => void): (() => void) => {
listeners.add(onStoreChange);
if (!initialized) {
initialized = true;
const synth = window.speechSynthesis as SpeechSynthesis | undefined;
if (!synth) {
snapshot = { voices: [], supported: false };
} else {
readVoices(synth);
try {
synth.addEventListener('voiceschanged', () => readVoices(synth));
} catch (error) {
console.error('Error subscribing to voiceschanged:', error);
}
}
}
return () => {
listeners.delete(onStoreChange);
};
};
export const getSpeechVoicesSnapshot = (): SpeechVoicesSnapshot => snapshot;