Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f23eae00c0 | ||
|
|
543b5ffa60 |
@@ -5,4 +5,3 @@ build/
|
|||||||
compile_commands.json
|
compile_commands.json
|
||||||
CMakeLists.txt.user
|
CMakeLists.txt.user
|
||||||
tags
|
tags
|
||||||
project.el
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function HumanAvatar({
|
|||||||
const url = useAvatarUrl(avatarObjectId);
|
const url = useAvatarUrl(avatarObjectId);
|
||||||
return (
|
return (
|
||||||
<Avatar {...props}>
|
<Avatar {...props}>
|
||||||
<AvatarImage src={url} alt={initials} />
|
{url && <AvatarImage src={url} alt={initials} />}
|
||||||
<AvatarFallback className={fallbackClassName}>{initials}</AvatarFallback>
|
<AvatarFallback className={fallbackClassName}>{initials}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import { useObjectUrl } from '@/hooks/use-object-url';
|
|||||||
import { AttachmentStrip } from '@/features/compose/attachment-strip';
|
import { AttachmentStrip } from '@/features/compose/attachment-strip';
|
||||||
import type { PendingAttachment } from '@/features/compose/attachment-strip';
|
import type { PendingAttachment } from '@/features/compose/attachment-strip';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
RECORDING_MAX_DURATION_SECONDS,
|
||||||
|
RECORDING_WARNING_SECONDS,
|
||||||
|
} from '@/lib/constants';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { KeyHint } from '@/components/key-hint';
|
import { KeyHint } from '@/components/key-hint';
|
||||||
import { useComposeIntentStore } from '@/stores/compose-intent-store';
|
import { useComposeIntentStore } from '@/stores/compose-intent-store';
|
||||||
@@ -34,16 +38,50 @@ interface RecordingOverlayProps {
|
|||||||
objectFit?: 'cover' | 'contain';
|
objectFit?: 'cover' | 'contain';
|
||||||
}
|
}
|
||||||
|
|
||||||
function RecordingTimer() {
|
const WARNING_AT_SECONDS =
|
||||||
|
RECORDING_MAX_DURATION_SECONDS - RECORDING_WARNING_SECONDS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks elapsed recording time and drives the time-limit UI. Keeps the
|
||||||
|
* recorder itself unaware of limits: when the cap is reached it dispatches the
|
||||||
|
* standard `stop` intent (the same path as releasing the ` key), which finishes
|
||||||
|
* the recording into the review step.
|
||||||
|
*/
|
||||||
|
function useRecordingCountdown(active: boolean) {
|
||||||
const [elapsed, setElapsed] = useState(0);
|
const [elapsed, setElapsed] = useState(0);
|
||||||
|
const requestIntent = useComposeIntentStore((s) => s.request);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
if (!active) return;
|
||||||
setElapsed((prev) => prev + 1);
|
|
||||||
}, 1000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
let stopped = false;
|
||||||
|
// Tick faster than 1s so the auto-stop lands within ~250ms of the cap, but
|
||||||
|
// only re-render when the whole-second value actually changes.
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const seconds = Math.floor((Date.now() - start) / 1000);
|
||||||
|
setElapsed((prev) => (prev === seconds ? prev : seconds));
|
||||||
|
if (seconds >= RECORDING_MAX_DURATION_SECONDS && !stopped) {
|
||||||
|
stopped = true;
|
||||||
|
requestIntent('stop');
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
setElapsed(0);
|
||||||
|
};
|
||||||
|
}, [active, requestIntent]);
|
||||||
|
|
||||||
|
return { elapsed, isWarning: elapsed >= WARNING_AT_SECONDS };
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecordingTimer({
|
||||||
|
elapsed,
|
||||||
|
isWarning,
|
||||||
|
}: {
|
||||||
|
elapsed: number;
|
||||||
|
isWarning: boolean;
|
||||||
|
}) {
|
||||||
const minutes = Math.floor(elapsed / 60);
|
const minutes = Math.floor(elapsed / 60);
|
||||||
const seconds = elapsed % 60;
|
const seconds = elapsed % 60;
|
||||||
const display = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
const display = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||||
@@ -51,7 +89,14 @@ function RecordingTimer() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-red-500" />
|
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-red-500" />
|
||||||
<span className="font-mono text-sm text-white/80">{display}</span>
|
<span
|
||||||
|
className={cn(
|
||||||
|
'font-mono text-sm text-white/80',
|
||||||
|
isWarning && 'text-red-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{display}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -137,14 +182,31 @@ export function RecordingOverlay({
|
|||||||
const isLoading = isRecording && !mediaStream;
|
const isLoading = isRecording && !mediaStream;
|
||||||
const requestIntent = useComposeIntentStore((s) => s.request);
|
const requestIntent = useComposeIntentStore((s) => s.request);
|
||||||
|
|
||||||
|
const { elapsed, isWarning } = useRecordingCountdown(
|
||||||
|
isRecording && !isLoading,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90',
|
'absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90',
|
||||||
isReviewing && isDragging && 'ring-2 ring-inset ring-white/30',
|
isReviewing && isDragging && 'ring-2 ring-inset ring-white/30',
|
||||||
|
isRecording && isWarning && 'record-warning-glow',
|
||||||
)}
|
)}
|
||||||
{...(isReviewing ? dropZoneProps : {})}
|
{...(isReviewing ? dropZoneProps : {})}
|
||||||
>
|
>
|
||||||
|
{/* Top progress bar: fills over the recording duration, red in warning */}
|
||||||
|
{isRecording && !isLoading && (
|
||||||
|
<div className="absolute inset-x-0 top-0 z-20 h-1 bg-white/10">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-full w-full origin-left record-progress',
|
||||||
|
isWarning ? 'bg-red-500' : 'bg-white/80',
|
||||||
|
)}
|
||||||
|
style={{ animationDuration: `${RECORDING_MAX_DURATION_SECONDS}s` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* Loading state */}
|
{/* Loading state */}
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="z-10 flex flex-col items-center gap-2">
|
<div className="z-10 flex flex-col items-center gap-2">
|
||||||
@@ -185,7 +247,7 @@ export function RecordingOverlay({
|
|||||||
{/* Top center: recording indicator */}
|
{/* Top center: recording indicator */}
|
||||||
<div className="absolute top-8 z-10">
|
<div className="absolute top-8 z-10">
|
||||||
{isRecording && !isLoading ? (
|
{isRecording && !isLoading ? (
|
||||||
<RecordingTimer />
|
<RecordingTimer elapsed={elapsed} isWarning={isWarning} />
|
||||||
) : isReviewing ? (
|
) : isReviewing ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-white/80">Review recording</span>
|
<span className="text-sm text-white/80">Review recording</span>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function StreamListSidebar({
|
|||||||
}, [currentIndex]);
|
}, [currentIndex]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="dark flex w-60 shrink-0 flex-col overflow-hidden border-l border-white/10 bg-zinc-950">
|
<aside className="dark flex max-w-60 shrink-0 flex-col border-l border-white/10 bg-zinc-950">
|
||||||
<div className="flex shrink-0 items-center gap-2 border-b border-white/10 px-4 py-3">
|
<div className="flex shrink-0 items-center gap-2 border-b border-white/10 px-4 py-3">
|
||||||
<List className="size-3.5 text-white/40" />
|
<List className="size-3.5 text-white/40" />
|
||||||
<span className="truncate text-sm font-medium text-white/90 mr-auto">
|
<span className="truncate text-sm font-medium text-white/90 mr-auto">
|
||||||
@@ -105,7 +105,7 @@ function ChatRow({
|
|||||||
if (e.key === 'Enter') onClick();
|
if (e.key === 'Enter') onClick();
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex cursor-pointer items-start gap-2.5 rounded-md px-2 py-2 text-left transition-colors',
|
'flex w-full cursor-pointer items-start gap-2.5 rounded-md px-2 py-2 text-left transition-colors',
|
||||||
isSelected ? 'bg-white/10' : 'hover:bg-white/5',
|
isSelected ? 'bg-white/10' : 'hover:bg-white/5',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -116,7 +116,7 @@ function ChatRow({
|
|||||||
avatarObjectId={sender.avatarObjectId}
|
avatarObjectId={sender.avatarObjectId}
|
||||||
fallbackClassName="bg-white/10 text-white/80 text-[10px] font-medium"
|
fallbackClassName="bg-white/10 text-white/80 text-[10px] font-medium"
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0 flex-1 overflow-hidden">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
<span className="truncate text-xs font-semibold text-white/90">
|
<span className="truncate text-xs font-semibold text-white/90">
|
||||||
{sender.displayName}
|
{sender.displayName}
|
||||||
@@ -141,7 +141,7 @@ function ChatRowContent({ particle }: { particle: Particle }) {
|
|||||||
switch (particle.type) {
|
switch (particle.type) {
|
||||||
case 'text':
|
case 'text':
|
||||||
return (
|
return (
|
||||||
<p className="line-clamp-2 text-xs leading-relaxed [overflow-wrap:anywhere] whitespace-pre-line text-white/70">
|
<p className="line-clamp-2 text-xs leading-relaxed whitespace-pre-line text-white/70">
|
||||||
{particle.properties.content}
|
{particle.properties.content}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ export const MAX_ATTACHMENT_SIZE_BYTES = 25 * 1024 * 1024;
|
|||||||
/** Maximum number of file attachments per particle. */
|
/** Maximum number of file attachments per particle. */
|
||||||
export const MAX_ATTACHMENTS = 10;
|
export const MAX_ATTACHMENTS = 10;
|
||||||
|
|
||||||
|
/** Maximum duration for a media (audio/video) recording, in seconds. */
|
||||||
|
export const RECORDING_MAX_DURATION_SECONDS = 60;
|
||||||
|
|
||||||
|
/** When this many seconds or fewer remain, show the red warning state. */
|
||||||
|
export const RECORDING_WARNING_SECONDS = 10;
|
||||||
|
|
||||||
export const SUPPORT_EMAIL = '[email protected]';
|
export const SUPPORT_EMAIL = '[email protected]';
|
||||||
|
|
||||||
export const PRIVACY_URL = 'https://flowylabs.ai/llink/privacy';
|
export const PRIVACY_URL = 'https://flowylabs.ai/llink/privacy';
|
||||||
|
|||||||
@@ -186,3 +186,39 @@
|
|||||||
.no-drag {
|
.no-drag {
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Recording time-limit progress bar: a single CSS animation fills the bar
|
||||||
|
left→right over the recording duration (duration set inline), avoiding
|
||||||
|
per-frame React renders. */
|
||||||
|
@keyframes record-progress {
|
||||||
|
from {
|
||||||
|
transform: scaleX(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.record-progress {
|
||||||
|
transform: scaleX(0);
|
||||||
|
animation-name: record-progress;
|
||||||
|
animation-timing-function: linear;
|
||||||
|
animation-fill-mode: forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pulsing red glow around the recording overlay during the final warning window. */
|
||||||
|
@keyframes record-warning-glow {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 2px var(--destructive),
|
||||||
|
inset 0 0 24px 0 oklch(0.6 0.24 27 / 0.25);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 3px var(--destructive),
|
||||||
|
inset 0 0 60px 0 oklch(0.6 0.24 27 / 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.record-warning-glow {
|
||||||
|
animation: record-warning-glow 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user