feat: allow toggling eavesdropping
This commit is contained in:
@@ -0,0 +1,44 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Toggle as TogglePrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const toggleVariants = cva(
|
||||||
|
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-transparent",
|
||||||
|
outline: "border border-input bg-transparent hover:bg-muted",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-8 min-w-8 px-2",
|
||||||
|
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-1.5 text-[0.8rem]",
|
||||||
|
lg: "h-9 min-w-9 px-2.5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Toggle({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TogglePrimitive.Root> &
|
||||||
|
VariantProps<typeof toggleVariants>) {
|
||||||
|
return (
|
||||||
|
<TogglePrimitive.Root
|
||||||
|
data-slot="toggle"
|
||||||
|
className={cn(toggleVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toggle, toggleVariants }
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { WindowControls } from "@/components/window-controls";
|
import { WindowControls } from "@/components/window-controls";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Outlet, useLocation, useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { Home, Settings } from "lucide-react";
|
import { Home, Settings, Volume2, VolumeOff } from "lucide-react";
|
||||||
|
import { Toggle } from "@/components/ui/toggle";
|
||||||
|
import { useAutoplayStore } from "@/stores/autoplay-store";
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
BreadcrumbItem,
|
BreadcrumbItem,
|
||||||
@@ -53,6 +55,23 @@ function NetworkBreadcrumbContent({ networkId }: { networkId: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AutoplayToggle() {
|
||||||
|
const muted = useAutoplayStore((s) => s.muted);
|
||||||
|
const toggleMuted = useAutoplayStore((s) => s.toggleMuted);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Toggle
|
||||||
|
size="sm"
|
||||||
|
pressed={!muted}
|
||||||
|
onPressedChange={toggleMuted}
|
||||||
|
className="no-drag"
|
||||||
|
aria-label={muted ? "Unmute autoplay" : "Mute autoplay"}
|
||||||
|
>
|
||||||
|
{muted ? <VolumeOff className="size-3.5" /> : <Volume2 className="size-3.5" />}
|
||||||
|
</Toggle>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function TopBar() {
|
function TopBar() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { networkId, "*": rest } = useParams();
|
const { networkId, "*": rest } = useParams();
|
||||||
@@ -116,6 +135,8 @@ function TopBar() {
|
|||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
<AutoplayToggle />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { X, Mic, Video } from "lucide-react";
|
import { X, Mic } from "lucide-react";
|
||||||
import { useAutoplayStore } from "@/stores/autoplay-store";
|
import { useAutoplayStore } from "@/stores/autoplay-store";
|
||||||
import { useDownloadUrl } from "@/hooks/use-download-url";
|
import { useDownloadUrl } from "@/hooks/use-download-url";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import { Small } from "@/components/ui/typography";
|
import { Small } from "@/components/ui/typography";
|
||||||
|
import { getInitials } from "@/lib/utils";
|
||||||
|
|
||||||
interface AutoplayOverlayProps {
|
interface AutoplayOverlayProps {
|
||||||
networkId: string;
|
networkId: string;
|
||||||
@@ -19,7 +20,9 @@ export function AutoplayOverlay({ networkId }: AutoplayOverlayProps) {
|
|||||||
if (!activeParticle || !url) return null;
|
if (!activeParticle || !url) return null;
|
||||||
|
|
||||||
const isVideo = activeParticle.properties.mime_type?.startsWith("video/");
|
const isVideo = activeParticle.properties.mime_type?.startsWith("video/");
|
||||||
const Icon = isVideo ? Video : Mic;
|
const senderEmail = activeParticle.created_by_email;
|
||||||
|
const senderInitials = getInitials(senderEmail);
|
||||||
|
const senderName = senderEmail.split("@")[0];
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
stop();
|
stop();
|
||||||
@@ -28,42 +31,63 @@ export function AutoplayOverlay({ networkId }: AutoplayOverlayProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const closeButton = (
|
||||||
|
<button
|
||||||
|
className="absolute top-1 right-1 z-10 rounded-full bg-black/50 p-0.5 text-white hover:bg-black/70"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
stop();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const senderBar = (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
|
<Avatar size="sm">
|
||||||
|
<AvatarFallback className="bg-primary/10 text-primary text-[10px] font-medium">
|
||||||
|
{senderInitials}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<Small className="truncate text-muted-foreground">{senderName}</Small>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<div
|
||||||
size="sm"
|
className="absolute bottom-4 right-4 z-40 w-52 cursor-pointer overflow-hidden rounded-xl bg-card text-card-foreground shadow-lg ring-1 ring-foreground/10"
|
||||||
className="absolute bottom-4 right-4 z-40 w-52 cursor-pointer shadow-lg"
|
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
<div className="relative">
|
{isVideo ? (
|
||||||
{/* Close button */}
|
<div className="relative">
|
||||||
<button
|
{closeButton}
|
||||||
className="absolute top-1 right-1 z-10 rounded-full bg-black/50 p-0.5 text-white hover:bg-black/70"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
stop();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<X className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isVideo ? (
|
|
||||||
<video
|
<video
|
||||||
src={url}
|
src={url}
|
||||||
autoPlay
|
autoPlay
|
||||||
playsInline
|
playsInline
|
||||||
onEnded={stop}
|
onEnded={stop}
|
||||||
className="w-full rounded-t-xl object-cover"
|
className="w-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
{senderBar}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative">
|
||||||
|
{closeButton}
|
||||||
<div className="flex items-center gap-2 px-3 py-3">
|
<div className="flex items-center gap-2 px-3 py-3">
|
||||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
<Avatar size="sm">
|
||||||
<Small className="truncate text-muted-foreground">
|
<AvatarFallback className="bg-primary/10 text-primary text-[10px] font-medium">
|
||||||
Playing voice note...
|
{senderInitials}
|
||||||
</Small>
|
</AvatarFallback>
|
||||||
<audio src={url} autoPlay onEnded={stop} />
|
</Avatar>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<Small className="truncate font-medium">{senderName}</Small>
|
||||||
|
</div>
|
||||||
|
<Mic />
|
||||||
</div>
|
</div>
|
||||||
)}
|
<audio src={url} autoPlay onEnded={stop} />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,15 +8,32 @@ interface AutoplayState {
|
|||||||
activeParticle: MediaParticle | null;
|
activeParticle: MediaParticle | null;
|
||||||
/** The stream this particle belongs to (for click-to-navigate) */
|
/** The stream this particle belongs to (for click-to-navigate) */
|
||||||
streamId: string | null;
|
streamId: string | null;
|
||||||
/** Play a media particle, preempting any currently playing */
|
/** When true, incoming play requests are ignored */
|
||||||
|
muted: boolean;
|
||||||
|
/** Play a media particle, preempting any currently playing. No-op when muted. */
|
||||||
play: (particle: MediaParticle, streamId: string) => void;
|
play: (particle: MediaParticle, streamId: string) => void;
|
||||||
/** Stop autoplay and clear state */
|
/** Stop autoplay and clear state */
|
||||||
stop: () => void;
|
stop: () => void;
|
||||||
|
/** Toggle muted state */
|
||||||
|
toggleMuted: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAutoplayStore = create<AutoplayState>((set) => ({
|
export const useAutoplayStore = create<AutoplayState>((set, get) => ({
|
||||||
activeParticle: null,
|
activeParticle: null,
|
||||||
streamId: null,
|
streamId: null,
|
||||||
play: (particle, streamId) => set({ activeParticle: particle, streamId }),
|
muted: false,
|
||||||
|
play: (particle, streamId) => {
|
||||||
|
if (get().muted) return;
|
||||||
|
set({ activeParticle: particle, streamId });
|
||||||
|
},
|
||||||
stop: () => set({ activeParticle: null, streamId: null }),
|
stop: () => set({ activeParticle: null, streamId: null }),
|
||||||
|
toggleMuted: () => {
|
||||||
|
const wasMuted = get().muted;
|
||||||
|
// If muting, also stop any current playback
|
||||||
|
if (!wasMuted) {
|
||||||
|
set({ muted: true, activeParticle: null, streamId: null });
|
||||||
|
} else {
|
||||||
|
set({ muted: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user