(feat): add tooltips
All checks were successful
/ deploy (push) Successful in 14m56s

(fix): small ui and layout bug
This commit is contained in:
Alois 2026-05-09 21:12:24 +02:00
commit dd09ca943d
10 changed files with 279 additions and 171 deletions

View file

@ -24,6 +24,7 @@ import {
ChevronUp, ChevronUp,
Maximize, Maximize,
Minimize, Minimize,
ScreenShareOff,
SquareArrowOutDownLeft, SquareArrowOutDownLeft,
SquareArrowOutUpRight, SquareArrowOutUpRight,
} from "lucide-react"; } from "lucide-react";
@ -96,68 +97,56 @@ export default function Actions() {
</div> </div>
<Card className="p-1.75"> <Card className="p-1.75">
<CardContent className="p-0! flex gap-1.75"> <CardContent className="p-0! flex gap-1.75">
<Tooltip> <MuteButton
<TooltipTrigger className={sharedClasses}
render={ iconSize={sharedIconSize}
<MuteButton tooltip="Mute"
className={sharedClasses} portalContainer={portalContainer}
iconSize={sharedIconSize} />
/> <DeafButton
} className={sharedClasses}
/> iconSize={sharedIconSize}
<TooltipContent portalProps={{ container: portalContainer }}> tooltip="Deafen"
Mute portalContainer={portalContainer}
</TooltipContent> />
</Tooltip> <ScreenshareButton
<Tooltip> className={sharedClasses}
<TooltipTrigger iconSize={sharedIconSize}
render={ tooltip="Screenshare"
<DeafButton />
className={sharedClasses} <InviteButton
iconSize={sharedIconSize} className={sharedClasses}
/> iconSize={sharedIconSize}
} tooltip="Invite"
/> />
<TooltipContent portalProps={{ container: portalContainer }}>
Deafen
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<ScreenshareButton
className={sharedClasses}
iconSize={sharedIconSize}
/>
}
/>
<TooltipContent portalProps={{ container: portalContainer }}>
Screenshare
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<InviteButton
className={sharedClasses}
iconSize={sharedIconSize}
/>
}
/>
<TooltipContent portalProps={{ container: portalContainer }}>
Invite
</TooltipContent>
</Tooltip>
{isWatchingFocusedStream ? ( {isWatchingFocusedStream ? (
<Button <Tooltip>
className="h-10" <TooltipTrigger
variant="destructive" render={
onClick={() => stopWatchingFocusedStream()} <Button
> className={sharedClasses}
Stop watching variant="destructive"
</Button> onClick={() => stopWatchingFocusedStream()}
>
<ScreenShareOff
style={{
scale: (sharedIconSize ? sharedIconSize + 100 : 100) + "%",
}}
/>
</Button>
}
/>
<TooltipContent portalProps={{ container: portalContainer }}>
Stop watching
</TooltipContent>
</Tooltip>
) : ( ) : (
<LeaveButton className={sharedClasses} iconSize={sharedIconSize} /> <LeaveButton
className={sharedClasses}
iconSize={sharedIconSize}
tooltip="Leave"
portalContainer={portalContainer}
/>
)} )}
</CardContent> </CardContent>
</Card> </Card>

View file

@ -1,17 +1,21 @@
import { Button } from "@tensamin/ui"; import { Button, Tooltip, TooltipContent, TooltipTrigger } from "@tensamin/ui";
import { toggleDeaf, useCall } from "../../store"; import { toggleDeaf, useCall } from "../../store";
import { HeadphoneOff, Headphones } from "lucide-react"; import { HeadphoneOff, Headphones } from "lucide-react";
export default function DeafButton({ export default function DeafButton({
className, className,
iconSize, iconSize,
tooltip,
portalContainer,
}: { }: {
className?: string; className?: string;
iconSize?: number; iconSize?: number;
tooltip?: string;
portalContainer?: HTMLElement;
}) { }) {
const deaf = useCall((state) => state.deaf); const deaf = useCall((state) => state.deaf);
return ( const button = (
<Button <Button
variant={deaf ? "destructive" : "default"} variant={deaf ? "destructive" : "default"}
onClick={toggleDeaf} onClick={toggleDeaf}
@ -28,4 +32,17 @@ export default function DeafButton({
)} )}
</Button> </Button>
); );
if (!tooltip) {
return button;
}
return (
<Tooltip>
<TooltipTrigger render={button} />
<TooltipContent portalProps={{ container: portalContainer }}>
{tooltip}
</TooltipContent>
</Tooltip>
);
} }

View file

@ -1,5 +1,13 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Button, Popover, PopoverContent, PopoverTrigger } from "@tensamin/ui"; import {
Button,
Popover,
PopoverContent,
PopoverTrigger,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@tensamin/ui";
import Wrapper from "@tensamin/user/wrapper"; import Wrapper from "@tensamin/user/wrapper";
import { Mail } from "lucide-react"; import { Mail } from "lucide-react";
import { sendCallInvite, useCall } from "../../store"; import { sendCallInvite, useCall } from "../../store";
@ -9,9 +17,11 @@ import { useSession } from "@tensamin/storage/session";
export default function InviteButton({ export default function InviteButton({
className, className,
iconSize, iconSize,
tooltip,
}: { }: {
className?: string; className?: string;
iconSize?: number; iconSize?: number;
tooltip?: string;
}) { }) {
const { contacts } = useSession(); const { contacts } = useSession();
const screenRef = useCall((state) => state.screenRef); const screenRef = useCall((state) => state.screenRef);
@ -22,43 +32,64 @@ export default function InviteButton({
}, [screenRef]); }, [screenRef]);
return ( return (
<Popover> <Tooltip>
<PopoverTrigger <Popover>
render={ <PopoverTrigger
<Button className={className}> render={
<Mail style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }} /> tooltip ? (
</Button> <TooltipTrigger
} render={
/> <Button className={className}>
<PopoverContent <Mail
className="flex w-40 flex-col gap-0.5 p-1!" style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }}
portalProps={{ container: portalContainer }} />
> </Button>
{contacts.map((contact) => ( }
<Wrapper />
key={contact.user_id} ) : (
userId={contact.user_id} <Button className={className}>
loading={<div>Loading...</div>} <Mail
component={(user) => ( style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }}
<Button />
className="w-full justify-start"
variant="ghost"
onClick={() => {
void sendCallInvite(contact.user_id).catch((err) => {
toast(
"error",
"Failed to send call invite. Check console for details.",
);
log(1, "call", "red", "Failed to send call invite", err);
});
}}
>
{user.display}
</Button> </Button>
)} )
/> }
))} />
</PopoverContent> <PopoverContent
</Popover> className="flex w-40 flex-col gap-0.5 p-1!"
portalProps={{ container: portalContainer }}
>
{contacts.map((contact) => (
<Wrapper
key={contact.user_id}
userId={contact.user_id}
loading={<div>Loading...</div>}
component={(user) => (
<Button
className="w-full justify-start"
variant="ghost"
onClick={() => {
void sendCallInvite(contact.user_id).catch((err) => {
toast(
"error",
"Failed to send call invite. Check console for details.",
);
log(1, "call", "red", "Failed to send call invite", err);
});
}}
>
{user.display}
</Button>
)}
/>
))}
</PopoverContent>
</Popover>
{tooltip && (
<TooltipContent portalProps={{ container: portalContainer }}>
{tooltip}
</TooltipContent>
)}
</Tooltip>
); );
} }

View file

@ -1,17 +1,21 @@
import { LeaveIcon } from "@livekit/components-react"; import { LeaveIcon } from "@livekit/components-react";
import { Button } from "@tensamin/ui"; import { Button, Tooltip, TooltipContent, TooltipTrigger } from "@tensamin/ui";
import { disconnect, useCall } from "../../store"; import { disconnect, useCall } from "../../store";
export default function LeaveButton({ export default function LeaveButton({
className, className,
iconSize, iconSize,
tooltip,
portalContainer,
}: { }: {
className?: string; className?: string;
iconSize?: number; iconSize?: number;
tooltip?: string;
portalContainer?: HTMLElement;
}) { }) {
const state = useCall((store) => store.state); const state = useCall((store) => store.state);
return ( const button = (
<Button <Button
className={className} className={className}
onClick={() => disconnect()} onClick={() => disconnect()}
@ -21,4 +25,17 @@ export default function LeaveButton({
<LeaveIcon style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }} /> <LeaveIcon style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }} />
</Button> </Button>
); );
if (!tooltip) {
return button;
}
return (
<Tooltip>
<TooltipTrigger render={button} />
<TooltipContent portalProps={{ container: portalContainer }}>
{tooltip}
</TooltipContent>
</Tooltip>
);
} }

View file

@ -1,17 +1,21 @@
import { Button } from "@tensamin/ui"; import { Button, Tooltip, TooltipContent, TooltipTrigger } from "@tensamin/ui";
import { toggleMute, useCall } from "../../store"; import { toggleMute, useCall } from "../../store";
import { Mic, MicOff } from "lucide-react"; import { Mic, MicOff } from "lucide-react";
export default function MuteButton({ export default function MuteButton({
className, className,
iconSize, iconSize,
tooltip,
portalContainer,
}: { }: {
className?: string; className?: string;
iconSize?: number; iconSize?: number;
tooltip?: string;
portalContainer?: HTMLElement;
}) { }) {
const micEnabled = useCall((state) => state.micEnabled); const micEnabled = useCall((state) => state.micEnabled);
return ( const button = (
<Button <Button
variant={micEnabled ? "default" : "destructive"} variant={micEnabled ? "default" : "destructive"}
className={className} className={className}
@ -24,4 +28,17 @@ export default function MuteButton({
)} )}
</Button> </Button>
); );
if (!tooltip) {
return button;
}
return (
<Tooltip>
<TooltipTrigger render={button} />
<TooltipContent portalProps={{ container: portalContainer }}>
{tooltip}
</TooltipContent>
</Tooltip>
);
} }

View file

@ -1,5 +1,13 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Button, Popover, PopoverContent, PopoverTrigger } from "@tensamin/ui"; import {
Button,
Popover,
PopoverContent,
PopoverTrigger,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@tensamin/ui";
import { MonitorDot, ScreenShare } from "lucide-react"; import { MonitorDot, ScreenShare } from "lucide-react";
import { toast } from "@tensamin/shared/log"; import { toast } from "@tensamin/shared/log";
import { isTauri } from "@tauri-apps/api/core"; import { isTauri } from "@tauri-apps/api/core";
@ -9,9 +17,11 @@ import ScreenShareDialog from "../screenshareDialog";
export default function ScreenshareButton({ export default function ScreenshareButton({
className, className,
iconSize, iconSize,
tooltip,
}: { }: {
className?: string; className?: string;
iconSize?: number; iconSize?: number;
tooltip?: string;
}) { }) {
const isScreensharing = useCall((state) => state.screenShareEnabled); const isScreensharing = useCall((state) => state.screenShareEnabled);
const screenRef = useCall((state) => state.screenRef); const screenRef = useCall((state) => state.screenRef);
@ -53,57 +63,89 @@ export default function ScreenshareButton({
return ( return (
<> <>
<Popover onOpenChange={setMenuOpen} open={menuOpen}> <Tooltip>
<PopoverTrigger <Popover onOpenChange={setMenuOpen} open={menuOpen}>
render={ <PopoverTrigger
<Button render={
variant={isScreensharing ? "subtleDefault" : "default"} tooltip ? (
className={className} <TooltipTrigger
> render={
{isScreensharing ? ( <Button
<MonitorDot variant={isScreensharing ? "subtleDefault" : "default"}
style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }} className={className}
>
{isScreensharing ? (
<MonitorDot
style={{
scale: (iconSize ? iconSize + 100 : 100) + "%",
}}
/>
) : (
<ScreenShare
style={{
scale: (iconSize ? iconSize + 100 : 100) + "%",
}}
/>
)}
</Button>
}
/> />
) : ( ) : (
<ScreenShare <Button
style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }} variant={isScreensharing ? "subtleDefault" : "default"}
/> className={className}
)} >
{isScreensharing ? (
<MonitorDot
style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }}
/>
) : (
<ScreenShare
style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }}
/>
)}
</Button>
)
}
/>
<PopoverContent
className="flex w-40 flex-col gap-2"
portalProps={{ container: portalContainer }}
>
<Button
disabled={isScreensharing}
onClick={() => {
setMenuOpen(false);
if (isTauri()) {
setDialogOpen(true);
return;
}
void startWebShare();
}}
>
Start screenshare
</Button> </Button>
} <Button
/> variant="destructive"
<PopoverContent disabled={!isScreensharing}
className="flex w-40 flex-col gap-2" onClick={() => {
portalProps={{ container: portalContainer }} setMenuOpen(false);
> void stopShare();
<Button }}
disabled={isScreensharing} >
onClick={() => { Stop screenshare
setMenuOpen(false); </Button>
<Button variant="outline">Change quality</Button>
if (isTauri()) { </PopoverContent>
setDialogOpen(true); </Popover>
return; {tooltip && (
} <TooltipContent portalProps={{ container: portalContainer }}>
{tooltip}
void startWebShare(); </TooltipContent>
}} )}
> </Tooltip>
Start screenshare
</Button>
<Button
variant="destructive"
disabled={!isScreensharing}
onClick={() => {
setMenuOpen(false);
void stopShare();
}}
>
Stop screenshare
</Button>
<Button variant="outline">Change quality</Button>
</PopoverContent>
</Popover>
{isTauri() && ( {isTauri() && (
<ScreenShareDialog <ScreenShareDialog

View file

@ -45,11 +45,7 @@ export default function SidebarBox() {
); );
} }
function ConnectionBar({ function ConnectionBar({ portalContainer }: { portalContainer?: HTMLElement }) {
portalContainer,
}: {
portalContainer?: HTMLElement;
}) {
const state = useCall((store) => store.state); const state = useCall((store) => store.state);
const isEncrypted = useCall((store) => store.isEncrypted); const isEncrypted = useCall((store) => store.isEncrypted);
const callId = useCall((store) => store.callId); const callId = useCall((store) => store.callId);

View file

@ -509,9 +509,7 @@ export function setCallView(view: CallView) {
useCall.setState({ view }); useCall.setState({ view });
} }
export function setUsersInFocusedViewHidden( export function setUsersInFocusedViewHidden(usersInFocusedViewHidden: boolean) {
usersInFocusedViewHidden: boolean,
) {
useCall.setState((state) => ({ useCall.setState((state) => ({
usersInFocusedViewHidden, usersInFocusedViewHidden,
layoutVersion: layoutVersion:

View file

@ -107,7 +107,9 @@ export default function View() {
let frameId = 0; let frameId = 0;
const syncTileLayout = () => { const syncTileLayout = () => {
const containerWidth = Math.floor(container.getBoundingClientRect().width); const containerWidth = Math.floor(
container.getBoundingClientRect().width,
);
const tileWidth = Math.floor(focusedTile.getBoundingClientRect().width); const tileWidth = Math.floor(focusedTile.getBoundingClientRect().width);
setIsFocusedTileFlush(Math.abs(containerWidth - tileWidth) <= 1); setIsFocusedTileFlush(Math.abs(containerWidth - tileWidth) <= 1);
@ -155,14 +157,10 @@ export default function View() {
const focusedParticipant = getParticipantById(focusedParticipantId); const focusedParticipant = getParticipantById(focusedParticipantId);
const focusedParticipantHasActiveScreenShare = const focusedParticipantHasActiveScreenShare =
activeScreenShareParticipantIdSet.has(focusedParticipantId); activeScreenShareParticipantIdSet.has(focusedParticipantId);
const isImmersiveFocusedView = const isImmersiveFocusedView = callIsFullscreen && usersInFocusedViewHidden;
callIsFullscreen && usersInFocusedViewHidden;
return ( return (
<div <div ref={containerRef} className="flex h-full w-full overflow-hidden">
ref={containerRef}
className="flex h-full w-full overflow-hidden"
>
<div <div
className="flex h-full w-full flex-col items-center overflow-hidden" className="flex h-full w-full flex-col items-center overflow-hidden"
style={{ style={{
@ -175,7 +173,7 @@ export default function View() {
className={ className={
isImmersiveFocusedView isImmersiveFocusedView
? "h-full w-full overflow-hidden" ? "h-full w-full overflow-hidden"
: "h-full max-w-full aspect-video overflow-hidden" : "h-full max-w-full aspect-video overflow-hidden flex items-center"
} }
> >
<Base <Base

View file

@ -7,9 +7,12 @@ export default function Layout({ children }: { children: React.ReactNode }) {
const screenRef = useRef<HTMLDivElement>(null); const screenRef = useRef<HTMLDivElement>(null);
const topBarRef = useRef<HTMLDivElement>(null); const topBarRef = useRef<HTMLDivElement>(null);
const actionsRef = useRef<HTMLDivElement>(null); const actionsRef = useRef<HTMLDivElement>(null);
const hideChromeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const hideChromeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const [overlayInsets, setOverlayInsets] = useState({ top: 0, bottom: 0 }); const [overlayInsets, setOverlayInsets] = useState({ top: 0, bottom: 0 });
const [isImmersiveChromeVisible, setIsImmersiveChromeVisible] = useState(false); const [isImmersiveChromeVisible, setIsImmersiveChromeVisible] =
useState(false);
useEffect(() => { useEffect(() => {
setScreenRef(screenRef); setScreenRef(screenRef);
@ -102,7 +105,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
ref={screenRef} ref={screenRef}
className={`relative w-full h-full flex flex-col ${ className={`relative w-full h-full flex flex-col ${
isImmersiveFocusedView && !immersiveChromeVisible ? "cursor-none" : "" isImmersiveFocusedView && !immersiveChromeVisible ? "cursor-none" : ""
}`} } ${callIsFullscreen ? "bg-background" : ""}`}
onMouseEnter={showImmersiveChrome} onMouseEnter={showImmersiveChrome}
onMouseMove={showImmersiveChrome} onMouseMove={showImmersiveChrome}
onMouseLeave={hideImmersiveChrome} onMouseLeave={hideImmersiveChrome}