|
1 | 1 | import { TextAttributes } from '@opentui/core' |
2 | | -import { useRenderer } from '@opentui/react' |
3 | | -import React, { useEffect, useMemo, useState } from 'react' |
| 2 | +import { useKeyboard, useRenderer } from '@opentui/react' |
| 3 | +import React, { useCallback, useEffect, useMemo, useState } from 'react' |
4 | 4 |
|
5 | 5 | import { AdBanner } from './ad-banner' |
6 | 6 | import { ChoiceAdBanner } from './choice-ad-banner' |
7 | 7 | import { ShimmerText } from './shimmer-text' |
| 8 | +import { endFreebuffSessionBestEffort } from '../hooks/use-freebuff-session' |
8 | 9 | import { useGravityAd } from '../hooks/use-gravity-ad' |
9 | 10 | import { useLogo } from '../hooks/use-logo' |
10 | 11 | import { useSheenAnimation } from '../hooks/use-sheen-animation' |
11 | 12 | import { useTerminalDimensions } from '../hooks/use-terminal-dimensions' |
12 | 13 | import { useTheme } from '../hooks/use-theme' |
| 14 | +import { flushAnalytics } from '../utils/analytics' |
| 15 | +import { withTimeout } from '../utils/terminal-color-detection' |
13 | 16 | import { getLogoAccentColor, getLogoBlockColor } from '../utils/theme-system' |
14 | 17 |
|
15 | 18 | import type { FreebuffSessionResponse } from '../types/freebuff-session' |
| 19 | +import type { KeyEvent } from '@opentui/core' |
| 20 | + |
| 21 | +/** Cap on exit cleanup (DELETE /session + flushAnalytics) so a slow network |
| 22 | + * doesn't block process exit. */ |
| 23 | +const EXIT_CLEANUP_TIMEOUT_MS = 1000 |
16 | 24 |
|
17 | 25 | interface WaitingRoomScreenProps { |
18 | 26 | session: FreebuffSessionResponse | null |
@@ -68,6 +76,24 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({ |
68 | 76 | // Always enable ads in the waiting room — this is where monetization lives. |
69 | 77 | const { ad, adData, recordImpression } = useGravityAd({ enabled: true }) |
70 | 78 |
|
| 79 | + // Ctrl+C exits. Stdin is in raw mode, so SIGINT never fires — the key comes |
| 80 | + // through as a normal OpenTUI key event. Release the seat before exit so |
| 81 | + // the next user in line doesn't have to wait for server-side expiry. |
| 82 | + useKeyboard( |
| 83 | + useCallback((key: KeyEvent) => { |
| 84 | + if (key.ctrl && key.name === 'c') { |
| 85 | + key.preventDefault?.() |
| 86 | + const cleanup = Promise.allSettled([ |
| 87 | + flushAnalytics(), |
| 88 | + endFreebuffSessionBestEffort(), |
| 89 | + ]) |
| 90 | + withTimeout(cleanup, EXIT_CLEANUP_TIMEOUT_MS, undefined).finally(() => { |
| 91 | + process.exit(0) |
| 92 | + }) |
| 93 | + } |
| 94 | + }, []), |
| 95 | + ) |
| 96 | + |
71 | 97 | // Elapsed-in-queue timer. Starts from `queuedAt` so it keeps ticking even if |
72 | 98 | // the user wanders away and comes back. |
73 | 99 | const queuedAtMs = useMemo(() => { |
@@ -127,40 +153,41 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({ |
127 | 153 |
|
128 | 154 | {isQueued && session && ( |
129 | 155 | <> |
130 | | - <text |
131 | | - style={{ fg: theme.foreground, marginBottom: 1 }} |
132 | | - > |
133 | | - <ShimmerText text="You're in the waiting room" /> |
| 156 | + <text style={{ fg: theme.foreground, marginBottom: 1 }}> |
| 157 | + You're in the waiting room |
134 | 158 | </text> |
135 | 159 |
|
136 | 160 | <box |
137 | 161 | style={{ |
138 | 162 | flexDirection: 'column', |
139 | | - alignItems: 'center', |
| 163 | + alignItems: 'flex-start', |
140 | 164 | gap: 0, |
141 | 165 | }} |
142 | 166 | > |
143 | | - <text style={{ fg: theme.foreground }}> |
144 | | - Position{' '} |
145 | | - <span fg={theme.primary} attributes={TextAttributes.BOLD}> |
146 | | - {session.position} |
147 | | - </span> |
148 | | - <span fg={theme.muted}> of {session.queueDepth}</span> |
149 | | - </text> |
150 | | - <text style={{ fg: theme.foreground }}> |
151 | | - Estimated wait:{' '} |
152 | | - <span fg={theme.primary}> |
153 | | - {formatWait(session.estimatedWaitMs)} |
154 | | - </span> |
155 | | - </text> |
156 | | - <text style={{ fg: theme.muted }}> |
157 | | - Waiting for {formatElapsed(elapsedMs)} |
158 | | - </text> |
159 | | - </box> |
160 | | - |
161 | | - <box style={{ marginTop: 1, alignItems: 'center' }}> |
162 | | - <text style={{ fg: theme.muted, wrapMode: 'word' }}> |
163 | | - Leave this window open — we'll ding when your session starts. |
| 167 | + {session.position === 1 ? ( |
| 168 | + <text style={{ fg: theme.primary, alignSelf: 'flex-start' }}> |
| 169 | + <ShimmerText text="Next in line" /> |
| 170 | + </text> |
| 171 | + ) : ( |
| 172 | + <text style={{ fg: theme.foreground, alignSelf: 'flex-start' }}> |
| 173 | + <span fg={theme.muted}>Position </span> |
| 174 | + <span fg={theme.primary} attributes={TextAttributes.BOLD}> |
| 175 | + {session.position} |
| 176 | + </span> |
| 177 | + <span fg={theme.muted}> / {session.queueDepth}</span> |
| 178 | + </text> |
| 179 | + )} |
| 180 | + {session.position !== 1 && ( |
| 181 | + <text style={{ fg: theme.foreground, alignSelf: 'flex-start' }}> |
| 182 | + <span fg={theme.muted}>Wait </span> |
| 183 | + <span fg={theme.primary}> |
| 184 | + <ShimmerText text={formatWait(session.estimatedWaitMs)} /> |
| 185 | + </span> |
| 186 | + </text> |
| 187 | + )} |
| 188 | + <text style={{ fg: theme.muted, alignSelf: 'flex-start' }}> |
| 189 | + <span>Elapsed </span> |
| 190 | + {formatElapsed(elapsedMs)} |
164 | 191 | </text> |
165 | 192 | </box> |
166 | 193 | </> |
|
0 commit comments