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