(feat): add fullscreen & popout buttons (feat): remove clock icon from awaiting messages (qol): update todos
This commit is contained in:
parent
9cb8ec6183
commit
30cf5ae3b9
14 changed files with 248 additions and 29 deletions
4
bun.lock
4
bun.lock
|
|
@ -255,7 +255,7 @@
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"overrides": {
|
"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",
|
"@tensamin/ui": "https://git.methanium.net/tensamin/ui/archive/0.0.30.tar.gz",
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
|
|
@ -695,7 +695,7 @@
|
||||||
|
|
||||||
"@tensamin/ttp": ["@tensamin/ttp@workspace:packages/ttp"],
|
"@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=="],
|
"@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=="],
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@tensamin/ui": "https://git.methanium.net/tensamin/ui/archive/0.0.30.tar.gz",
|
"@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": {
|
"dependencies": {
|
||||||
"@tensamin/ttp-core": "*",
|
"@tensamin/ttp-core": "*",
|
||||||
|
|
|
||||||
|
|
@ -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 MuteButton from "./buttons/mute";
|
||||||
import DeafButton from "./buttons/deaf";
|
import DeafButton from "./buttons/deaf";
|
||||||
import ScreenshareButton from "./buttons/screenshare";
|
import ScreenshareButton from "./buttons/screenshare";
|
||||||
import LeaveButton from "./buttons/leave";
|
import LeaveButton from "./buttons/leave";
|
||||||
import { stopWatchingFocusedStream, useCall } from "../store";
|
import {
|
||||||
|
setCallIsPopout,
|
||||||
|
stopWatchingFocusedStream,
|
||||||
|
triggerCallLayoutCalculation,
|
||||||
|
useCall,
|
||||||
|
} from "../store";
|
||||||
import InviteButton from "./buttons/invite";
|
import InviteButton from "./buttons/invite";
|
||||||
|
import {
|
||||||
|
Maximize,
|
||||||
|
Minimize,
|
||||||
|
SquareArrowOutDownLeft,
|
||||||
|
SquareArrowOutUpRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
export default function Actions() {
|
export default function Actions() {
|
||||||
const sharedClasses = "w-14 h-10";
|
const sharedClasses = "w-14 h-10";
|
||||||
|
|
@ -19,8 +37,23 @@ export default function Actions() {
|
||||||
focusedParticipantId != null &&
|
focusedParticipantId != null &&
|
||||||
watchedStreamParticipantIds.includes(focusedParticipantId);
|
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 (
|
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">
|
<Card className="p-1.75">
|
||||||
<CardContent className="p-0! flex gap-1.75">
|
<CardContent className="p-0! flex gap-1.75">
|
||||||
<MuteButton className={sharedClasses} iconSize={sharedIconSize} />
|
<MuteButton className={sharedClasses} iconSize={sharedIconSize} />
|
||||||
|
|
@ -43,6 +76,46 @@ export default function Actions() {
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
import {
|
import {
|
||||||
|
Avatar,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarImage,
|
||||||
Button,
|
Button,
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
ContextMenuContent,
|
ContextMenuContent,
|
||||||
|
|
@ -13,7 +16,7 @@ import {
|
||||||
useCall,
|
useCall,
|
||||||
} from "../../store";
|
} from "../../store";
|
||||||
import { Track, type Participant } from "livekit-client";
|
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 { useUser, type User } from "@tensamin/user/context";
|
||||||
import VideoViewer from "../videoViewer";
|
import VideoViewer from "../videoViewer";
|
||||||
import { HeadphoneOff, MicOff, Monitor, Plus, Shield } from "lucide-react";
|
import { HeadphoneOff, MicOff, Monitor, Plus, Shield } from "lucide-react";
|
||||||
|
|
@ -130,6 +133,9 @@ export default function Base({
|
||||||
};
|
};
|
||||||
}, [participant, get]);
|
}, [participant, get]);
|
||||||
|
|
||||||
|
// Avatar calc
|
||||||
|
const currentCard = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
if (!participant || !user) {
|
if (!participant || !user) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -202,9 +208,11 @@ export default function Base({
|
||||||
</>
|
</>
|
||||||
</div>
|
</div>
|
||||||
<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 ${
|
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"
|
flush ? "rounded-none" : "rounded-sm"
|
||||||
}`}
|
}`}
|
||||||
|
style={{ containerType: "size" }}
|
||||||
>
|
>
|
||||||
{/* Detect video / user and place here */}
|
{/* Detect video / user and place here */}
|
||||||
|
|
||||||
|
|
@ -230,7 +238,23 @@ export default function Base({
|
||||||
</div>
|
</div>
|
||||||
) : null)}
|
) : 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>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,10 @@ type CallStore = {
|
||||||
pendingWatchedParticipantIds: number[];
|
pendingWatchedParticipantIds: number[];
|
||||||
activeScreenShareParticipantIds: number[];
|
activeScreenShareParticipantIds: number[];
|
||||||
isEncrypted: boolean;
|
isEncrypted: boolean;
|
||||||
|
callIsFullscreen: boolean;
|
||||||
|
callIsPopout: boolean;
|
||||||
|
layoutVersion: number;
|
||||||
|
screenRef: React.RefObject<HTMLDivElement | null> | null;
|
||||||
room: Room;
|
room: Room;
|
||||||
keyProvider: ExternalE2EEKeyProvider;
|
keyProvider: ExternalE2EEKeyProvider;
|
||||||
e2eeWorker: Worker;
|
e2eeWorker: Worker;
|
||||||
|
|
@ -504,6 +508,24 @@ export function setCallView(view: CallView) {
|
||||||
useCall.setState({ view });
|
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.
|
// Keep the current call id in sync with navigation and connection flow.
|
||||||
export function setCallId(callId: string | null) {
|
export function setCallId(callId: string | null) {
|
||||||
useCall.setState({ callId });
|
useCall.setState({ callId });
|
||||||
|
|
@ -950,6 +972,10 @@ export const useCall = create<CallStore>(() => ({
|
||||||
activeScreenShareParticipantIds: [],
|
activeScreenShareParticipantIds: [],
|
||||||
isEncrypted:
|
isEncrypted:
|
||||||
room.localParticipant.isE2EEEnabled && room.localParticipant.isEncrypted,
|
room.localParticipant.isE2EEEnabled && room.localParticipant.isEncrypted,
|
||||||
|
callIsFullscreen: false,
|
||||||
|
callIsPopout: false,
|
||||||
|
layoutVersion: 0,
|
||||||
|
screenRef: null,
|
||||||
room,
|
room,
|
||||||
keyProvider,
|
keyProvider,
|
||||||
e2eeWorker,
|
e2eeWorker,
|
||||||
|
|
@ -1051,6 +1077,41 @@ export function useInitializeCall() {
|
||||||
}
|
}
|
||||||
}, [location.pathname, location.searchStr]);
|
}, [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
|
// room setup
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (listenersRegistered.current) {
|
if (listenersRegistered.current) {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ const STACK_GAP_PX = 12;
|
||||||
|
|
||||||
export default function View() {
|
export default function View() {
|
||||||
const room = useCall((state) => state.room);
|
const room = useCall((state) => state.room);
|
||||||
|
const layoutVersion = useCall((state) => state.layoutVersion);
|
||||||
|
|
||||||
const focusedParticipantId = useCall((state) => state.focusedParticipantId);
|
const focusedParticipantId = useCall((state) => state.focusedParticipantId);
|
||||||
const activeScreenShareParticipantIds = useCall(
|
const activeScreenShareParticipantIds = useCall(
|
||||||
|
|
@ -136,7 +137,7 @@ export default function View() {
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
window.removeEventListener("resize", syncTileLayout);
|
window.removeEventListener("resize", syncTileLayout);
|
||||||
};
|
};
|
||||||
}, [tiles.length, focusedParticipantId]);
|
}, [layoutVersion, tiles.length, focusedParticipantId]);
|
||||||
|
|
||||||
if (focusedParticipantId == null) {
|
if (focusedParticipantId == null) {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,7 @@ function calculateOptimalGridLayout(
|
||||||
|
|
||||||
export default function View() {
|
export default function View() {
|
||||||
const room = useCall((state) => state.room);
|
const room = useCall((state) => state.room);
|
||||||
|
const layoutVersion = useCall((state) => state.layoutVersion);
|
||||||
const activeScreenShareParticipantIds = useCall(
|
const activeScreenShareParticipantIds = useCall(
|
||||||
(state) => state.activeScreenShareParticipantIds,
|
(state) => state.activeScreenShareParticipantIds,
|
||||||
);
|
);
|
||||||
|
|
@ -98,15 +99,21 @@ export default function View() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const syncContainerSize = () => {
|
||||||
|
const { width, height } = element.getBoundingClientRect();
|
||||||
|
setContainerSize({ width, height });
|
||||||
|
};
|
||||||
|
|
||||||
const observer = new ResizeObserver(([entry]) => {
|
const observer = new ResizeObserver(([entry]) => {
|
||||||
const { width, height } = entry.contentRect;
|
const { width, height } = entry.contentRect;
|
||||||
setContainerSize({ width, height });
|
setContainerSize({ width, height });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
syncContainerSize();
|
||||||
observer.observe(element);
|
observer.observe(element);
|
||||||
|
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, []);
|
}, [layoutVersion]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const syncParticipants = () => {
|
const syncParticipants = () => {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,17 @@
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
import Actions from "../../components/actions";
|
import Actions from "../../components/actions";
|
||||||
import TopBar from "../../components/top";
|
import TopBar from "../../components/top";
|
||||||
|
import { setScreenRef } from "../../store";
|
||||||
|
|
||||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
const screenRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setScreenRef(screenRef);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
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">
|
<div className="shrink-0">
|
||||||
<TopBar />
|
<TopBar />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
- Overlay for stream modals
|
- Overlay for stream modals
|
||||||
- User modals
|
- User modals
|
||||||
- Bg based on avatar
|
- Bg based on avatar
|
||||||
- Avatar in the center
|
|
||||||
- Mobile
|
- Mobile
|
||||||
- Call invite popup
|
- Call invite popup
|
||||||
- Save call invite in `calls` array
|
- Save call invite in `calls` array
|
||||||
|
|
@ -11,3 +10,5 @@
|
||||||
- Admin call actions
|
- Admin call actions
|
||||||
- Timeout
|
- Timeout
|
||||||
- Disconnect
|
- Disconnect
|
||||||
|
- Desktop-App screenshares
|
||||||
|
- Toggle members button in focused view
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import type { RawMessage } from "../values";
|
import type { RawMessage } from "../values";
|
||||||
import Text from "@tensamin/markdown/text";
|
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 { cn, Tooltip, TooltipContent, TooltipTrigger } from "@tensamin/ui";
|
||||||
import { useIsMobile } from "@tensamin/ui";
|
import { useIsMobile } from "@tensamin/ui";
|
||||||
|
|
@ -50,9 +50,6 @@ function MessageComponent({
|
||||||
<TooltipTrigger render={<AlertTriangle size={17} />} />
|
<TooltipTrigger render={<AlertTriangle size={17} />} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{!message.failed && message.message_state === "awaiting" && (
|
|
||||||
<Clock size={17} />
|
|
||||||
)}
|
|
||||||
<Text value={message.content} />
|
<Text value={message.content} />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ export default function Screen() {
|
||||||
scrollTop: number;
|
scrollTop: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [scrollWidth, setScrollWidth] = React.useState(0);
|
const [scrollWidth, setScrollWidth] = React.useState(0);
|
||||||
|
const [scrollHeight, setScrollHeight] = React.useState(0);
|
||||||
const [inputHeight, setInputHeight] = React.useState(0);
|
const [inputHeight, setInputHeight] = React.useState(0);
|
||||||
const [measuredHeights, setMeasuredHeights] = React.useState<
|
const [measuredHeights, setMeasuredHeights] = React.useState<
|
||||||
Record<number, number>
|
Record<number, number>
|
||||||
|
|
@ -93,6 +94,10 @@ export default function Screen() {
|
||||||
|
|
||||||
return [...historicalMessages, ...liveWithoutDuplicates];
|
return [...historicalMessages, ...liveWithoutDuplicates];
|
||||||
}, [historicalMessages, liveMessagesSnapshot]);
|
}, [historicalMessages, liveMessagesSnapshot]);
|
||||||
|
const shouldShowConversationStart =
|
||||||
|
!!messagesQuery.data && !messagesQuery.hasNextPage;
|
||||||
|
const virtualRowCount =
|
||||||
|
messages.length + (shouldShowConversationStart ? 1 : 0);
|
||||||
|
|
||||||
const messagesRef = React.useRef(messages);
|
const messagesRef = React.useRef(messages);
|
||||||
|
|
||||||
|
|
@ -101,8 +106,13 @@ export default function Screen() {
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
const getItemKey = React.useCallback((index: number) => {
|
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(
|
const setMeasurementRef = React.useCallback(
|
||||||
(sendTime: number, element: HTMLDivElement | null) => {
|
(sendTime: number, element: HTMLDivElement | null) => {
|
||||||
|
|
@ -118,7 +128,12 @@ export default function Screen() {
|
||||||
|
|
||||||
const estimateMessageSize = React.useCallback(
|
const estimateMessageSize = React.useCallback(
|
||||||
(index: number) => {
|
(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) {
|
if (sendTime === undefined) {
|
||||||
return FALLBACK_MESSAGE_HEIGHT;
|
return FALLBACK_MESSAGE_HEIGHT;
|
||||||
|
|
@ -126,12 +141,12 @@ export default function Screen() {
|
||||||
|
|
||||||
return measuredHeights[sendTime] ?? FALLBACK_MESSAGE_HEIGHT;
|
return measuredHeights[sendTime] ?? FALLBACK_MESSAGE_HEIGHT;
|
||||||
},
|
},
|
||||||
[measuredHeights, messages],
|
[measuredHeights, messages, shouldShowConversationStart],
|
||||||
);
|
);
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/incompatible-library
|
// eslint-disable-next-line react-hooks/incompatible-library
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: messages.length,
|
count: virtualRowCount,
|
||||||
getScrollElement: () => scrollRef.current,
|
getScrollElement: () => scrollRef.current,
|
||||||
getItemKey,
|
getItemKey,
|
||||||
estimateSize: estimateMessageSize,
|
estimateSize: estimateMessageSize,
|
||||||
|
|
@ -144,13 +159,14 @@ export default function Screen() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateWidth = () => {
|
const updateDimensions = () => {
|
||||||
setScrollWidth(element.clientWidth);
|
setScrollWidth(element.clientWidth);
|
||||||
|
setScrollHeight(element.clientHeight);
|
||||||
};
|
};
|
||||||
|
|
||||||
updateWidth();
|
updateDimensions();
|
||||||
|
|
||||||
const observer = new ResizeObserver(updateWidth);
|
const observer = new ResizeObserver(updateDimensions);
|
||||||
observer.observe(element);
|
observer.observe(element);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|
@ -300,14 +316,14 @@ export default function Screen() {
|
||||||
if (
|
if (
|
||||||
!scrollRef.current ||
|
!scrollRef.current ||
|
||||||
hasScrolledToBottomInitially ||
|
hasScrolledToBottomInitially ||
|
||||||
messages.length === 0
|
virtualRowCount === 0
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
setHasScrolledToBottomInitially(true);
|
setHasScrolledToBottomInitially(true);
|
||||||
}, [hasScrolledToBottomInitially, messages]);
|
}, [hasScrolledToBottomInitially, virtualRowCount]);
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
const count = liveMessagesSnapshot.length;
|
const count = liveMessagesSnapshot.length;
|
||||||
|
|
@ -355,6 +371,9 @@ export default function Screen() {
|
||||||
}, [onScroll]);
|
}, [onScroll]);
|
||||||
|
|
||||||
const [value, setValue] = React.useState("");
|
const [value, setValue] = React.useState("");
|
||||||
|
const totalSize = virtualizer.getTotalSize();
|
||||||
|
const verticalOffset = Math.max(0, scrollHeight - totalSize);
|
||||||
|
const contentHeight = Math.max(totalSize, scrollHeight);
|
||||||
|
|
||||||
// Render
|
// Render
|
||||||
if (!hasValidChatUser) {
|
if (!hasValidChatUser) {
|
||||||
|
|
@ -381,11 +400,38 @@ export default function Screen() {
|
||||||
<div
|
<div
|
||||||
className="relative w-full"
|
className="relative w-full"
|
||||||
style={{
|
style={{
|
||||||
height: `${virtualizer.getTotalSize()}px`,
|
height: `${contentHeight}px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
{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) {
|
if (!message) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -400,7 +446,7 @@ export default function Screen() {
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
width: "100%",
|
width: "100%",
|
||||||
transform: `translateY(${virtualRow.start}px)`,
|
transform: `translateY(${virtualRow.start + verticalOffset}px)`,
|
||||||
padding: "4px 0",
|
padding: "4px 0",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
1
todo.md
Normal file
1
todo.md
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
- Move legal to extra onboarding package
|
||||||
Loading…
Reference in a new issue