Major UI refresh: sidebar redesign, theme expansion, and chat performance optimizations (#706)
## Summary Complete sidebar redesign and comprehensive UI polish pass with performance optimizations, theme system refinements, and desktop integration improvements. ## Key Changes **Sidebar & Navigation Redesign** - Redesigned sessions sidebar layout with unified button primitives - Added activity sections with project grouping and improved session organization - Refined sidebar corners, spacing, and visual hierarchy - Removed NavRail component in favor of streamlined sidebar - Stabilized sessions bar toggle position in fullscreen mode **Performance Optimizations** - Reduced chat streaming CPU usage and storage churn - Optimized task tool polling and live timers with debouncing - Prevented chat state races and reduced background request load - Debounced draft writes and coalesced session reloads - Optimized message store updates and turn tracking **Theme & Visual System** - Added theme-aware window corners (desktop) and border radius tokens - Introduced glassmorphism effects on desktop sidebar - Added backdrop blur to UI elements **Chat Experience** - Added session-based permission auto-accept toggle in chat input - Polished permission shield UX with improved icon sizing and spacing - Fixed chat scroll-to-bottom behavior and timeline tracking - Enhanced tool output display with better path label detection - Removed duplicate draft context details in chat header - Added text selection menu to chat messages **Git Improvements** - Refreshed git history visual design with cleaner dividers - Added remote removal action in sync selector - Stabilized git polling to prevent excessive requests - Improved tool output rendering for git operations **Settings & Panels** - Fixed mobile scrolling on settings pages - Made outside-click settings close instantly - Reduced settings load churn and CPU spikes - Improved services dropdown layout and spacing - Softened panel resize handles **Desktop Integration** - Synced macOS window theme with app theme - Restored window dragging in sidebar header zones - Fixed system window corners on macOS - Improved header session metadata and action controls **Button & Component Standardization** - Unified button primitives across all components - Standardized destructive action patterns - Removed unused button variants (button-large, button-small) - Aligned context tab close hit areas --------- Co-authored-by: Iuliia Ivashko <yulia.ivashko@gmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Iuliia Ivashko
parent
359879153a
commit
321cc7252a
@@ -24,6 +24,12 @@ interface SelectionPayload {
|
||||
|
||||
const DESKTOP_MENU_SIDE_MARGIN_PX = 8;
|
||||
const DESKTOP_MENU_FALLBACK_WIDTH_PX = 280;
|
||||
const BLOCK_TAGS = new Set([
|
||||
'address', 'article', 'aside', 'blockquote', 'dd', 'div', 'dl', 'dt',
|
||||
'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3',
|
||||
'h4', 'h5', 'h6', 'header', 'hr', 'li', 'main', 'nav', 'ol', 'p', 'pre',
|
||||
'section', 'table', 'ul',
|
||||
]);
|
||||
|
||||
const normalizeLineBreaks = (value: string): string => value.replace(/\r\n?/g, '\n');
|
||||
|
||||
@@ -103,6 +109,11 @@ const renderBlockMarkdownNode = (node: Node): string => {
|
||||
return `\`\`\`${language}\n${code}\n\`\`\``;
|
||||
}
|
||||
|
||||
if (tag === 'code') {
|
||||
const code = normalizeLineBreaks(element.textContent || '').trim();
|
||||
return code ? `\`${code.replace(/`/g, '\\`')}\`` : '';
|
||||
}
|
||||
|
||||
if (tag === 'ul') return renderListMarkdown(element, false);
|
||||
if (tag === 'ol') return renderListMarkdown(element, true);
|
||||
|
||||
@@ -137,8 +148,34 @@ const renderBlockMarkdownNode = (node: Node): string => {
|
||||
return trimSelectionValue(Array.from(element.childNodes).map((child) => renderInlineMarkdownNode(child)).join(''));
|
||||
};
|
||||
|
||||
const isInlineSelectionFragment = (fragment: DocumentFragment): boolean => {
|
||||
return Array.from(fragment.childNodes).every((node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return true;
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const element = node as HTMLElement;
|
||||
return !BLOCK_TAGS.has(element.tagName.toLowerCase());
|
||||
});
|
||||
};
|
||||
|
||||
const rangeToMarkdown = (range: Range, plainText: string): string => {
|
||||
const fragment = range.cloneContents();
|
||||
|
||||
if (isInlineSelectionFragment(fragment)) {
|
||||
const inlineMarkdown = trimSelectionValue(
|
||||
Array.from(fragment.childNodes)
|
||||
.map((node) => renderInlineMarkdownNode(node))
|
||||
.join('')
|
||||
);
|
||||
if (inlineMarkdown) {
|
||||
return inlineMarkdown;
|
||||
}
|
||||
}
|
||||
|
||||
const markdown = Array.from(fragment.childNodes)
|
||||
.map((node) => renderBlockMarkdownNode(node))
|
||||
.filter((value) => value.length > 0)
|
||||
@@ -436,6 +473,8 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
)}
|
||||
style={{
|
||||
paddingBottom: 'calc(0.5rem + env(safe-area-inset-bottom, 0px))',
|
||||
backdropFilter: 'blur(28px)',
|
||||
WebkitBackdropFilter: 'blur(28px)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
@@ -507,6 +546,10 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
|
||||
isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
|
||||
)}
|
||||
style={{
|
||||
backdropFilter: 'blur(28px)',
|
||||
WebkitBackdropFilter: 'blur(28px)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={handleAddToChat}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { cn } from '@/lib/utils';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useDurationTickerNow } from './useDurationTicker';
|
||||
|
||||
type PartWithText = Part & { text?: string; content?: string; time?: { start?: number; end?: number } };
|
||||
|
||||
@@ -65,19 +66,7 @@ const formatDuration = (start: number, end?: number, now: number = Date.now()):
|
||||
};
|
||||
|
||||
const LiveDuration: React.FC<{ start: number; end?: number; active: boolean }> = ({ start, end, active }) => {
|
||||
const [now, setNow] = React.useState(() => Date.now());
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
setNow(Date.now());
|
||||
}, 100);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [active]);
|
||||
const now = useDurationTickerNow(active, 250);
|
||||
|
||||
return <>{formatDuration(start, end, now)}</>;
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSessionActivity } from '@/hooks/useSessionActivity';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
||||
import { Text } from '@/components/ui/text';
|
||||
@@ -32,6 +33,7 @@ import { DiffViewToggle, type DiffViewMode } from '../DiffViewToggle';
|
||||
import { MinDurationShineText } from './MinDurationShineText';
|
||||
import { ToolRevealOnMount } from './ToolRevealOnMount';
|
||||
import { getToolIcon } from './toolPresentation';
|
||||
import { useDurationTickerNow } from './useDurationTicker';
|
||||
|
||||
type ToolStateWithMetadata = ToolStateUnion & { metadata?: Record<string, unknown>; input?: Record<string, unknown>; output?: string; error?: string; time?: { start: number; end?: number } };
|
||||
|
||||
@@ -144,6 +146,14 @@ const normalizeToolName = (toolName: string | undefined | null): string => {
|
||||
};
|
||||
|
||||
const MAX_DURATION_MS = 5 * 60 * 1000; // 5 minutes cap
|
||||
const TASK_TOOL_POLL_FAST_MS = 1200;
|
||||
const TASK_TOOL_POLL_IDLE_MS = 3200;
|
||||
const TASK_TOOL_POLL_HIDDEN_MS = 6000;
|
||||
const TASK_TOOL_INITIAL_FETCH_LIMIT = 500;
|
||||
const TASK_TOOL_ACTIVE_FETCH_LIMIT = 160;
|
||||
const TASK_TOOL_IDLE_FETCH_LIMIT = 80;
|
||||
const TASK_TOOL_NO_CHANGE_BACKOFF_AFTER_POLLS = 3;
|
||||
const TASK_TOOL_SETTLE_GRACE_MS = 2500;
|
||||
|
||||
const formatDuration = (start: number, end?: number, now: number = Date.now()) => {
|
||||
const duration = Math.min(Math.max(0, (end ?? now) - start), MAX_DURATION_MS);
|
||||
@@ -154,17 +164,7 @@ const formatDuration = (start: number, end?: number, now: number = Date.now()) =
|
||||
};
|
||||
|
||||
const LiveDuration: React.FC<{ start: number; end?: number; active: boolean }> = ({ start, end, active }) => {
|
||||
const [now, setNow] = React.useState(() => Date.now());
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setInterval(() => {
|
||||
setNow(Date.now());
|
||||
}, 100);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [active]);
|
||||
const now = useDurationTickerNow(active, 250);
|
||||
|
||||
return <>{formatDuration(start, end, now)}</>;
|
||||
};
|
||||
@@ -642,6 +642,41 @@ const buildTaskSummaryEntriesFromSession = (messages: SessionMessageWithParts[])
|
||||
return entries;
|
||||
};
|
||||
|
||||
const buildTaskSessionMessagesSignature = (messages: SessionMessageWithParts[]): string => {
|
||||
if (!Array.isArray(messages) || messages.length === 0) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
const lastMessageId = typeof lastMessage?.info?.id === 'string' ? lastMessage.info.id : '';
|
||||
const lastMessageUpdated =
|
||||
typeof lastMessage?.info?.time?.completed === 'number'
|
||||
? lastMessage.info.time.completed
|
||||
: typeof lastMessage?.info?.time?.created === 'number'
|
||||
? lastMessage.info.time.created
|
||||
: 0;
|
||||
const lastParts = Array.isArray(lastMessage?.parts) ? lastMessage.parts : [];
|
||||
const lastPart = lastParts[lastParts.length - 1] as Record<string, unknown> | undefined;
|
||||
const tailType = typeof lastPart?.type === 'string' ? lastPart.type : '';
|
||||
const tailId = typeof lastPart?.id === 'string' ? lastPart.id : '';
|
||||
const tailTextLength = (() => {
|
||||
const textCandidate = lastPart?.text;
|
||||
if (typeof textCandidate === 'string') {
|
||||
return textCandidate.length;
|
||||
}
|
||||
const stateCandidate = lastPart?.state;
|
||||
if (stateCandidate && typeof stateCandidate === 'object') {
|
||||
const stateStatus = (stateCandidate as Record<string, unknown>).status;
|
||||
if (typeof stateStatus === 'string') {
|
||||
return stateStatus.length;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
})();
|
||||
|
||||
return `${messages.length}:${lastMessageId}:${lastMessageUpdated}:${lastParts.length}:${tailType}:${tailId}:${tailTextLength}`;
|
||||
};
|
||||
|
||||
const getTaskSummaryLabel = (entry: TaskToolSummaryEntry): string => {
|
||||
const title = entry.state?.title;
|
||||
if (typeof title === 'string' && title.trim().length > 0) {
|
||||
@@ -687,7 +722,16 @@ const shouldRenderGitPathLabel = (toolName: string, label: string): boolean => {
|
||||
return false;
|
||||
}
|
||||
|
||||
return trimmed.includes('/') || trimmed.includes('\\');
|
||||
if (trimmed.includes('/') || trimmed.includes('\\')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const baseName = trimmed.split(/[\\/]/).pop() || trimmed;
|
||||
if (baseName.startsWith('.') || baseName.includes('.')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return /^[A-Za-z0-9_-]+$/.test(baseName);
|
||||
};
|
||||
|
||||
const stripTaskMetadataFromOutput = (output: string): string => {
|
||||
@@ -1692,6 +1736,69 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
||||
return false;
|
||||
}, [childSessionMessages, isTaskTool, taskSessionId]);
|
||||
|
||||
const childSessionActivity = useSessionActivity(taskSessionId);
|
||||
const [taskChildSeenActive, setTaskChildSeenActive] = React.useState(false);
|
||||
const [taskChildPollingStopped, setTaskChildPollingStopped] = React.useState(false);
|
||||
|
||||
const taskPollNoChangeCountRef = React.useRef(0);
|
||||
const taskPollLastSignatureRef = React.useRef<string>('');
|
||||
|
||||
React.useEffect(() => {
|
||||
setTaskChildSeenActive(false);
|
||||
setTaskChildPollingStopped(false);
|
||||
taskPollNoChangeCountRef.current = 0;
|
||||
taskPollLastSignatureRef.current = '';
|
||||
}, [taskSessionId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isTaskTool || !taskSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const childSessionIsActive =
|
||||
childSessionActivity.phase === 'busy'
|
||||
|| childSessionActivity.phase === 'retry'
|
||||
|| childSessionHasInFlightTools
|
||||
|| (!isFinalized && activeLatched);
|
||||
|
||||
if (childSessionIsActive) {
|
||||
if (!taskChildSeenActive) {
|
||||
setTaskChildSeenActive(true);
|
||||
}
|
||||
if (taskChildPollingStopped) {
|
||||
setTaskChildPollingStopped(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!taskChildSeenActive || taskChildPollingStopped || childSessionTaskSummaryEntries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
setTaskChildPollingStopped(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
setTaskChildPollingStopped(true);
|
||||
}, TASK_TOOL_SETTLE_GRACE_MS);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [
|
||||
childSessionActivity.phase,
|
||||
childSessionHasInFlightTools,
|
||||
childSessionTaskSummaryEntries.length,
|
||||
activeLatched,
|
||||
isFinalized,
|
||||
isTaskTool,
|
||||
taskChildPollingStopped,
|
||||
taskChildSeenActive,
|
||||
taskSessionId,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof time?.end === 'number' || typeof pinnedTime.end === 'number') {
|
||||
setLocalFinalizedAt(undefined);
|
||||
@@ -1730,7 +1837,10 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldPoll = isActive || childSessionHasInFlightTools || childSessionTaskSummaryEntries.length === 0;
|
||||
const childSessionActive = childSessionActivity.phase === 'busy' || childSessionActivity.phase === 'retry';
|
||||
const shouldPoll =
|
||||
!taskChildPollingStopped
|
||||
&& (isActive || childSessionHasInFlightTools || childSessionActive || childSessionTaskSummaryEntries.length === 0);
|
||||
const shouldFetchSnapshot = childSessionTaskSummaryEntries.length === 0 || shouldPoll;
|
||||
if (!shouldFetchSnapshot) {
|
||||
return;
|
||||
@@ -1739,33 +1849,83 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
||||
let cancelled = false;
|
||||
let pollTimer: number | undefined;
|
||||
|
||||
const fetchSessionMessages = async () => {
|
||||
const isVisible = () => {
|
||||
if (typeof document === 'undefined') {
|
||||
return true;
|
||||
}
|
||||
return document.visibilityState === 'visible';
|
||||
};
|
||||
|
||||
const resolveFetchLimit = (isInitialFetch: boolean) => {
|
||||
if (isInitialFetch && childSessionTaskSummaryEntries.length === 0) {
|
||||
return TASK_TOOL_INITIAL_FETCH_LIMIT;
|
||||
}
|
||||
if (isActive || childSessionHasInFlightTools || childSessionActive) {
|
||||
return TASK_TOOL_ACTIVE_FETCH_LIMIT;
|
||||
}
|
||||
return TASK_TOOL_IDLE_FETCH_LIMIT;
|
||||
};
|
||||
|
||||
const resolvePollDelay = () => {
|
||||
if (!isVisible()) {
|
||||
return TASK_TOOL_POLL_HIDDEN_MS;
|
||||
}
|
||||
if (taskPollNoChangeCountRef.current >= TASK_TOOL_NO_CHANGE_BACKOFF_AFTER_POLLS) {
|
||||
return TASK_TOOL_POLL_IDLE_MS;
|
||||
}
|
||||
return TASK_TOOL_POLL_FAST_MS;
|
||||
};
|
||||
|
||||
const scheduleNextPoll = () => {
|
||||
if (!shouldPoll || typeof window === 'undefined' || cancelled) {
|
||||
return;
|
||||
}
|
||||
pollTimer = window.setTimeout(() => {
|
||||
pollTimer = undefined;
|
||||
void fetchSessionMessages(false);
|
||||
}, resolvePollDelay());
|
||||
};
|
||||
|
||||
const fetchSessionMessages = async (isInitialFetch: boolean) => {
|
||||
try {
|
||||
const messages = await opencodeClient.getSessionMessages(taskSessionId, 500);
|
||||
const messages = await opencodeClient.getSessionMessages(taskSessionId, resolveFetchLimit(isInitialFetch));
|
||||
if (cancelled || !Array.isArray(messages) || messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSignature = buildTaskSessionMessagesSignature(messages as SessionMessageWithParts[]);
|
||||
if (nextSignature === taskPollLastSignatureRef.current) {
|
||||
taskPollNoChangeCountRef.current += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
taskPollLastSignatureRef.current = nextSignature;
|
||||
taskPollNoChangeCountRef.current = 0;
|
||||
useSessionStore.getState().syncMessages(taskSessionId, messages);
|
||||
} catch {
|
||||
// Ignore transient subagent fetch errors.
|
||||
} finally {
|
||||
scheduleNextPoll();
|
||||
}
|
||||
};
|
||||
|
||||
void fetchSessionMessages();
|
||||
|
||||
if (shouldPoll && typeof window !== 'undefined') {
|
||||
pollTimer = window.setInterval(() => {
|
||||
void fetchSessionMessages();
|
||||
}, 1200);
|
||||
}
|
||||
void fetchSessionMessages(true);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (typeof pollTimer === 'number') {
|
||||
window.clearInterval(pollTimer);
|
||||
window.clearTimeout(pollTimer);
|
||||
}
|
||||
};
|
||||
}, [childSessionHasInFlightTools, childSessionTaskSummaryEntries.length, isActive, isTaskTool, taskSessionId]);
|
||||
}, [
|
||||
childSessionActivity.phase,
|
||||
childSessionHasInFlightTools,
|
||||
childSessionTaskSummaryEntries.length,
|
||||
isActive,
|
||||
isTaskTool,
|
||||
taskChildPollingStopped,
|
||||
taskSessionId,
|
||||
]);
|
||||
|
||||
|
||||
const taskSummaryLenRef = React.useRef<number>(taskSummaryEntries.length);
|
||||
|
||||
@@ -25,7 +25,7 @@ export const ToolRevealOnMount: React.FC<ToolRevealOnMountProps> = ({
|
||||
return;
|
||||
}
|
||||
target.style.opacity = '';
|
||||
target.style.filter = '';
|
||||
// target.style.filter = '';
|
||||
target.style.transform = '';
|
||||
target.style.maskImage = '';
|
||||
target.style.webkitMaskImage = '';
|
||||
@@ -61,7 +61,7 @@ export const ToolRevealOnMount: React.FC<ToolRevealOnMountProps> = ({
|
||||
CSS.supports('-webkit-mask-image', 'linear-gradient(to right, black, transparent)'));
|
||||
|
||||
el.style.opacity = '0';
|
||||
el.style.filter = wipe ? 'blur(3px)' : 'blur(2px)';
|
||||
// el.style.filter = wipe ? 'blur(3px)' : 'blur(2px)';
|
||||
el.style.transform = wipe ? 'translateX(-0.06em)' : 'translateY(0.04em)';
|
||||
|
||||
if (maskSupported) {
|
||||
@@ -84,16 +84,15 @@ export const ToolRevealOnMount: React.FC<ToolRevealOnMountProps> = ({
|
||||
|
||||
const keyframes: Keyframe[] = maskSupported
|
||||
? [
|
||||
{ opacity: 0, filter: 'blur(3px)', transform: 'translateX(-0.06em)', maskPosition: '100% 0%' },
|
||||
{ opacity: 1, filter: 'blur(0px)', transform: 'translateX(0)', maskPosition: '0% 0%' },
|
||||
{ opacity: 0, transform: 'translateX(-0.06em)', maskPosition: '100% 0%' },
|
||||
{ opacity: 1, transform: 'translateX(0)', maskPosition: '0% 0%' },
|
||||
]
|
||||
: [
|
||||
{
|
||||
opacity: 0,
|
||||
filter: wipe ? 'blur(3px)' : 'blur(2px)',
|
||||
transform: wipe ? 'translateX(-0.06em)' : 'translateY(0.04em)',
|
||||
},
|
||||
{ opacity: 1, filter: 'blur(0px)', transform: wipe ? 'translateX(0)' : 'translateY(0)' },
|
||||
{ opacity: 1, transform: wipe ? 'translateX(0)' : 'translateY(0)' },
|
||||
];
|
||||
|
||||
animation = node.animate(keyframes, {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
|
||||
type Subscriber = (now: number) => void;
|
||||
|
||||
type TickerChannel = {
|
||||
subscribers: Set<Subscriber>;
|
||||
timerId: number | null;
|
||||
};
|
||||
|
||||
const tickerChannels = new Map<number, TickerChannel>();
|
||||
|
||||
const getTickerChannel = (intervalMs: number): TickerChannel => {
|
||||
const existing = tickerChannels.get(intervalMs);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const created: TickerChannel = {
|
||||
subscribers: new Set<Subscriber>(),
|
||||
timerId: null,
|
||||
};
|
||||
tickerChannels.set(intervalMs, created);
|
||||
return created;
|
||||
};
|
||||
|
||||
const subscribeToTicker = (intervalMs: number, subscriber: Subscriber): (() => void) => {
|
||||
const channel = getTickerChannel(intervalMs);
|
||||
channel.subscribers.add(subscriber);
|
||||
subscriber(Date.now());
|
||||
|
||||
if (channel.timerId === null && typeof window !== 'undefined') {
|
||||
channel.timerId = window.setInterval(() => {
|
||||
const now = Date.now();
|
||||
channel.subscribers.forEach((listener) => {
|
||||
listener(now);
|
||||
});
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
return () => {
|
||||
const tracked = tickerChannels.get(intervalMs);
|
||||
if (!tracked) {
|
||||
return;
|
||||
}
|
||||
|
||||
tracked.subscribers.delete(subscriber);
|
||||
if (tracked.subscribers.size > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (tracked.timerId !== null && typeof window !== 'undefined') {
|
||||
window.clearInterval(tracked.timerId);
|
||||
}
|
||||
tickerChannels.delete(intervalMs);
|
||||
};
|
||||
};
|
||||
|
||||
export const useDurationTickerNow = (active: boolean, intervalMs: number = 250): number => {
|
||||
const [now, setNow] = React.useState(() => Date.now());
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
return subscribeToTicker(intervalMs, setNow);
|
||||
}, [active, intervalMs]);
|
||||
|
||||
return now;
|
||||
};
|
||||
Reference in New Issue
Block a user