(feat): add avatars to call modals
Some checks failed
/ deploy (push) Has been cancelled

(feat): add fullscreen & popout buttons
(feat): remove clock icon from awaiting messages
(qol): update todos
This commit is contained in:
Alois 2026-05-09 14:36:19 +02:00
commit 30cf5ae3b9
14 changed files with 248 additions and 29 deletions

View file

@ -1,10 +1,28 @@
import { Card, CardContent, Button } from "@tensamin/ui";
import {
Card,
CardContent,
Button,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@tensamin/ui";
import MuteButton from "./buttons/mute";
import DeafButton from "./buttons/deaf";
import ScreenshareButton from "./buttons/screenshare";
import LeaveButton from "./buttons/leave";
import { stopWatchingFocusedStream, useCall } from "../store";
import {
setCallIsPopout,
stopWatchingFocusedStream,
triggerCallLayoutCalculation,
useCall,
} from "../store";
import InviteButton from "./buttons/invite";
import {
Maximize,
Minimize,
SquareArrowOutDownLeft,
SquareArrowOutUpRight,
} from "lucide-react";
export default function Actions() {
const sharedClasses = "w-14 h-10";
@ -19,8 +37,23 @@ export default function Actions() {
focusedParticipantId != null &&
watchedStreamParticipantIds.includes(focusedParticipantId);
const callIsPopout = useCall((state) => state.callIsPopout);
const callIsFullscreen = useCall((state) => state.callIsFullscreen);
const screenRef = useCall((state) => state.screenRef);
const toggleFullscreen = async () => {
if (callIsFullscreen) {
await document.exitFullscreen().catch(() => undefined);
} else {
await screenRef?.current?.requestFullscreen().catch(() => undefined);
}
triggerCallLayoutCalculation();
};
return (
<div className="w-full flex justify-center">
<div className="w-full flex justify-between items-center">
<div className="w-30" />
<Card className="p-1.75">
<CardContent className="p-0! flex gap-1.75">
<MuteButton className={sharedClasses} iconSize={sharedIconSize} />
@ -43,6 +76,46 @@ export default function Actions() {
)}
</CardContent>
</Card>
<div className="w-30 flex justify-end">
<Tooltip>
<TooltipTrigger
render={
<Button
onClick={() => setCallIsPopout(!callIsPopout)}
variant="link"
className="w-11 h-11 p-0! text-foreground"
>
{callIsPopout ? (
<SquareArrowOutDownLeft className="size-6" />
) : (
<SquareArrowOutUpRight className="size-6" />
)}
</Button>
}
/>
<TooltipContent>Popout</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
onClick={() => {
void toggleFullscreen();
}}
variant="link"
className="w-11 h-11 p-0! mr-3 text-foreground"
>
{callIsFullscreen ? (
<Minimize className="size-6" />
) : (
<Maximize className="size-6" />
)}
</Button>
}
/>
<TooltipContent>Fullscreen</TooltipContent>
</Tooltip>
</div>
</div>
);
}

View file

@ -1,4 +1,7 @@
import {
Avatar,
AvatarFallback,
AvatarImage,
Button,
ContextMenu,
ContextMenuContent,
@ -13,7 +16,7 @@ import {
useCall,
} from "../../store";
import { Track, type Participant } from "livekit-client";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useUser, type User } from "@tensamin/user/context";
import VideoViewer from "../videoViewer";
import { HeadphoneOff, MicOff, Monitor, Plus, Shield } from "lucide-react";
@ -130,6 +133,9 @@ export default function Base({
};
}, [participant, get]);
// Avatar calc
const currentCard = useRef<HTMLDivElement>(null);
if (!participant || !user) {
return (
<div
@ -202,9 +208,11 @@ export default function Base({
</>
</div>
<div
ref={currentCard}
className={`z-10 bg-card absolute top-0 left-0 w-full h-full flex gap-2 items-center justify-center ${
flush ? "rounded-none" : "rounded-sm"
}`}
style={{ containerType: "size" }}
>
{/* Detect video / user and place here */}
@ -230,7 +238,23 @@ export default function Base({
</div>
) : null)}
{type === "user" && <p>{user.display}</p>}
{type === "user" && (
<Avatar
style={{
width: "32cqh",
height: "32cqh",
}}
>
<AvatarImage src={user.avatar} />
<AvatarFallback
style={{
fontSize: "11cqh",
}}
>
{user.display.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
)}
</div>
</div>
}

View file

@ -90,6 +90,10 @@ type CallStore = {
pendingWatchedParticipantIds: number[];
activeScreenShareParticipantIds: number[];
isEncrypted: boolean;
callIsFullscreen: boolean;
callIsPopout: boolean;
layoutVersion: number;
screenRef: React.RefObject<HTMLDivElement | null> | null;
room: Room;
keyProvider: ExternalE2EEKeyProvider;
e2eeWorker: Worker;
@ -504,6 +508,24 @@ export function setCallView(view: CallView) {
useCall.setState({ view });
}
export function setCallIsFullscreen(callIsFullscreen: boolean) {
useCall.setState({ callIsFullscreen });
}
export function setCallIsPopout(callIsPopout: boolean) {
useCall.setState({ callIsPopout });
}
export function triggerCallLayoutCalculation() {
useCall.setState((state) => ({
layoutVersion: state.layoutVersion + 1,
}));
}
export function setScreenRef(screenRef: React.RefObject<HTMLDivElement | null> | null) {
useCall.setState({ screenRef });
}
// Keep the current call id in sync with navigation and connection flow.
export function setCallId(callId: string | null) {
useCall.setState({ callId });
@ -950,6 +972,10 @@ export const useCall = create<CallStore>(() => ({
activeScreenShareParticipantIds: [],
isEncrypted:
room.localParticipant.isE2EEEnabled && room.localParticipant.isEncrypted,
callIsFullscreen: false,
callIsPopout: false,
layoutVersion: 0,
screenRef: null,
room,
keyProvider,
e2eeWorker,
@ -1051,6 +1077,41 @@ export function useInitializeCall() {
}
}, [location.pathname, location.searchStr]);
// fullscreen sync
useEffect(() => {
const handleFullscreenChange = () => {
const screenRef = useCall.getState().screenRef;
if (!screenRef?.current) return;
if (screenRef.current === document.fullscreenElement) {
setCallIsFullscreen(true);
} else {
setCallIsFullscreen(false);
}
triggerCallLayoutCalculation();
};
document.addEventListener("fullscreenchange", handleFullscreenChange);
return () => document.removeEventListener("fullscreenchange", handleFullscreenChange);
}, []);
// esc key listener
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (
e.key === "Escape" &&
useCall.getState().callIsFullscreen &&
location.pathname.startsWith("/call")
) {
useCall.setState({ callIsFullscreen: false });
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [location.pathname]);
// room setup
useEffect(() => {
if (listenersRegistered.current) {

View file

@ -9,6 +9,7 @@ const STACK_GAP_PX = 12;
export default function View() {
const room = useCall((state) => state.room);
const layoutVersion = useCall((state) => state.layoutVersion);
const focusedParticipantId = useCall((state) => state.focusedParticipantId);
const activeScreenShareParticipantIds = useCall(
@ -136,7 +137,7 @@ export default function View() {
observer.disconnect();
window.removeEventListener("resize", syncTileLayout);
};
}, [tiles.length, focusedParticipantId]);
}, [layoutVersion, tiles.length, focusedParticipantId]);
if (focusedParticipantId == null) {
return null;

View file

@ -83,6 +83,7 @@ function calculateOptimalGridLayout(
export default function View() {
const room = useCall((state) => state.room);
const layoutVersion = useCall((state) => state.layoutVersion);
const activeScreenShareParticipantIds = useCall(
(state) => state.activeScreenShareParticipantIds,
);
@ -98,15 +99,21 @@ export default function View() {
return;
}
const syncContainerSize = () => {
const { width, height } = element.getBoundingClientRect();
setContainerSize({ width, height });
};
const observer = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
setContainerSize({ width, height });
});
syncContainerSize();
observer.observe(element);
return () => observer.disconnect();
}, []);
}, [layoutVersion]);
useEffect(() => {
const syncParticipants = () => {

View file

@ -1,9 +1,17 @@
import { useEffect, useRef } from "react";
import Actions from "../../components/actions";
import TopBar from "../../components/top";
import { setScreenRef } from "../../store";
export default function Layout({ children }: { children: React.ReactNode }) {
const screenRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setScreenRef(screenRef);
}, []);
return (
<div className="w-full h-full flex flex-col">
<div ref={screenRef} className="w-full h-full flex flex-col">
<div className="shrink-0">
<TopBar />
</div>

View file

@ -2,7 +2,6 @@
- Overlay for stream modals
- User modals
- Bg based on avatar
- Avatar in the center
- Mobile
- Call invite popup
- Save call invite in `calls` array
@ -11,3 +10,5 @@
- Admin call actions
- Timeout
- Disconnect
- Desktop-App screenshares
- Toggle members button in focused view

View file

@ -1,7 +1,7 @@
import * as React from "react";
import type { RawMessage } from "../values";
import Text from "@tensamin/markdown/text";
import { AlertTriangle, Clock } from "lucide-react";
import { AlertTriangle } from "lucide-react";
import { cn, Tooltip, TooltipContent, TooltipTrigger } from "@tensamin/ui";
import { useIsMobile } from "@tensamin/ui";
@ -50,9 +50,6 @@ function MessageComponent({
<TooltipTrigger render={<AlertTriangle size={17} />} />
</Tooltip>
)}
{!message.failed && message.message_state === "awaiting" && (
<Clock size={17} />
)}
<Text value={message.content} />
</div>
}

View file

@ -41,6 +41,7 @@ export default function Screen() {
scrollTop: number;
} | null>(null);
const [scrollWidth, setScrollWidth] = React.useState(0);
const [scrollHeight, setScrollHeight] = React.useState(0);
const [inputHeight, setInputHeight] = React.useState(0);
const [measuredHeights, setMeasuredHeights] = React.useState<
Record<number, number>
@ -93,6 +94,10 @@ export default function Screen() {
return [...historicalMessages, ...liveWithoutDuplicates];
}, [historicalMessages, liveMessagesSnapshot]);
const shouldShowConversationStart =
!!messagesQuery.data && !messagesQuery.hasNextPage;
const virtualRowCount =
messages.length + (shouldShowConversationStart ? 1 : 0);
const messagesRef = React.useRef(messages);
@ -101,8 +106,13 @@ export default function Screen() {
}, [messages]);
const getItemKey = React.useCallback((index: number) => {
return messagesRef.current[index]?.send_time ?? index;
}, []);
if (shouldShowConversationStart && index === 0) {
return "conversation-start";
}
const messageIndex = shouldShowConversationStart ? index - 1 : index;
return messagesRef.current[messageIndex]?.send_time ?? index;
}, [shouldShowConversationStart]);
const setMeasurementRef = React.useCallback(
(sendTime: number, element: HTMLDivElement | null) => {
@ -118,7 +128,12 @@ export default function Screen() {
const estimateMessageSize = React.useCallback(
(index: number) => {
const sendTime = messages[index]?.send_time;
if (shouldShowConversationStart && index === 0) {
return FALLBACK_MESSAGE_HEIGHT;
}
const messageIndex = shouldShowConversationStart ? index - 1 : index;
const sendTime = messages[messageIndex]?.send_time;
if (sendTime === undefined) {
return FALLBACK_MESSAGE_HEIGHT;
@ -126,12 +141,12 @@ export default function Screen() {
return measuredHeights[sendTime] ?? FALLBACK_MESSAGE_HEIGHT;
},
[measuredHeights, messages],
[measuredHeights, messages, shouldShowConversationStart],
);
// eslint-disable-next-line react-hooks/incompatible-library
const virtualizer = useVirtualizer({
count: messages.length,
count: virtualRowCount,
getScrollElement: () => scrollRef.current,
getItemKey,
estimateSize: estimateMessageSize,
@ -144,13 +159,14 @@ export default function Screen() {
return;
}
const updateWidth = () => {
const updateDimensions = () => {
setScrollWidth(element.clientWidth);
setScrollHeight(element.clientHeight);
};
updateWidth();
updateDimensions();
const observer = new ResizeObserver(updateWidth);
const observer = new ResizeObserver(updateDimensions);
observer.observe(element);
return () => {
@ -300,14 +316,14 @@ export default function Screen() {
if (
!scrollRef.current ||
hasScrolledToBottomInitially ||
messages.length === 0
virtualRowCount === 0
) {
return;
}
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
setHasScrolledToBottomInitially(true);
}, [hasScrolledToBottomInitially, messages]);
}, [hasScrolledToBottomInitially, virtualRowCount]);
React.useLayoutEffect(() => {
const count = liveMessagesSnapshot.length;
@ -355,6 +371,9 @@ export default function Screen() {
}, [onScroll]);
const [value, setValue] = React.useState("");
const totalSize = virtualizer.getTotalSize();
const verticalOffset = Math.max(0, scrollHeight - totalSize);
const contentHeight = Math.max(totalSize, scrollHeight);
// Render
if (!hasValidChatUser) {
@ -381,11 +400,38 @@ export default function Screen() {
<div
className="relative w-full"
style={{
height: `${virtualizer.getTotalSize()}px`,
height: `${contentHeight}px`,
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const message = messages[virtualRow.index];
if (shouldShowConversationStart && virtualRow.index === 0) {
return (
<div
key="conversation-start"
data-index={virtualRow.index}
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start + verticalOffset}px)`,
padding: "4px 0",
}}
>
<div className="w-full flex justify-start">
<div className="max-w-[80%] rounded-lg bg-muted px-2 py-px text-sm text-foreground/55 whitespace-pre-wrap break-all">
This is the start of your conversation with{" "}
</div>
</div>
</div>
);
}
const messageIndex = shouldShowConversationStart
? virtualRow.index - 1
: virtualRow.index;
const message = messages[messageIndex];
if (!message) {
return null;
}
@ -400,7 +446,7 @@ export default function Screen() {
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start}px)`,
transform: `translateY(${virtualRow.start + verticalOffset}px)`,
padding: "4px 0",
}}
>