7 Commits

Author SHA1 Message Date
Arjun Patel 044f1a7457 fix nits 2026-06-12 12:20:25 -07:00
Arjun Patel 224b64f86b Merge branch 'main' into ecosystem-prototype 2026-06-12 11:01:10 -07:00
Arjun Patel 353f15d334 cleanup 2026-06-12 10:58:12 -07:00
Arjun Patel 91c973d862 cleanup and add toggle for sidebar 2026-06-12 10:52:04 -07:00
Arjun Patel 22ba0410a5 cleanup folders and events, and condense changes 2026-06-12 10:11:44 -07:00
Arjun Patel 72caee8660 fix folder from root 2026-06-11 22:47:25 -07:00
Arjun Patel 1baae790c6 first attempt at stream sidebar, tasks, and events 2026-06-11 22:38:55 -07:00
9 changed files with 60 additions and 128 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "Flowy.llink",
"productName": "Flowy.llink",
"version": "1.7.1",
"version": "1.6.0",
"description": "Flowy.llink is a video messaging app for teams",
"main": ".vite/build/main.js",
"private": true,
+1 -1
View File
@@ -16,7 +16,7 @@ function ScrollArea({
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&>div]:!w-full"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
@@ -7,10 +7,6 @@ import { useObjectUrl } from '@/hooks/use-object-url';
import { AttachmentStrip } from '@/features/compose/attachment-strip';
import type { PendingAttachment } from '@/features/compose/attachment-strip';
import { cn } from '@/lib/utils';
import {
RECORDING_MAX_DURATION_SECONDS,
RECORDING_WARNING_SECONDS,
} from '@/lib/constants';
import { Button } from '@/components/ui/button';
import { KeyHint } from '@/components/key-hint';
import { useComposeIntentStore } from '@/stores/compose-intent-store';
@@ -38,50 +34,16 @@ interface RecordingOverlayProps {
objectFit?: 'cover' | 'contain';
}
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) {
function RecordingTimer() {
const [elapsed, setElapsed] = useState(0);
const requestIntent = useComposeIntentStore((s) => s.request);
useEffect(() => {
if (!active) return;
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]);
setElapsed((prev) => prev + 1);
}, 1000);
return () => clearInterval(interval);
}, []);
return { elapsed, isWarning: elapsed >= WARNING_AT_SECONDS };
}
function RecordingTimer({
elapsed,
isWarning,
}: {
elapsed: number;
isWarning: boolean;
}) {
const minutes = Math.floor(elapsed / 60);
const seconds = elapsed % 60;
const display = `${minutes}:${seconds.toString().padStart(2, '0')}`;
@@ -89,14 +51,7 @@ function RecordingTimer({
return (
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-red-500" />
<span
className={cn(
'font-mono text-sm text-white/80',
isWarning && 'text-red-400',
)}
>
{display}
</span>
<span className="font-mono text-sm text-white/80">{display}</span>
</div>
);
}
@@ -182,31 +137,14 @@ export function RecordingOverlay({
const isLoading = isRecording && !mediaStream;
const requestIntent = useComposeIntentStore((s) => s.request);
const { elapsed, isWarning } = useRecordingCountdown(
isRecording && !isLoading,
);
return (
<div
className={cn(
'absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90',
isReviewing && isDragging && 'ring-2 ring-inset ring-white/30',
isRecording && isWarning && 'record-warning-glow',
)}
{...(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 */}
{isLoading && (
<div className="z-10 flex flex-col items-center gap-2">
@@ -247,7 +185,7 @@ export function RecordingOverlay({
{/* Top center: recording indicator */}
<div className="absolute top-8 z-10">
{isRecording && !isLoading ? (
<RecordingTimer elapsed={elapsed} isWarning={isWarning} />
<RecordingTimer />
) : isReviewing ? (
<div className="flex items-center gap-2">
<span className="text-sm text-white/80">Review recording</span>
@@ -10,6 +10,7 @@ import { KeyHint } from '@/components/key-hint';
import { ScrollArea } from '@/components/ui/scroll-area';
interface StreamListSidebarProps {
streamName: string;
items: Particle[];
networkId: string;
currentIndex: number;
@@ -23,6 +24,7 @@ interface StreamListSidebarProps {
* nothing auto-advances.
*/
export function StreamListSidebar({
streamName,
items,
networkId,
currentIndex,
@@ -39,12 +41,13 @@ export function StreamListSidebar({
}, [currentIndex]);
return (
<aside className="dark flex max-w-60 shrink-0 flex-col border-l border-white/10 bg-zinc-950">
<aside className="dark flex w-96 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">
<List className="size-3.5 text-white/40" />
<span className="truncate text-sm font-medium text-white/90 mr-auto">
{items.length} messages
<span className="truncate text-sm font-medium text-white/90">
{streamName}
</span>
<span className="ml-auto text-xs text-white/40">{items.length}</span>
<KeyHint
keys="L"
onClick={onToggle}
@@ -141,7 +144,7 @@ function ChatRowContent({ particle }: { particle: Particle }) {
switch (particle.type) {
case 'text':
return (
<p className="line-clamp-2 text-xs leading-relaxed whitespace-pre-line text-white/70">
<p className="line-clamp-3 text-xs leading-relaxed whitespace-pre-line text-white/70">
{particle.properties.content}
</p>
);
@@ -212,7 +212,11 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
platform.autoplay.dismiss();
});
const { mode, toggle: toggleViewMode } = useStreamViewMode();
const userId = useAuthStore((s) => s.user?.id);
const { mode, toggle: toggleViewMode } = useStreamViewMode(
streamParticle,
userId,
);
const {
children,
@@ -568,6 +572,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
{/* Browse sidebar — a separate chat-like panel beside the stream */}
{mode === 'list' && (
<StreamListSidebar
streamName={streamParticle.properties.name}
items={children}
networkId={networkId}
currentIndex={currentIndex}
+4 -5
View File
@@ -145,14 +145,13 @@ export default function SettingsPage() {
aria-label="Change profile picture"
>
<HumanAvatar
className="size-16"
size="lg"
avatarObjectId={user?.avatar_object_id}
initials={initials}
fallbackClassName="bg-primary/10 text-primary text-xl font-medium"
fallbackClassName="bg-primary/10 text-primary font-medium"
/>
<span className="absolute inset-0 flex items-center justify-center rounded-full bg-black/40 opacity-0 transition-opacity group-hover:opacity-100" />
<span className="bg-primary text-primary-foreground ring-background absolute bottom-0 right-0 flex size-5 items-center justify-center rounded-full ring-2">
<Camera className="size-2.5" />
<span className="absolute inset-0 flex items-center justify-center rounded-full bg-black/40 opacity-0 transition-opacity group-hover:opacity-100">
<Camera className="size-4 text-white" />
</span>
</button>
<div className="min-w-0 flex-1">
+34 -5
View File
@@ -1,12 +1,41 @@
import { useCallback, useState } from 'react';
import type { Particle } from '@/api/types';
export type StreamViewMode = 'player' | 'list';
export function useStreamViewMode(): {
mode: StreamViewMode;
toggle: () => void;
} {
const [mode, setMode] = useState<StreamViewMode>('player');
function decideMode(
streamParticle: Particle & { type: 'stream' },
userId: string | undefined,
): StreamViewMode {
const marker = userId ? streamParticle.playback_markers?.[userId] : undefined;
const lastChildAt = streamParticle.last_child_created_at;
const caughtUp =
!!marker && !!lastChildAt && lastChildAt.getTime() <= marker.getTime();
return caughtUp ? 'list' : 'player';
}
/**
* Which mode a stream opens in: the player (autoplay catch-up) when there's
* unseen content, the browsable list when the user is fully caught up.
* Decided once on entry from the playback marker vs. the stream's last
* activity — browsing afterwards advances the marker, but the mode only
* changes via the user's toggle.
*/
export function useStreamViewMode(
streamParticle: Particle & { type: 'stream' },
userId: string | undefined,
): { mode: StreamViewMode; toggle: () => void } {
const [mode, setMode] = useState<StreamViewMode>(() =>
decideMode(streamParticle, userId),
);
// Re-decide when navigating between streams without an unmount.
const [prevStreamId, setPrevStreamId] = useState(streamParticle.id);
if (prevStreamId !== streamParticle.id) {
setPrevStreamId(streamParticle.id);
setMode(decideMode(streamParticle, userId));
}
const toggle = useCallback(() => {
setMode((prev) => (prev === 'player' ? 'list' : 'player'));
}, []);
-6
View File
@@ -4,12 +4,6 @@ export const MAX_ATTACHMENT_SIZE_BYTES = 25 * 1024 * 1024;
/** Maximum number of file attachments per particle. */
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 = 'team@flowylabs.ai';
export const PRIVACY_URL = 'https://flowylabs.ai/llink/privacy';
-36
View File
@@ -186,39 +186,3 @@
.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;
}