Keep popup instructions readable until continued
Show concise progress during first room create/join; reserve persistent recovery instructions for saved/uncertain attempts. Retain completed-action text until a local Continue button, without gating rules or AI. Preserve unread text across backgrounding, bounded summaries, mute and private-safe feedback. Fix the container QA helper path-import collision. Verified: typecheck, lint, 5200 offline tests, 52 HTTP tests and production build. Delayed/lost-response browser checks prove progress, refresh recovery and exact retry without duplicates. Notice stayed for over 47 seconds while the QA game progressed; Continue advances only presentation. Mobile 16px/44px controls verified. Existing games preserved. Container/deployed acceptance follows publication.
This commit is contained in:
1 parent
c2d392e155
commit
f29d05620c
10 files changed
+238
-115
No files matched your search
@@ -1,15 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useReducer,
|
||||
useSyncExternalStore,
|
||||
} from 'react';
|
||||
import { Bell, BellOff } from 'lucide-react';
|
||||
import { FACTIONS } from '@/game/catalog';
|
||||
import { advanceActionNotices, type AutomaticActionEvent, type QueuedActionNotice } from '@/lib/action-notice-queue';
|
||||
import { initialActionNotices, updateActionNotices, type AutomaticActionEvent, type QueuedActionNotice } from '@/lib/action-notice-queue';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export type { AutomaticActionEvent } from '@/lib/action-notice-queue';
|
||||
@@ -42,89 +40,28 @@ export function AutomaticActionNotice({
|
||||
readPreference,
|
||||
serverPreference,
|
||||
);
|
||||
// The first snapshot establishes a baseline. Refreshing never replays history.
|
||||
const seenSeq = useRef(
|
||||
events.reduce((last, event) => Math.max(last, event.seq), -1),
|
||||
);
|
||||
const queue = useRef<QueuedActionNotice[]>([]);
|
||||
const active = useRef<QueuedActionNotice | null>(null);
|
||||
const [notice, setNotice] = useState<
|
||||
(QueuedActionNotice & { fading: boolean }) | null
|
||||
>(null);
|
||||
const [completed, setCompleted] = useState(0);
|
||||
const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
const clearTimers = useCallback(() => {
|
||||
for (const timer of timers.current) clearTimeout(timer);
|
||||
timers.current = [];
|
||||
}, []);
|
||||
|
||||
const showNext = useCallback(() => {
|
||||
if (active.current) return;
|
||||
if (!readPreference() || document.visibilityState === 'hidden') {
|
||||
queue.current = [];
|
||||
return;
|
||||
}
|
||||
const next = queue.current.shift();
|
||||
if (!next) return;
|
||||
active.current = next;
|
||||
clearTimers();
|
||||
timers.current = [
|
||||
setTimeout(() => {
|
||||
setNotice({ ...next, fading: false });
|
||||
}, 0),
|
||||
setTimeout(() => {
|
||||
setNotice((current) =>
|
||||
current?.id === next.id ? { ...current, fading: true } : current,
|
||||
);
|
||||
}, 1250),
|
||||
setTimeout(() => {
|
||||
active.current = null;
|
||||
setNotice(null);
|
||||
setCompleted((count) => count + 1);
|
||||
}, 1500),
|
||||
];
|
||||
}, [clearTimers]);
|
||||
const [presentation, updatePresentation] = useReducer(updateActionNotices, events, initialActionNotices);
|
||||
|
||||
useEffect(() => {
|
||||
// Preserve the visible notice. Summarize older pending actions so a bot
|
||||
// batch cannot leave minutes of stale feedback; the chronicle keeps detail.
|
||||
const next = advanceActionNotices({ seenSeq: seenSeq.current, pending: queue.current }, events, {
|
||||
active: !!active.current,
|
||||
show: readPreference() && document.visibilityState !== 'hidden',
|
||||
updatePresentation({
|
||||
type: 'events', events, enabled,
|
||||
visible: document.visibilityState !== 'hidden',
|
||||
});
|
||||
seenSeq.current = next.seenSeq;
|
||||
queue.current = next.pending;
|
||||
showNext();
|
||||
}, [events, showNext, completed]);
|
||||
}, [events, enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
const hide = () => {
|
||||
if (document.visibilityState !== 'hidden') return;
|
||||
clearTimers();
|
||||
queue.current = [];
|
||||
active.current = null;
|
||||
setNotice(null);
|
||||
updatePresentation({ type: 'hide' });
|
||||
};
|
||||
document.addEventListener('visibilitychange', hide);
|
||||
return () => document.removeEventListener('visibilitychange', hide);
|
||||
}, [clearTimers]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearTimers();
|
||||
queue.current = [];
|
||||
active.current = null;
|
||||
},
|
||||
[clearTimers],
|
||||
);
|
||||
}, []);
|
||||
|
||||
const toggle = () => {
|
||||
const next = !enabled;
|
||||
memoryPreference = next;
|
||||
clearTimers();
|
||||
queue.current = [];
|
||||
active.current = null;
|
||||
setNotice(null);
|
||||
updatePresentation({ type: 'clear' });
|
||||
try {
|
||||
localStorage.setItem(PREFERENCE_KEY, String(next));
|
||||
memoryPreference = undefined;
|
||||
@@ -133,9 +70,6 @@ export function AutomaticActionNotice({
|
||||
}
|
||||
window.dispatchEvent(new Event(PREFERENCE_EVENT));
|
||||
};
|
||||
const house = notice
|
||||
? FACTIONS.find((candidate) => candidate.id === notice.faction)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -155,26 +89,30 @@ export function AutomaticActionNotice({
|
||||
aria-atomic="true"
|
||||
className="pointer-events-none fixed top-[max(1rem,env(safe-area-inset-top))] left-1/2 z-40 w-[calc(100%-2rem)] max-w-sm -translate-x-1/2"
|
||||
>
|
||||
{enabled && notice && (
|
||||
<span
|
||||
className={`block rounded-xl border border-[#807456] border-l-4 bg-[#141814]/95 px-4 py-3 text-[#f5ecda] shadow-xl transition-opacity duration-250 motion-reduce:opacity-100 motion-reduce:transition-none ${notice.fading ? 'opacity-0' : 'opacity-100'}`}
|
||||
style={{ borderLeftColor: house?.color ?? '#d4b77b' }}
|
||||
>
|
||||
<span
|
||||
className="block text-xs font-semibold tracking-wider uppercase"
|
||||
style={{ color: house?.color ?? '#d4b77b' }}
|
||||
>
|
||||
{house?.name ?? 'Automatic action'}
|
||||
</span>
|
||||
<span className="mt-1 block break-words text-sm font-medium leading-5">
|
||||
{notice.name}
|
||||
</span>
|
||||
{notice.count > 1 && <span className="mt-1 block text-sm leading-5 text-[#d6cdb8]">
|
||||
{notice.count - 1} earlier {notice.count === 2 ? 'action' : 'actions'} also completed. Details are in the table chronicle.
|
||||
</span>}
|
||||
</span>
|
||||
{enabled && presentation.active && (
|
||||
<ActionNoticeCard notice={presentation.active} onContinue={() => updatePresentation({ type: 'continue' })} />
|
||||
)}
|
||||
</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The notice alone accepts input; it does not trap or move keyboard focus. */
|
||||
export function ActionNoticeCard({ notice, onContinue }: { notice: QueuedActionNotice; onContinue: () => void }) {
|
||||
const house = FACTIONS.find((candidate) => candidate.id === notice.faction);
|
||||
return <span
|
||||
className="pointer-events-auto block max-h-[calc(100dvh-2rem)] overflow-y-auto rounded-xl border border-[#807456] border-l-4 bg-[#141814]/95 px-4 py-3 text-[#f5ecda] shadow-xl"
|
||||
style={{ borderLeftColor: house?.color ?? '#d4b77b' }}
|
||||
>
|
||||
<span className="block text-sm font-semibold leading-5 tracking-wider uppercase" style={{ color: house?.color ?? '#d4b77b' }}>
|
||||
{house?.name ?? 'Automatic action'}
|
||||
</span>
|
||||
<span className="mt-1 block break-words text-base font-medium leading-6">{notice.name}</span>
|
||||
{notice.count > 1 && <span className="mt-1 block text-sm leading-5 text-[#d6cdb8]">
|
||||
{notice.count - 1} earlier {notice.count === 2 ? 'action' : 'actions'} also completed. Details are in the table chronicle.
|
||||
</span>}
|
||||
<Button type="button" variant="outline" className="mt-3 min-h-11 max-w-full whitespace-normal" style={{ fontSize: '1rem' }} onClick={onContinue}>
|
||||
Continue
|
||||
</Button>
|
||||
</span>;
|
||||
}
|
||||
Reference in new issue
Block a user