(feat): improve mobile cal ui a bit
All checks were successful
/ build-web (push) Successful in 7m40s
/ build-desktop (linux) (push) Successful in 12m10s
/ build-mobile (push) Successful in 19m34s
/ release (push) Successful in 3m31s

(feat): add popout for call ui on mobile
(fix): fix cache and profile avatar upload stuff
This commit is contained in:
Alois 2026-07-31 22:17:08 +02:00
commit b5ce3c554d
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
14 changed files with 443 additions and 155 deletions

View file

@ -1,10 +1,22 @@
import { Participant, Track } from "livekit-client";
import VideoViewer from "./videoViewer";
import { useLocation } from "@tanstack/react-router";
import { getRoom, stopWatchingStream, useCall } from "../store";
import { getRoom, openCallPage, stopWatchingStream, useCall } from "../store";
import { useState, useRef, useEffect, useCallback } from "react";
import { Button, cn } from "@methanium/ui";
import {
Avatar,
AvatarFallback,
AvatarImage,
Button,
Card,
cn,
useIsMobile,
useSidebar,
} from "@methanium/ui";
import { ScreenShareOff } from "lucide-react";
import { useUser, type User } from "@tensamin/user/context";
import { useIsSpeaking, useLastSpeakingParticipantId } from "../speakingState";
import { getAverageImageColor } from "./modals/base";
function getTrackPublicationBySource(
participant: Participant | undefined,
@ -38,6 +50,208 @@ const MARGIN = 40;
const MIN_SIZE = 240;
const MAX_SIZE = 1000;
const ASPECT_RATIO = 9 / 16;
const MOBILE_MARGIN = 16;
const MOBILE_PILL_WIDTH = 128;
const MOBILE_PILL_HEIGHT = 48;
function MobileCallPill({
active,
callId,
}: {
active: boolean;
callId: string;
}) {
const { setOpenMobile } = useSidebar();
const { get } = useUser();
const lastSpeakingParticipantId = useLastSpeakingParticipantId();
const isSpeaking = useIsSpeaking(lastSpeakingParticipantId ?? -1);
const [lastSpeakingUser, setLastSpeakingUser] = useState<User | null>(null);
const [avatarBackgroundColor, setAvatarBackgroundColor] = useState<
string | undefined
>(undefined);
const pillRef = useRef<HTMLDivElement>(null);
const initialCoords = {
x: window.innerWidth - MOBILE_PILL_WIDTH - MOBILE_MARGIN,
y: MOBILE_MARGIN,
};
const coordsRef = useRef<Point>(initialCoords);
const dragOffsetRef = useRef<Point>({ x: 0, y: 0 });
const dragStartRef = useRef<Point>({ x: 0, y: 0 });
const movedRef = useRef(false);
const [position, setPosition] = useState<Positions>("top-right");
const [coords, setCoords] = useState(initialCoords);
const [isDragging, setIsDragging] = useState(false);
useEffect(() => {
if (lastSpeakingParticipantId == null) {
setLastSpeakingUser(null);
return;
}
let mounted = true;
void get(lastSpeakingParticipantId).then((user) => {
if (mounted) {
setLastSpeakingUser(user);
}
});
return () => {
mounted = false;
};
}, [get, lastSpeakingParticipantId]);
useEffect(() => {
if (!lastSpeakingUser?.Avatar) {
setAvatarBackgroundColor(undefined);
return;
}
let mounted = true;
void getAverageImageColor(lastSpeakingUser.Avatar).then((color) => {
if (mounted) {
setAvatarBackgroundColor(color);
}
});
return () => {
mounted = false;
};
}, [lastSpeakingUser?.Avatar]);
const getCoordsForPosition = useCallback((nextPosition: Positions): Point => {
const bounds = pillRef.current?.getBoundingClientRect();
const width = bounds?.width ?? MOBILE_PILL_WIDTH;
const height = bounds?.height ?? MOBILE_PILL_HEIGHT;
return {
x: nextPosition.endsWith("right")
? window.innerWidth - width - MOBILE_MARGIN
: MOBILE_MARGIN,
y: nextPosition.startsWith("bottom")
? window.innerHeight - height - MOBILE_MARGIN
: MOBILE_MARGIN,
};
}, []);
const setCoordsSafe = useCallback((next: Point) => {
coordsRef.current = next;
setCoords(next);
}, []);
const snapToPosition = useCallback(
(nextPosition: Positions) => {
setPosition(nextPosition);
setCoordsSafe(getCoordsForPosition(nextPosition));
},
[getCoordsForPosition, setCoordsSafe],
);
useEffect(() => {
if (active && !isDragging) {
snapToPosition(position);
}
}, [active, isDragging, position, snapToPosition]);
useEffect(() => {
const handleResize = () => snapToPosition(position);
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [position, snapToPosition]);
if (!active) {
return null;
}
return (
<Card
ref={pillRef}
onClick={() => {
if (movedRef.current) {
movedRef.current = false;
return;
}
setOpenMobile(false);
void openCallPage(callId);
}}
onPointerDown={(event) => {
if (event.button !== 0) return;
event.currentTarget.setPointerCapture(event.pointerId);
dragOffsetRef.current = {
x: event.clientX - coordsRef.current.x,
y: event.clientY - coordsRef.current.y,
};
dragStartRef.current = { x: event.clientX, y: event.clientY };
movedRef.current = false;
setIsDragging(true);
}}
onPointerMove={(event) => {
if (!isDragging) return;
const bounds = pillRef.current?.getBoundingClientRect();
const width = bounds?.width ?? MOBILE_PILL_WIDTH;
const height = bounds?.height ?? MOBILE_PILL_HEIGHT;
const next = {
x: Math.min(
window.innerWidth - width - MOBILE_MARGIN,
Math.max(MOBILE_MARGIN, event.clientX - dragOffsetRef.current.x),
),
y: Math.min(
window.innerHeight - height - MOBILE_MARGIN,
Math.max(MOBILE_MARGIN, event.clientY - dragOffsetRef.current.y),
),
};
if (
Math.abs(event.clientX - dragStartRef.current.x) > 3 ||
Math.abs(event.clientY - dragStartRef.current.y) > 3
) {
movedRef.current = true;
}
setCoordsSafe(next);
}}
onPointerUp={(event) => {
if (!isDragging) return;
const nextPosition = `${
event.clientY < window.innerHeight / 2 ? "top" : "bottom"
}-${event.clientX < window.innerWidth / 2 ? "left" : "right"}` as Positions;
setIsDragging(false);
snapToPosition(nextPosition);
}}
onPointerCancel={() => {
setIsDragging(false);
snapToPosition(position);
}}
className={cn(
"fixed left-0 top-0 z-200 flex w-23 h-23! touch-none select-none shadow-xl rounded-2xl flex items-center justify-center",
isSpeaking && "border-3! border-(--primary-foreground-alt)/75!",
isDragging ? "cursor-grabbing" : "cursor-grab",
)}
style={{
backgroundColor: avatarBackgroundColor,
transform: `translate3d(${coords.x}px, ${coords.y}px, 0)`,
transition: isDragging
? "none"
: "transform 420ms cubic-bezier(0.34, 1.56, 0.64, 1)",
willChange: "transform",
}}
>
<Avatar className="size-14">
<AvatarImage src={lastSpeakingUser?.Avatar} />
<AvatarFallback className="text-lg">
{lastSpeakingUser?.Display.slice(0, 2).toUpperCase() ?? "..."}
</AvatarFallback>
</Avatar>
</Card>
);
}
export function Popout({ participant }: { participant: Participant }) {
const screenSharePublication = getTrackPublicationBySource(
@ -455,7 +669,10 @@ export function Popout({ participant }: { participant: Participant }) {
export default function Wrapper() {
const room = getRoom();
const { pathname } = useLocation();
const { openMobile } = useSidebar();
const isMobile = useIsMobile();
const state = useCall((state) => state.state);
const callId = useCall((state) => state.callId);
const watchedStreamParticipantIds = useCall(
(state) => state.watchedStreamParticipantIds,
);
@ -466,6 +683,17 @@ export default function Wrapper() {
String(lastFocusedParticipantId),
);
if (isMobile) {
return (
<MobileCallPill
active={
(!pathname.startsWith("/call") || openMobile) && state === "open"
}
callId={callId ?? ""}
/>
);
}
if (!participant || !lastFocusedParticipantId) {
return null;
}