diff --git a/bun.lock b/bun.lock
index 40d6841..d69a8f8 100644
--- a/bun.lock
+++ b/bun.lock
@@ -255,7 +255,7 @@
},
},
"overrides": {
- "@tensamin/ttp-core": "https://git.methanium.net/tensamin/ttp/archive/0.0.15.tar.gz",
+ "@tensamin/ttp-core": "https://git.methanium.net/tensamin/ttp/archive/0.0.17.tar.gz",
"@tensamin/ui": "https://git.methanium.net/tensamin/ui/archive/0.0.30.tar.gz",
},
"packages": {
@@ -695,7 +695,7 @@
"@tensamin/ttp": ["@tensamin/ttp@workspace:packages/ttp"],
- "@tensamin/ttp-core": ["@tensamin/ttp-core@https://git.methanium.net/tensamin/ttp/archive/0.0.15.tar.gz", { "dependencies": { "@eslint/js": "^10.0.1", "@typescript-eslint/parser": "^8.59.1", "@webtransport-bun/webtransport": "^0.3.0", "globals": "^17.5.0", "typescript": "^6.0.3", "typescript-eslint": "^8.59.1", "zod": "^4.4.1" } }, "sha512-jeaju9OB/RX2LxdDISWOVM5HMSNHOFF3dIxhgF+niwiOm9qLPMdEIqdUDIPat1dwlKugsNHfZfrbW5cW+xXJag=="],
+ "@tensamin/ttp-core": ["@tensamin/ttp-core@https://git.methanium.net/tensamin/ttp/archive/0.0.17.tar.gz", { "dependencies": { "@eslint/js": "^10.0.1", "@typescript-eslint/parser": "^8.59.1", "@webtransport-bun/webtransport": "^0.3.0", "globals": "^17.5.0", "typescript": "^6.0.3", "typescript-eslint": "^8.59.1", "zod": "^4.4.1" } }, "sha512-smyx+04hSnWM1oyWJfJrRWmxu7OInwyHeIlaD1h3tUrkrSxiKWHl1qCmxVO1bC+cDwyUXhJ5HUnNCbx1aWx7Dg=="],
"@tensamin/ui": ["@tensamin/ui@https://git.methanium.net/tensamin/ui/archive/0.0.30.tar.gz", { "dependencies": { "@base-ui/react": "^1.3.0", "@fontsource-variable/inter": "^5.2.6", "@tauri-apps/api": "^2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", "input-otp": "^1.4.2", "lucide-react": "^1.8.0", "next-themes": "^0.4.6", "react-day-picker": "^9.14.0", "react-resizable-panels": "^4.10.0", "recharts": "3.8.0", "shadcn": "^3.5.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.3.0", "vaul": "^1.1.2" }, "peerDependencies": { "react": "^19.2.0", "react-dom": "^19.2.0" } }, "sha512-dlJBw7M/GFGHveJrL6uZA2d9yjAJ9F8QNJ2NfqCHTwzMoF6Y2ESHdeKZE5wBr2BEh9ublNqAGpN0TGD3iDV8rQ=="],
diff --git a/package.json b/package.json
index 219e5b1..4cb4c7b 100644
--- a/package.json
+++ b/package.json
@@ -40,7 +40,7 @@
},
"overrides": {
"@tensamin/ui": "https://git.methanium.net/tensamin/ui/archive/0.0.30.tar.gz",
- "@tensamin/ttp-core": "https://git.methanium.net/tensamin/ttp/archive/0.0.15.tar.gz"
+ "@tensamin/ttp-core": "https://git.methanium.net/tensamin/ttp/archive/0.0.17.tar.gz"
},
"dependencies": {
"@tensamin/ttp-core": "*",
diff --git a/packages/call/src/components/actions.tsx b/packages/call/src/components/actions.tsx
index 23aabcc..0002e2f 100644
--- a/packages/call/src/components/actions.tsx
+++ b/packages/call/src/components/actions.tsx
@@ -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 (
-
+
+
@@ -43,6 +76,46 @@ export default function Actions() {
)}
+
+
+ setCallIsPopout(!callIsPopout)}
+ variant="link"
+ className="w-11 h-11 p-0! text-foreground"
+ >
+ {callIsPopout ? (
+
+ ) : (
+
+ )}
+
+ }
+ />
+ Popout
+
+
+ {
+ void toggleFullscreen();
+ }}
+ variant="link"
+ className="w-11 h-11 p-0! mr-3 text-foreground"
+ >
+ {callIsFullscreen ? (
+
+ ) : (
+
+ )}
+
+ }
+ />
+ Fullscreen
+
+
);
}
diff --git a/packages/call/src/components/modals/base.tsx b/packages/call/src/components/modals/base.tsx
index d668aff..03327d9 100644
--- a/packages/call/src/components/modals/base.tsx
+++ b/packages/call/src/components/modals/base.tsx
@@ -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
(null);
+
if (!participant || !user) {
return (
{/* Detect video / user and place here */}
@@ -230,7 +238,23 @@ export default function Base({
) : null)}
- {type === "user" && {user.display}
}
+ {type === "user" && (
+
+
+
+ {user.display.slice(0, 2).toUpperCase()}
+
+
+ )}
}
diff --git a/packages/call/src/components/modals/user.tsx b/packages/call/src/components/modals/user.tsx
deleted file mode 100644
index e69de29..0000000
diff --git a/packages/call/src/components/modals/video.tsx b/packages/call/src/components/modals/video.tsx
deleted file mode 100644
index e69de29..0000000
diff --git a/packages/call/src/store.tsx b/packages/call/src/store.tsx
index 2ab212c..e25b609 100644
--- a/packages/call/src/store.tsx
+++ b/packages/call/src/store.tsx
@@ -90,6 +90,10 @@ type CallStore = {
pendingWatchedParticipantIds: number[];
activeScreenShareParticipantIds: number[];
isEncrypted: boolean;
+ callIsFullscreen: boolean;
+ callIsPopout: boolean;
+ layoutVersion: number;
+ screenRef: React.RefObject | 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 | 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(() => ({
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) {
diff --git a/packages/call/src/views/main/focused.tsx b/packages/call/src/views/main/focused.tsx
index cb10ee0..ba056a0 100644
--- a/packages/call/src/views/main/focused.tsx
+++ b/packages/call/src/views/main/focused.tsx
@@ -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;
diff --git a/packages/call/src/views/main/grid.tsx b/packages/call/src/views/main/grid.tsx
index c84436e..27be20e 100644
--- a/packages/call/src/views/main/grid.tsx
+++ b/packages/call/src/views/main/grid.tsx
@@ -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 = () => {
diff --git a/packages/call/src/views/main/layout.tsx b/packages/call/src/views/main/layout.tsx
index b2370c1..959cfcf 100644
--- a/packages/call/src/views/main/layout.tsx
+++ b/packages/call/src/views/main/layout.tsx
@@ -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(null);
+
+ useEffect(() => {
+ setScreenRef(screenRef);
+ }, []);
+
return (
-
+
diff --git a/packages/call/todo.md b/packages/call/todo.md
index 6a71bb8..c0f8666 100644
--- a/packages/call/todo.md
+++ b/packages/call/todo.md
@@ -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
diff --git a/packages/chat/src/components/message.tsx b/packages/chat/src/components/message.tsx
index db4fc20..e85d890 100644
--- a/packages/chat/src/components/message.tsx
+++ b/packages/chat/src/components/message.tsx
@@ -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({
} />
)}
- {!message.failed && message.message_state === "awaiting" && (
-
- )}
}
diff --git a/packages/chat/src/screen.tsx b/packages/chat/src/screen.tsx
index cfcd628..aedcc90 100644
--- a/packages/chat/src/screen.tsx
+++ b/packages/chat/src/screen.tsx
@@ -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
@@ -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() {
{virtualizer.getVirtualItems().map((virtualRow) => {
- const message = messages[virtualRow.index];
+ if (shouldShowConversationStart && virtualRow.index === 0) {
+ return (
+
+
+
+ This is the start of your conversation with{" "}
+
+
+
+ );
+ }
+
+ 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",
}}
>
diff --git a/todo.md b/todo.md
new file mode 100644
index 0000000..837b847
--- /dev/null
+++ b/todo.md
@@ -0,0 +1 @@
+- Move legal to extra onboarding package