From b5ce3c554de16327c9439c7f1b537867d440fdcc Mon Sep 17 00:00:00 2001 From: Alois Date: Fri, 31 Jul 2026 22:17:08 +0200 Subject: [PATCH] (feat): improve mobile cal ui a bit (feat): add popout for call ui on mobile (fix): fix cache and profile avatar upload stuff --- apps/web/src/index.tsx | 2 - apps/web/src/routes/app/layout.tsx | 2 + packages/call/src/components/actions.tsx | 171 +++++++------- packages/call/src/components/modals/base.tsx | 22 +- packages/call/src/components/popout.tsx | 232 ++++++++++++++++++- packages/call/src/speakingState.ts | 25 +- packages/call/src/store.tsx | 8 +- packages/call/src/views/main/grid.tsx | 5 +- packages/call/src/views/main/layout.tsx | 39 ++-- packages/mtp/src/context.tsx | 14 +- packages/settings/src/layout.tsx | 4 +- packages/settings/src/pages/profile.tsx | 38 ++- packages/storage/src/secure.ts | 5 +- packages/user/src/context.tsx | 31 ++- 14 files changed, 443 insertions(+), 155 deletions(-) diff --git a/apps/web/src/index.tsx b/apps/web/src/index.tsx index 6782f73..8a7e776 100644 --- a/apps/web/src/index.tsx +++ b/apps/web/src/index.tsx @@ -22,7 +22,6 @@ import ChatScreen from "@tensamin/chat/screen"; import CallScreen from "@tensamin/call/screen"; import Login from "@/routes/screens/login"; -import CallPopout from "@tensamin/call/popout"; import ChatContext from "@tensamin/chat/context"; import { useCall, useInitializeCall } from "@tensamin/call/store"; import { useIsSpeaking } from "@tensamin/call/speakingState"; @@ -289,7 +288,6 @@ function AppShell() { - diff --git a/apps/web/src/routes/app/layout.tsx b/apps/web/src/routes/app/layout.tsx index 61fea08..9a46805 100644 --- a/apps/web/src/routes/app/layout.tsx +++ b/apps/web/src/routes/app/layout.tsx @@ -3,6 +3,7 @@ import { type ReactNode } from "react"; import Sidebar from "@/components/sidebar"; import Navbar, { MobileNavbar } from "@/components/navbar"; import { useShowMobileNavbar } from "./useShowMobileNavbar"; +import CallPopout from "@tensamin/call/popout"; import { useIsMobile, cn, SidebarProvider } from "@methanium/ui"; @@ -16,6 +17,7 @@ export default function Layout({ children }: { children: ReactNode }) {
+
state.usersInFocusedViewHidden, ); + const isMobile = useIsMobile(); + return ( -
-
- {view === "focused" && ( - - ( - - )} - /> - - Hide users - - - )} -
+
+ {!isMobile && ( +
+ {view === "focused" && ( + + ( + + )} + /> + + Hide users + + + )} +
+ )} -
- - ( - - )} - /> - - Popout - - - - ( - - )} - /> - - Fullscreen - - -
+ {!isMobile && ( +
+ + ( + + )} + /> + + Popout + + + + ( + + )} + /> + + Fullscreen + + +
+ )}
); } diff --git a/packages/call/src/components/modals/base.tsx b/packages/call/src/components/modals/base.tsx index 550265b..5eaa5b7 100644 --- a/packages/call/src/components/modals/base.tsx +++ b/packages/call/src/components/modals/base.tsx @@ -5,6 +5,7 @@ import { Button, ContextMenu as UIContextMenu, ContextMenuTrigger, + cn, } from "@methanium/ui"; import { focusParticipant, @@ -43,7 +44,7 @@ function TransparentButton({ children }: { children: React.ReactNode }) { ); } -function getAverageImageColor(src: string) { +export function getAverageImageColor(src: string) { return new Promise((resolve) => { const image = new Image(); @@ -264,15 +265,14 @@ export default function Base({ render={
<> @@ -309,7 +309,7 @@ export default function Base({
(null); + const [avatarBackgroundColor, setAvatarBackgroundColor] = useState< + string | undefined + >(undefined); + const pillRef = useRef(null); + const initialCoords = { + x: window.innerWidth - MOBILE_PILL_WIDTH - MOBILE_MARGIN, + y: MOBILE_MARGIN, + }; + const coordsRef = useRef(initialCoords); + const dragOffsetRef = useRef({ x: 0, y: 0 }); + const dragStartRef = useRef({ x: 0, y: 0 }); + const movedRef = useRef(false); + const [position, setPosition] = useState("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 ( + { + 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", + }} + > + + + + {lastSpeakingUser?.Display.slice(0, 2).toUpperCase() ?? "..."} + + + + ); +} 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 ( + + ); + } + if (!participant || !lastFocusedParticipantId) { return null; } diff --git a/packages/call/src/speakingState.ts b/packages/call/src/speakingState.ts index 8f9e815..b689bf5 100644 --- a/packages/call/src/speakingState.ts +++ b/packages/call/src/speakingState.ts @@ -2,11 +2,13 @@ import { create } from "zustand"; type SpeakingState = { speakingParticipantIds: Set; + lastSpeakingParticipantId: number | null; micGated: boolean; }; const useSpeakingState = create(() => ({ speakingParticipantIds: new Set(), + lastSpeakingParticipantId: null, micGated: false, })); @@ -20,10 +22,19 @@ export function clearSpeakingParticipants() { export function removeSpeakingParticipant(participantId: number) { useSpeakingState.setState((state) => { - if (!state.speakingParticipantIds.has(participantId)) return state; + const wasSpeaking = state.speakingParticipantIds.has(participantId); + const wasLastSpeaking = state.lastSpeakingParticipantId === participantId; + + if (!wasSpeaking && !wasLastSpeaking) return state; + const next = new Set(state.speakingParticipantIds); next.delete(participantId); - return { speakingParticipantIds: next }; + return { + speakingParticipantIds: next, + lastSpeakingParticipantId: wasLastSpeaking + ? null + : state.lastSpeakingParticipantId, + }; }); } @@ -31,11 +42,13 @@ export function updateSpeakingParticipants(changed: Map) { useSpeakingState.setState((state) => { let hasDiff = false; const next = new Set(state.speakingParticipantIds); + let lastSpeakingParticipantId = state.lastSpeakingParticipantId; for (const [id, speaking] of changed) { if (speaking) { if (!next.has(id)) { next.add(id); + lastSpeakingParticipantId = id; hasDiff = true; } } else if (next.has(id)) { @@ -44,10 +57,16 @@ export function updateSpeakingParticipants(changed: Map) { } } - return hasDiff ? { speakingParticipantIds: next } : state; + return hasDiff + ? { speakingParticipantIds: next, lastSpeakingParticipantId } + : state; }); } +export function useLastSpeakingParticipantId(): number | null { + return useSpeakingState((state) => state.lastSpeakingParticipantId); +} + export function useIsSpeaking(participantId: number): boolean { return useSpeakingState((state) => state.speakingParticipantIds.has(participantId), diff --git a/packages/call/src/store.tsx b/packages/call/src/store.tsx index 0d70a70..35bd61b 100644 --- a/packages/call/src/store.tsx +++ b/packages/call/src/store.tsx @@ -69,7 +69,8 @@ type IncomingCallInvite = { senderId: number; }; type CurrentCallData = - (z.infer & { exists: boolean }) | null; + | (z.infer & { exists: boolean }) + | null; type NavigateFn = (options: { to: string; @@ -173,10 +174,7 @@ async function startCallJingle(shouldPlay: () => boolean) { "settings.call_jingle", ); - if ( - generation !== callJingleGeneration || - !shouldPlay() - ) { + if (generation !== callJingleGeneration || !shouldPlay()) { return; } diff --git a/packages/call/src/views/main/grid.tsx b/packages/call/src/views/main/grid.tsx index 956842d..de6ba9b 100644 --- a/packages/call/src/views/main/grid.tsx +++ b/packages/call/src/views/main/grid.tsx @@ -2,6 +2,7 @@ import { RoomEvent } from "livekit-client"; import { useEffect, useMemo, useRef, useState } from "react"; import { useCall, getRoom } from "../../store"; import Base from "../../components/modals/base"; +import { cn, useIsMobile } from "@methanium/ui"; const TILE_ASPECT_RATIO = 16 / 9; const GRID_GAP = 12; @@ -214,6 +215,8 @@ export default function View() { return room.getParticipantByIdentity(String(participantId)); } + const isMobile = useIsMobile(); + return (
{rows.map((row) => ( diff --git a/packages/call/src/views/main/layout.tsx b/packages/call/src/views/main/layout.tsx index f20e233..d16ba0b 100644 --- a/packages/call/src/views/main/layout.tsx +++ b/packages/call/src/views/main/layout.tsx @@ -7,6 +7,7 @@ import { triggerCallLayoutCalculation, useCall, } from "../../store"; +import { useIsMobile } from "@methanium/ui"; export default function Layout({ children }: { children: React.ReactNode }) { const screenRef = useRef(null); @@ -129,6 +130,8 @@ export default function Layout({ children }: { children: React.ReactNode }) { setIsImmersiveChromeVisible(false); }; + const isMobile = useIsMobile(); + return (
-
- -
+ {!isMobile && ( +
+ +
+ )}
( options?: { id?: number }, ) => Promise>; -export type PushHandler = ( - message: ProtocolMessage, -) => void | Promise; +export type PushHandler = (message: ProtocolMessage) => void | Promise; const PUSH_TYPES = [ "MessageLive", @@ -252,7 +246,9 @@ export function Provider(props: { sonnerToast.error("Connection failed", { id: "mtp-connection-toast", description: - error instanceof Error ? error.message.split(":")[0] : "Connection lost", + error instanceof Error + ? error.message.split(":")[0] + : "Connection lost", icon: null, duration: Infinity, closeButton: true, diff --git a/packages/settings/src/layout.tsx b/packages/settings/src/layout.tsx index 095fd15..53759cd 100644 --- a/packages/settings/src/layout.tsx +++ b/packages/settings/src/layout.tsx @@ -52,9 +52,7 @@ export function SettingsSidebar({ return (
({ ...previous, avatar })); + updateDraftUser((previous) => ({ ...previous, Avatar: avatar })); if (avatarUploadRef.current) avatarUploadRef.current.value = ""; } if (!currentUser) return

Loading...

; @@ -102,7 +106,7 @@ export default function Page() { onClick={() => updateDraftUser((previous) => ({ ...previous, - avatar: "none", + Avatar: undefined, })) } variant="destructive" @@ -158,7 +162,7 @@ export default function Page() { ...draftUsersWithoutAvatar, ...(typeof Avatar === "string" ? { - avatar: Avatar.startsWith("data:") + Avatar: Avatar.startsWith("data:") ? (Avatar.split(",", 2)[1] ?? "") : Avatar, } @@ -173,7 +177,17 @@ export default function Page() { return; } try { - await send("ChangeUserData", validation.data); + const response = await send("ChangeUserData", validation.data); + if (response.type.startsWith("Error")) { + throw new Error(response.type); + } + const updatedUser = mtp.GetUserData.response.parse({ + ...currentUser, + ...draftUser, + }); + await update(updatedUser); + setCurrentUser(updatedUser); + setDraftUser(updatedUser); setSaveSucceeded(true); setErrorMessage(""); } catch (error) { diff --git a/packages/storage/src/secure.ts b/packages/storage/src/secure.ts index 618977c..283d5c0 100644 --- a/packages/storage/src/secure.ts +++ b/packages/storage/src/secure.ts @@ -4,7 +4,10 @@ import { getDatabaseEntry, setDatabaseEntry } from "@tensamin/shared/indexedDb"; export type SecureStorageStatus = { backend: - "electron-keyring" | "application-storage" | "webcrypto" | "indexeddb"; + | "electron-keyring" + | "application-storage" + | "webcrypto" + | "indexeddb"; secure: boolean; reason?: string; }; diff --git a/packages/user/src/context.tsx b/packages/user/src/context.tsx index 4bf2ff5..390cba0 100644 --- a/packages/user/src/context.tsx +++ b/packages/user/src/context.tsx @@ -21,6 +21,7 @@ const USER_CACHE_MAX_AGE = 5 * 60 * 1000; interface contextValue { get(userId: number): Promise; + update(user: User): Promise; } const UserContext = createContext(undefined); @@ -39,6 +40,7 @@ export default function UserProvider(props: { children: ReactNode }) { const { load } = useStorage(); const { contacts } = useSession(); const [accountId, setAccountId] = useState(null); + const [cacheVersion, setCacheVersion] = useState(0); useEffect(() => { void load("user_id").then((accountId) => { @@ -53,6 +55,7 @@ export default function UserProvider(props: { children: ReactNode }) { */ const get = useCallback( async (userId: number): Promise => { + void cacheVersion; if (userId == null) { throw new Error("userId is required"); } @@ -63,28 +66,26 @@ export default function UserProvider(props: { children: ReactNode }) { } const request = (async () => { - const cache = accountId ? createCache(String(accountId)) : null; + const cache = createCache(String(accountId ?? userId)); const cachedValue = - storageRef.current[userId] ?? (await cache?.profiles.get(userId)); + storageRef.current[userId] ?? (await cache.profiles.get(userId)); const cachedResult = schemas.GetUserData.response.safeParse(cachedValue); const cached = cachedResult.success ? cachedResult.data : undefined; if (cached) { storageRef.current[userId] = cached; const checkedAt = checkedAtRef.current[userId]; - if (checkedAt && Date.now() - checkedAt < USER_CACHE_MAX_AGE) { + if (!checkedAt || Date.now() - checkedAt < USER_CACHE_MAX_AGE) { + checkedAtRef.current[userId] = Date.now(); return cached; } } try { const userData = await send("GetUserData", { UserId: userId }); - if ( - userData.type === "ErrorNotFound" || - userData.data.UserId === 0 - ) { + if (userData.type === "ErrorNotFound" || userData.data.UserId === 0) { delete storageRef.current[userId]; delete checkedAtRef.current[userId]; - await cache?.profiles.delete(userId); + await cache.profiles.delete(userId); throw new Error("GetUserData failed: user not found"); } if (userData.type.startsWith("Error")) { @@ -111,7 +112,17 @@ export default function UserProvider(props: { children: ReactNode }) { delete pendingRef.current[userId]; } }, - [accountId, send], + [accountId, cacheVersion, send], + ); + + const update = useCallback( + async (user: User) => { + await createCache(String(accountId ?? user.UserId)).profiles.put(user); + storageRef.current[user.UserId] = user; + checkedAtRef.current[user.UserId] = Date.now(); + setCacheVersion((version) => version + 1); + }, + [accountId], ); useEffect(() => { @@ -120,7 +131,7 @@ export default function UserProvider(props: { children: ReactNode }) { }, [accountId, contacts, get]); return ( - + {props.children} );