Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/src/routes/app/main/_layout.index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ function Component() {

return (
<div
className="flex h-full gap-1 overflow-hidden p-1"
className="flex h-full gap-1 overflow-hidden bg-stone-50 p-1"
data-testid="main-app-shell"
>
{leftsidebar.expanded && !isOnboarding && <LeftSidebar />}
Expand Down
84 changes: 13 additions & 71 deletions apps/desktop/src/session/components/outer-header/listen.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { useHover } from "@uidotdev/usehooks";
import { MicOff } from "lucide-react";
import { useCallback } from "react";

Expand All @@ -12,7 +11,6 @@ import { cn } from "@hypr/utils";

import {
ActionableTooltipContent,
RecordingIcon,
useHasTranscript,
useListenButtonState,
} from "~/session/components/shared";
Expand All @@ -25,7 +23,7 @@ export function ListenButton({ sessionId }: { sessionId: string }) {
const hasTranscript = useHasTranscript(sessionId);

if (!shouldRender) {
return <InMeetingIndicator sessionId={sessionId} />;
return <DancingSticksIndicator sessionId={sessionId} />;
}

if (hasTranscript) {
Expand Down Expand Up @@ -57,7 +55,6 @@ function StartButton({ sessionId }: { sessionId: string }) {
"disabled:pointer-events-none disabled:opacity-50",
])}
>
<RecordingIcon />
<span className="whitespace-nowrap text-neutral-900 hover:text-neutral-800">
Resume listening
</span>
Expand Down Expand Up @@ -95,83 +92,28 @@ function StartButton({ sessionId }: { sessionId: string }) {
);
}

function InMeetingIndicator({ sessionId }: { sessionId: string }) {
const [ref, hovered] = useHover();

const { mode, stop, amplitude, muted } = useListener((state) => ({
function DancingSticksIndicator({ sessionId }: { sessionId: string }) {
const { mode, amplitude, muted } = useListener((state) => ({
mode: state.getSessionMode(sessionId),
stop: state.stop,
amplitude: state.live.amplitude,
muted: state.live.muted,
}));

const active = mode === "active" || mode === "finalizing";
const finalizing = mode === "finalizing";
const active = mode === "active";

if (!active) {
return null;
}

return (
<Tooltip>
<TooltipTrigger asChild>
<button
ref={ref as React.Ref<HTMLButtonElement>}
type="button"
onClick={finalizing ? undefined : stop}
disabled={finalizing}
className={cn([
"inline-flex items-center justify-center rounded-md text-sm font-medium",
finalizing
? ["text-neutral-500", "bg-neutral-100", "cursor-wait"]
: [
"text-red-500 hover:text-red-600",
"bg-red-50 hover:bg-red-100",
],
"h-7 w-20",
"disabled:pointer-events-none disabled:opacity-50",
])}
aria-label={finalizing ? "Finalizing" : "Stop listening"}
>
{finalizing ? (
<div className="flex items-center gap-1.5">
<span className="animate-pulse">...</span>
</div>
) : (
<>
<div
className={cn([
"flex items-center gap-1.5",
hovered ? "hidden" : "flex",
])}
>
{muted && <MicOff size={14} />}
<DancingSticks
amplitude={Math.min(
(amplitude.mic + amplitude.speaker) / 2000,
1,
)}
color="#ef4444"
height={18}
width={60}
/>
</div>
<div
className={cn([
"flex items-center gap-1.5",
hovered ? "flex" : "hidden",
])}
>
<span className="size-2 rounded-none bg-red-500" />
<span className="text-xs">Stop</span>
</div>
</>
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{finalizing ? "Finalizing..." : "Stop listening"}
</TooltipContent>
</Tooltip>
<div className="flex items-center gap-1.5 px-2">
{muted && <MicOff size={14} className="text-red-500" />}
<DancingSticks
amplitude={Math.min((amplitude.mic + amplitude.speaker) / 2000, 1)}
color="#ef4444"
height={18}
width={60}
/>
</div>
);
}
2 changes: 1 addition & 1 deletion apps/desktop/src/session/components/shared.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export function useCurrentNoteTab(
}

export function RecordingIcon() {
return <div className="size-2 rounded-full bg-red-500" />;
return <div className="size-3 rounded-full bg-red-500" />;
}

export function useListenButtonState(sessionId: string) {
Expand Down
213 changes: 213 additions & 0 deletions apps/desktop/src/shared/main/header-listen-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import { ChevronDownIcon } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useState } from "react";

import { Button } from "@hypr/ui/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@hypr/ui/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@hypr/ui/components/ui/tooltip";
import { cn } from "@hypr/utils";

import { useNewNote, useNewNoteAndListen } from "./useNewNote";

import { useTabs } from "~/store/zustand/tabs";
import { useListener } from "~/stt/contexts";

const LABEL_WIDTH_ESTIMATE_PX = 90;

export function HeaderListenButton({
contentOverflowPx,
}: {
contentOverflowPx: number;
}) {
const liveSessionId = useListener((state) => state.live.sessionId);
const liveStatus = useListener((state) => state.live.status);
const stop = useListener((state) => state.stop);

const isActive = liveStatus === "active";
const isFinalizing = liveStatus === "finalizing";

const select = useTabs((state) => state.select);
const tabs = useTabs((state) => state.tabs);

const handleStop = useCallback(() => {
stop();
if (liveSessionId) {
const tab = tabs.find(
(t) => t.type === "sessions" && t.id === liveSessionId,
);
if (tab) {
select(tab);
}
}
}, [stop, liveSessionId, tabs, select]);

if (isActive || isFinalizing) {
return (
<StopButton
compact={contentOverflowPx > 0}
finalizing={isFinalizing}
onStop={handleStop}
/>
);
}

return <DefaultButton contentOverflowPx={contentOverflowPx} />;
}

function StopButton({
compact,
finalizing,
onStop,
}: {
compact: boolean;
finalizing: boolean;
onStop: () => void;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={finalizing ? undefined : onStop}
disabled={finalizing}
className={cn([
"flex h-7 items-center gap-1.5 rounded-full",
compact ? "px-3" : "pl-3 pr-4",
finalizing
? "cursor-wait bg-neutral-100 text-neutral-500"
: "cursor-pointer bg-red-500 text-white transition-colors hover:bg-red-600",
"disabled:pointer-events-none disabled:opacity-50",
])}
>
{finalizing
? <span className="animate-pulse text-sm">...</span>
: (
<>
<div className="size-2.5 shrink-0 rounded-sm bg-white" />
<AnimatePresence initial={false}>
{!compact && (
<motion.span
initial={{ width: 0, opacity: 0 }}
animate={{ width: "auto", opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden text-xs font-medium whitespace-nowrap"
>
Stop
</motion.span>
)}
</AnimatePresence>
</>
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{finalizing ? "Finalizing..." : "Stop listening"}
</TooltipContent>
</Tooltip>
);
}

function DefaultButton({ contentOverflowPx }: { contentOverflowPx: number }) {
const [dropdownOpen, setDropdownOpen] = useState(false);
const [showLabel, setShowLabel] = useState(true);

useEffect(() => {
if (showLabel) {
if (contentOverflowPx > 0) {
setShowLabel(false);
}
} else {
if (contentOverflowPx + LABEL_WIDTH_ESTIMATE_PX <= 0) {
setShowLabel(true);
}
}
}, [contentOverflowPx, showLabel]);

const handleNewRecording = useNewNoteAndListen();

return (
<div className="flex items-center">
<button
onClick={handleNewRecording}
className={cn([
"flex h-7 cursor-pointer items-center gap-1.5 rounded-l-full pr-1.5 pl-3",
"bg-neutral-800 text-white",
"transition-colors hover:bg-neutral-700",
])}
>
<div className="size-2.5 rounded-full bg-red-500" />
<AnimatePresence initial={false}>
{showLabel && (
<motion.span
initial={{ width: 0, opacity: 0 }}
animate={{ width: "auto", opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden text-xs font-medium whitespace-nowrap"
>
New meeting
</motion.span>
)}
</AnimatePresence>
</button>
<Popover open={dropdownOpen} onOpenChange={setDropdownOpen}>
<PopoverTrigger asChild>
<button
className={cn([
"flex h-7 cursor-pointer items-center rounded-r-full pr-2 pl-1",
"bg-neutral-800 text-white",
"transition-colors hover:bg-neutral-700",
"border-l border-neutral-600",
])}
>
<ChevronDownIcon size={14} />
</button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="end"
sideOffset={4}
className="w-44 rounded-xl p-1.5"
>
<UploadOptions onDone={() => setDropdownOpen(false)} />
</PopoverContent>
</Popover>
</div>
);
}

function UploadOptions({ onDone }: { onDone: () => void }) {
const handleNewNote = useNewNote({ behavior: "new" });

const handleOption = useCallback(() => {
onDone();
handleNewNote();
}, [onDone, handleNewNote]);

return (
<div className="flex flex-col gap-1">
<Button
variant="ghost"
className="h-9 justify-center px-3 whitespace-nowrap"
onClick={handleOption}
>
<span className="text-sm">Upload audio</span>
</Button>
<Button
variant="ghost"
className="h-9 justify-center px-3 whitespace-nowrap"
onClick={handleOption}
>
<span className="text-sm">Upload transcript</span>
</Button>
</div>
);
}
Loading