(feat): improve mobile cal ui a bit
(feat): add popout for call ui on mobile (fix): fix cache and profile avatar upload stuff
This commit is contained in:
parent
37b2f8747d
commit
b5ce3c554d
14 changed files with 443 additions and 155 deletions
|
|
@ -22,7 +22,6 @@ import ChatScreen from "@tensamin/chat/screen";
|
||||||
import CallScreen from "@tensamin/call/screen";
|
import CallScreen from "@tensamin/call/screen";
|
||||||
import Login from "@/routes/screens/login";
|
import Login from "@/routes/screens/login";
|
||||||
|
|
||||||
import CallPopout from "@tensamin/call/popout";
|
|
||||||
import ChatContext from "@tensamin/chat/context";
|
import ChatContext from "@tensamin/chat/context";
|
||||||
import { useCall, useInitializeCall } from "@tensamin/call/store";
|
import { useCall, useInitializeCall } from "@tensamin/call/store";
|
||||||
import { useIsSpeaking } from "@tensamin/call/speakingState";
|
import { useIsSpeaking } from "@tensamin/call/speakingState";
|
||||||
|
|
@ -289,7 +288,6 @@ function AppShell() {
|
||||||
<Session>
|
<Session>
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
<CallInit />
|
<CallInit />
|
||||||
<CallPopout />
|
|
||||||
<TAuthWrapper>
|
<TAuthWrapper>
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<ChatContext>
|
<ChatContext>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { type ReactNode } from "react";
|
||||||
import Sidebar from "@/components/sidebar";
|
import Sidebar from "@/components/sidebar";
|
||||||
import Navbar, { MobileNavbar } from "@/components/navbar";
|
import Navbar, { MobileNavbar } from "@/components/navbar";
|
||||||
import { useShowMobileNavbar } from "./useShowMobileNavbar";
|
import { useShowMobileNavbar } from "./useShowMobileNavbar";
|
||||||
|
import CallPopout from "@tensamin/call/popout";
|
||||||
|
|
||||||
import { useIsMobile, cn, SidebarProvider } from "@methanium/ui";
|
import { useIsMobile, cn, SidebarProvider } from "@methanium/ui";
|
||||||
|
|
||||||
|
|
@ -16,6 +17,7 @@ export default function Layout({ children }: { children: ReactNode }) {
|
||||||
<div className="w-full h-full min-h-0 flex overflow-hidden bg-sidebar">
|
<div className="w-full h-full min-h-0 flex overflow-hidden bg-sidebar">
|
||||||
<SidebarProvider className="h-full min-h-0 overflow-hidden">
|
<SidebarProvider className="h-full min-h-0 overflow-hidden">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
|
<CallPopout />
|
||||||
<div
|
<div
|
||||||
// Background of ui that is overlapping with the system ui
|
// Background of ui that is overlapping with the system ui
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
|
useIsMobile,
|
||||||
|
cn,
|
||||||
} from "@methanium/ui";
|
} from "@methanium/ui";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import MuteButton from "./buttons/mute";
|
import MuteButton from "./buttons/mute";
|
||||||
|
|
@ -77,36 +79,45 @@ export default function Actions() {
|
||||||
(state) => state.usersInFocusedViewHidden,
|
(state) => state.usersInFocusedViewHidden,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex justify-between items-center">
|
<div
|
||||||
<div className="w-30 flex justify-start">
|
className={cn(
|
||||||
{view === "focused" && (
|
"w-full flex items-center",
|
||||||
<Tooltip>
|
isMobile ? "justify-center" : "justify-between",
|
||||||
<TooltipTrigger
|
)}
|
||||||
render={({ ref, onClick }) => (
|
>
|
||||||
<Button
|
{!isMobile && (
|
||||||
ref={ref as React.Ref<HTMLButtonElement>}
|
<div className="w-30 flex justify-start">
|
||||||
onClick={(event) => {
|
{view === "focused" && (
|
||||||
onClick?.(event);
|
<Tooltip>
|
||||||
setUsersInFocusedViewHidden(!usersInFocusedViewHidden);
|
<TooltipTrigger
|
||||||
}}
|
render={({ ref, onClick }) => (
|
||||||
variant="ghost"
|
<Button
|
||||||
className="w-11 h-11! p-0! ml-3 text-foreground border-0!"
|
ref={ref as React.Ref<HTMLButtonElement>}
|
||||||
>
|
onClick={(event) => {
|
||||||
{usersInFocusedViewHidden ? (
|
onClick?.(event);
|
||||||
<ChevronUp className="size-6" />
|
setUsersInFocusedViewHidden(!usersInFocusedViewHidden);
|
||||||
) : (
|
}}
|
||||||
<ChevronDown className="size-6" />
|
variant="ghost"
|
||||||
)}
|
className="w-11 h-11! p-0! ml-3 text-foreground border-0!"
|
||||||
</Button>
|
>
|
||||||
)}
|
{usersInFocusedViewHidden ? (
|
||||||
/>
|
<ChevronUp className="size-6" />
|
||||||
<TooltipContent portalProps={{ container: portalContainer }}>
|
) : (
|
||||||
Hide users
|
<ChevronDown className="size-6" />
|
||||||
</TooltipContent>
|
)}
|
||||||
</Tooltip>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
/>
|
||||||
|
<TooltipContent portalProps={{ container: portalContainer }}>
|
||||||
|
Hide users
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<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
|
<MuteButton
|
||||||
|
|
@ -167,56 +178,58 @@ export default function Actions() {
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<div className="w-30 flex justify-end gap-1.5">
|
{!isMobile && (
|
||||||
<Tooltip>
|
<div className="w-30 flex justify-end gap-1.5">
|
||||||
<TooltipTrigger
|
<Tooltip>
|
||||||
render={({ ref, onClick }) => (
|
<TooltipTrigger
|
||||||
<Button
|
render={({ ref, onClick }) => (
|
||||||
ref={ref}
|
<Button
|
||||||
onClick={(event) => {
|
ref={ref}
|
||||||
onClick?.(event);
|
onClick={(event) => {
|
||||||
setCallIsPopout(!callIsPopout);
|
onClick?.(event);
|
||||||
}}
|
setCallIsPopout(!callIsPopout);
|
||||||
variant="ghost"
|
}}
|
||||||
className="w-11 h-11! p-0! text-foreground border-0!"
|
variant="ghost"
|
||||||
>
|
className="w-11 h-11! p-0! text-foreground border-0!"
|
||||||
{callIsPopout ? (
|
>
|
||||||
<SquareArrowOutDownLeft className="size-6" />
|
{callIsPopout ? (
|
||||||
) : (
|
<SquareArrowOutDownLeft className="size-6" />
|
||||||
<SquareArrowOutUpRight className="size-6" />
|
) : (
|
||||||
)}
|
<SquareArrowOutUpRight className="size-6" />
|
||||||
</Button>
|
)}
|
||||||
)}
|
</Button>
|
||||||
/>
|
)}
|
||||||
<TooltipContent portalProps={{ container: portalContainer }}>
|
/>
|
||||||
Popout
|
<TooltipContent portalProps={{ container: portalContainer }}>
|
||||||
</TooltipContent>
|
Popout
|
||||||
</Tooltip>
|
</TooltipContent>
|
||||||
<Tooltip>
|
</Tooltip>
|
||||||
<TooltipTrigger
|
<Tooltip>
|
||||||
render={({ ref, onClick }) => (
|
<TooltipTrigger
|
||||||
<Button
|
render={({ ref, onClick }) => (
|
||||||
ref={ref}
|
<Button
|
||||||
onClick={(event) => {
|
ref={ref}
|
||||||
onClick?.(event);
|
onClick={(event) => {
|
||||||
void toggleFullscreen();
|
onClick?.(event);
|
||||||
}}
|
void toggleFullscreen();
|
||||||
variant="ghost"
|
}}
|
||||||
className="w-11 h-11! p-0! mr-3 text-foreground border-0!"
|
variant="ghost"
|
||||||
>
|
className="w-11 h-11! p-0! mr-3 text-foreground border-0!"
|
||||||
{callIsFullscreen ? (
|
>
|
||||||
<Minimize className="size-6" />
|
{callIsFullscreen ? (
|
||||||
) : (
|
<Minimize className="size-6" />
|
||||||
<Maximize className="size-6" />
|
) : (
|
||||||
)}
|
<Maximize className="size-6" />
|
||||||
</Button>
|
)}
|
||||||
)}
|
</Button>
|
||||||
/>
|
)}
|
||||||
<TooltipContent portalProps={{ container: portalContainer }}>
|
/>
|
||||||
Fullscreen
|
<TooltipContent portalProps={{ container: portalContainer }}>
|
||||||
</TooltipContent>
|
Fullscreen
|
||||||
</Tooltip>
|
</TooltipContent>
|
||||||
</div>
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
Button,
|
Button,
|
||||||
ContextMenu as UIContextMenu,
|
ContextMenu as UIContextMenu,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
|
cn,
|
||||||
} from "@methanium/ui";
|
} from "@methanium/ui";
|
||||||
import {
|
import {
|
||||||
focusParticipant,
|
focusParticipant,
|
||||||
|
|
@ -43,7 +44,7 @@ function TransparentButton({ children }: { children: React.ReactNode }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAverageImageColor(src: string) {
|
export function getAverageImageColor(src: string) {
|
||||||
return new Promise<string | undefined>((resolve) => {
|
return new Promise<string | undefined>((resolve) => {
|
||||||
const image = new Image();
|
const image = new Image();
|
||||||
|
|
||||||
|
|
@ -264,15 +265,14 @@ export default function Base({
|
||||||
render={
|
render={
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`relative w-full ${fill ? "h-full" : "aspect-video"} ${
|
className={cn(
|
||||||
flush || fill ? "rounded-none" : "rounded-md"
|
"relative w-full",
|
||||||
} ${
|
fill ? "h-full" : "aspect-video",
|
||||||
type === "user" && isSpeaking
|
flush && !fill && "border-x-0!",
|
||||||
? "border-4 border-(--primary-foreground-alt)/75!"
|
type === "user" &&
|
||||||
: fill
|
isSpeaking &&
|
||||||
? "border-0"
|
"border-4 border-(--primary-foreground-alt)/75!",
|
||||||
: "border"
|
)}
|
||||||
} ${flush && !fill ? "border-x-0!" : ""}`}
|
|
||||||
>
|
>
|
||||||
<div className="z-20 absolute bottom-0 left-0 w-full h-full flex justify-start items-end p-2">
|
<div className="z-20 absolute bottom-0 left-0 w-full h-full flex justify-start items-end p-2">
|
||||||
<>
|
<>
|
||||||
|
|
@ -309,7 +309,7 @@ export default function Base({
|
||||||
<div
|
<div
|
||||||
ref={currentCard}
|
ref={currentCard}
|
||||||
className={`transition-all duration-150 z-10 bg-card absolute top-0 left-0 w-full h-full flex gap-2 items-center justify-center ${
|
className={`transition-all duration-150 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-lg"
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: avatarBackgroundColor,
|
backgroundColor: avatarBackgroundColor,
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,22 @@
|
||||||
import { Participant, Track } from "livekit-client";
|
import { Participant, Track } from "livekit-client";
|
||||||
import VideoViewer from "./videoViewer";
|
import VideoViewer from "./videoViewer";
|
||||||
import { useLocation } from "@tanstack/react-router";
|
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 { 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 { ScreenShareOff } from "lucide-react";
|
||||||
|
import { useUser, type User } from "@tensamin/user/context";
|
||||||
|
import { useIsSpeaking, useLastSpeakingParticipantId } from "../speakingState";
|
||||||
|
import { getAverageImageColor } from "./modals/base";
|
||||||
|
|
||||||
function getTrackPublicationBySource(
|
function getTrackPublicationBySource(
|
||||||
participant: Participant | undefined,
|
participant: Participant | undefined,
|
||||||
|
|
@ -38,6 +50,208 @@ const MARGIN = 40;
|
||||||
const MIN_SIZE = 240;
|
const MIN_SIZE = 240;
|
||||||
const MAX_SIZE = 1000;
|
const MAX_SIZE = 1000;
|
||||||
const ASPECT_RATIO = 9 / 16;
|
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 }) {
|
export function Popout({ participant }: { participant: Participant }) {
|
||||||
const screenSharePublication = getTrackPublicationBySource(
|
const screenSharePublication = getTrackPublicationBySource(
|
||||||
|
|
@ -455,7 +669,10 @@ export function Popout({ participant }: { participant: Participant }) {
|
||||||
export default function Wrapper() {
|
export default function Wrapper() {
|
||||||
const room = getRoom();
|
const room = getRoom();
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
|
const { openMobile } = useSidebar();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const state = useCall((state) => state.state);
|
const state = useCall((state) => state.state);
|
||||||
|
const callId = useCall((state) => state.callId);
|
||||||
const watchedStreamParticipantIds = useCall(
|
const watchedStreamParticipantIds = useCall(
|
||||||
(state) => state.watchedStreamParticipantIds,
|
(state) => state.watchedStreamParticipantIds,
|
||||||
);
|
);
|
||||||
|
|
@ -466,6 +683,17 @@ export default function Wrapper() {
|
||||||
String(lastFocusedParticipantId),
|
String(lastFocusedParticipantId),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<MobileCallPill
|
||||||
|
active={
|
||||||
|
(!pathname.startsWith("/call") || openMobile) && state === "open"
|
||||||
|
}
|
||||||
|
callId={callId ?? ""}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!participant || !lastFocusedParticipantId) {
|
if (!participant || !lastFocusedParticipantId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,13 @@ import { create } from "zustand";
|
||||||
|
|
||||||
type SpeakingState = {
|
type SpeakingState = {
|
||||||
speakingParticipantIds: Set<number>;
|
speakingParticipantIds: Set<number>;
|
||||||
|
lastSpeakingParticipantId: number | null;
|
||||||
micGated: boolean;
|
micGated: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const useSpeakingState = create<SpeakingState>(() => ({
|
const useSpeakingState = create<SpeakingState>(() => ({
|
||||||
speakingParticipantIds: new Set(),
|
speakingParticipantIds: new Set(),
|
||||||
|
lastSpeakingParticipantId: null,
|
||||||
micGated: false,
|
micGated: false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -20,10 +22,19 @@ export function clearSpeakingParticipants() {
|
||||||
|
|
||||||
export function removeSpeakingParticipant(participantId: number) {
|
export function removeSpeakingParticipant(participantId: number) {
|
||||||
useSpeakingState.setState((state) => {
|
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);
|
const next = new Set(state.speakingParticipantIds);
|
||||||
next.delete(participantId);
|
next.delete(participantId);
|
||||||
return { speakingParticipantIds: next };
|
return {
|
||||||
|
speakingParticipantIds: next,
|
||||||
|
lastSpeakingParticipantId: wasLastSpeaking
|
||||||
|
? null
|
||||||
|
: state.lastSpeakingParticipantId,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -31,11 +42,13 @@ export function updateSpeakingParticipants(changed: Map<number, boolean>) {
|
||||||
useSpeakingState.setState((state) => {
|
useSpeakingState.setState((state) => {
|
||||||
let hasDiff = false;
|
let hasDiff = false;
|
||||||
const next = new Set(state.speakingParticipantIds);
|
const next = new Set(state.speakingParticipantIds);
|
||||||
|
let lastSpeakingParticipantId = state.lastSpeakingParticipantId;
|
||||||
|
|
||||||
for (const [id, speaking] of changed) {
|
for (const [id, speaking] of changed) {
|
||||||
if (speaking) {
|
if (speaking) {
|
||||||
if (!next.has(id)) {
|
if (!next.has(id)) {
|
||||||
next.add(id);
|
next.add(id);
|
||||||
|
lastSpeakingParticipantId = id;
|
||||||
hasDiff = true;
|
hasDiff = true;
|
||||||
}
|
}
|
||||||
} else if (next.has(id)) {
|
} else if (next.has(id)) {
|
||||||
|
|
@ -44,10 +57,16 @@ export function updateSpeakingParticipants(changed: Map<number, boolean>) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
export function useIsSpeaking(participantId: number): boolean {
|
||||||
return useSpeakingState((state) =>
|
return useSpeakingState((state) =>
|
||||||
state.speakingParticipantIds.has(participantId),
|
state.speakingParticipantIds.has(participantId),
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,8 @@ type IncomingCallInvite = {
|
||||||
senderId: number;
|
senderId: number;
|
||||||
};
|
};
|
||||||
type CurrentCallData =
|
type CurrentCallData =
|
||||||
(z.infer<typeof mtp.CallData.response> & { exists: boolean }) | null;
|
| (z.infer<typeof mtp.CallData.response> & { exists: boolean })
|
||||||
|
| null;
|
||||||
|
|
||||||
type NavigateFn = (options: {
|
type NavigateFn = (options: {
|
||||||
to: string;
|
to: string;
|
||||||
|
|
@ -173,10 +174,7 @@ async function startCallJingle(shouldPlay: () => boolean) {
|
||||||
"settings.call_jingle",
|
"settings.call_jingle",
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (generation !== callJingleGeneration || !shouldPlay()) {
|
||||||
generation !== callJingleGeneration ||
|
|
||||||
!shouldPlay()
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { RoomEvent } from "livekit-client";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useCall, getRoom } from "../../store";
|
import { useCall, getRoom } from "../../store";
|
||||||
import Base from "../../components/modals/base";
|
import Base from "../../components/modals/base";
|
||||||
|
import { cn, useIsMobile } from "@methanium/ui";
|
||||||
|
|
||||||
const TILE_ASPECT_RATIO = 16 / 9;
|
const TILE_ASPECT_RATIO = 16 / 9;
|
||||||
const GRID_GAP = 12;
|
const GRID_GAP = 12;
|
||||||
|
|
@ -214,6 +215,8 @@ export default function View() {
|
||||||
return room.getParticipantByIdentity(String(participantId));
|
return room.getParticipantByIdentity(String(participantId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
|
|
@ -221,7 +224,7 @@ export default function View() {
|
||||||
height: "calc(100% - 2rem)",
|
height: "calc(100% - 2rem)",
|
||||||
width: "calc(100% - 2rem)",
|
width: "calc(100% - 2rem)",
|
||||||
}}
|
}}
|
||||||
className="overflow-hidden p-3"
|
className={cn("overflow-hidden", isMobile ? "" : "p-3")}
|
||||||
>
|
>
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3">
|
<div className="flex h-full w-full flex-col items-center justify-center gap-3">
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
triggerCallLayoutCalculation,
|
triggerCallLayoutCalculation,
|
||||||
useCall,
|
useCall,
|
||||||
} from "../../store";
|
} from "../../store";
|
||||||
|
import { useIsMobile } from "@methanium/ui";
|
||||||
|
|
||||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
const screenRef = useRef<HTMLDivElement>(null);
|
const screenRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -129,6 +130,8 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
setIsImmersiveChromeVisible(false);
|
setIsImmersiveChromeVisible(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={screenRef}
|
ref={screenRef}
|
||||||
|
|
@ -139,23 +142,25 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
onMouseMove={showImmersiveChrome}
|
onMouseMove={showImmersiveChrome}
|
||||||
onMouseLeave={hideImmersiveChrome}
|
onMouseLeave={hideImmersiveChrome}
|
||||||
>
|
>
|
||||||
<div
|
{!isMobile && (
|
||||||
ref={topBarRef}
|
<div
|
||||||
className={`shrink-0 w-full z-40 transition-all duration-200 ${
|
ref={topBarRef}
|
||||||
isImmersiveFocusedView
|
className={`shrink-0 w-full z-40 transition-all duration-200 ${
|
||||||
? immersiveChromeVisible
|
isImmersiveFocusedView
|
||||||
? "opacity-100 translate-y-0 pointer-events-auto"
|
? immersiveChromeVisible
|
||||||
: "opacity-0 -translate-y-2 pointer-events-none"
|
? "opacity-100 translate-y-0 pointer-events-auto"
|
||||||
: "opacity-100 translate-y-0"
|
: "opacity-0 -translate-y-2 pointer-events-none"
|
||||||
}`}
|
: "opacity-100 translate-y-0"
|
||||||
style={{
|
}`}
|
||||||
position: usersInFocusedViewHidden ? "absolute" : "relative",
|
style={{
|
||||||
top: 0,
|
position: usersInFocusedViewHidden ? "absolute" : "relative",
|
||||||
left: 0,
|
top: 0,
|
||||||
}}
|
left: 0,
|
||||||
>
|
}}
|
||||||
<TopBar />
|
>
|
||||||
</div>
|
<TopBar />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className="min-h-0 flex-1 w-full flex justify-center items-center overflow-hidden"
|
className="min-h-0 flex-1 w-full flex justify-center items-center overflow-hidden"
|
||||||
style={
|
style={
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,7 @@ import {
|
||||||
import { log } from "@tensamin/shared/log";
|
import { log } from "@tensamin/shared/log";
|
||||||
import { useStorage } from "@tensamin/storage/context";
|
import { useStorage } from "@tensamin/storage/context";
|
||||||
|
|
||||||
import {
|
import { RECONNECT_RESET, RECONNECT_TRIES, RETRY_INTERVAL } from "./values";
|
||||||
RECONNECT_RESET,
|
|
||||||
RECONNECT_TRIES,
|
|
||||||
RETRY_INTERVAL,
|
|
||||||
} from "./values";
|
|
||||||
|
|
||||||
function base64ToUint8Array(b64: string) {
|
function base64ToUint8Array(b64: string) {
|
||||||
const bin = atob(b64);
|
const bin = atob(b64);
|
||||||
|
|
@ -57,9 +53,7 @@ export type BoundSendFn = <T extends keyof Schemas & string>(
|
||||||
options?: { id?: number },
|
options?: { id?: number },
|
||||||
) => Promise<ProtocolMessage<T>>;
|
) => Promise<ProtocolMessage<T>>;
|
||||||
|
|
||||||
export type PushHandler = (
|
export type PushHandler = (message: ProtocolMessage) => void | Promise<void>;
|
||||||
message: ProtocolMessage,
|
|
||||||
) => void | Promise<void>;
|
|
||||||
|
|
||||||
const PUSH_TYPES = [
|
const PUSH_TYPES = [
|
||||||
"MessageLive",
|
"MessageLive",
|
||||||
|
|
@ -252,7 +246,9 @@ export function Provider(props: {
|
||||||
sonnerToast.error("Connection failed", {
|
sonnerToast.error("Connection failed", {
|
||||||
id: "mtp-connection-toast",
|
id: "mtp-connection-toast",
|
||||||
description:
|
description:
|
||||||
error instanceof Error ? error.message.split(":")[0] : "Connection lost",
|
error instanceof Error
|
||||||
|
? error.message.split(":")[0]
|
||||||
|
: "Connection lost",
|
||||||
icon: null,
|
icon: null,
|
||||||
duration: Infinity,
|
duration: Infinity,
|
||||||
closeButton: true,
|
closeButton: true,
|
||||||
|
|
|
||||||
|
|
@ -52,9 +52,7 @@ export function SettingsSidebar({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
mobile
|
mobile ? "w-full p-1" : "p-3 rounded-tl-2xl border-r bg-input/15 w-50",
|
||||||
? "w-full p-1"
|
|
||||||
: "p-3 rounded-tl-2xl border-r bg-input/15 w-50",
|
|
||||||
"flex flex-col gap-6",
|
"flex flex-col gap-6",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -29,18 +29,22 @@ async function prepImage(
|
||||||
const scale = Math.max(size / bitmap.width, size / bitmap.height);
|
const scale = Math.max(size / bitmap.width, size / bitmap.height);
|
||||||
const width = bitmap.width * scale;
|
const width = bitmap.width * scale;
|
||||||
const height = bitmap.height * scale;
|
const height = bitmap.height * scale;
|
||||||
context.drawImage(
|
try {
|
||||||
bitmap,
|
context.drawImage(
|
||||||
(size - width) / 2,
|
bitmap,
|
||||||
(size - height) / 2,
|
(size - width) / 2,
|
||||||
width,
|
(size - height) / 2,
|
||||||
height,
|
width,
|
||||||
);
|
height,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
bitmap.close();
|
||||||
|
}
|
||||||
return canvas.toDataURL("image/webp", quality);
|
return canvas.toDataURL("image/webp", quality);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const { get } = useUser();
|
const { get, update } = useUser();
|
||||||
const { load } = useStorage();
|
const { load } = useStorage();
|
||||||
const { send } = useMTP();
|
const { send } = useMTP();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
@ -69,7 +73,7 @@ export default function Page() {
|
||||||
}, [currentUser]);
|
}, [currentUser]);
|
||||||
async function handleAvatarUpload(file: File) {
|
async function handleAvatarUpload(file: File) {
|
||||||
const avatar = await prepImage(file);
|
const avatar = await prepImage(file);
|
||||||
updateDraftUser((previous) => ({ ...previous, avatar }));
|
updateDraftUser((previous) => ({ ...previous, Avatar: avatar }));
|
||||||
if (avatarUploadRef.current) avatarUploadRef.current.value = "";
|
if (avatarUploadRef.current) avatarUploadRef.current.value = "";
|
||||||
}
|
}
|
||||||
if (!currentUser) return <p>Loading...</p>;
|
if (!currentUser) return <p>Loading...</p>;
|
||||||
|
|
@ -102,7 +106,7 @@ export default function Page() {
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
updateDraftUser((previous) => ({
|
updateDraftUser((previous) => ({
|
||||||
...previous,
|
...previous,
|
||||||
avatar: "none",
|
Avatar: undefined,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
|
|
@ -158,7 +162,7 @@ export default function Page() {
|
||||||
...draftUsersWithoutAvatar,
|
...draftUsersWithoutAvatar,
|
||||||
...(typeof Avatar === "string"
|
...(typeof Avatar === "string"
|
||||||
? {
|
? {
|
||||||
avatar: Avatar.startsWith("data:")
|
Avatar: Avatar.startsWith("data:")
|
||||||
? (Avatar.split(",", 2)[1] ?? "")
|
? (Avatar.split(",", 2)[1] ?? "")
|
||||||
: Avatar,
|
: Avatar,
|
||||||
}
|
}
|
||||||
|
|
@ -173,7 +177,17 @@ export default function Page() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
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);
|
setSaveSucceeded(true);
|
||||||
setErrorMessage("");
|
setErrorMessage("");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,10 @@ import { getDatabaseEntry, setDatabaseEntry } from "@tensamin/shared/indexedDb";
|
||||||
|
|
||||||
export type SecureStorageStatus = {
|
export type SecureStorageStatus = {
|
||||||
backend:
|
backend:
|
||||||
"electron-keyring" | "application-storage" | "webcrypto" | "indexeddb";
|
| "electron-keyring"
|
||||||
|
| "application-storage"
|
||||||
|
| "webcrypto"
|
||||||
|
| "indexeddb";
|
||||||
secure: boolean;
|
secure: boolean;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ const USER_CACHE_MAX_AGE = 5 * 60 * 1000;
|
||||||
|
|
||||||
interface contextValue {
|
interface contextValue {
|
||||||
get(userId: number): Promise<User>;
|
get(userId: number): Promise<User>;
|
||||||
|
update(user: User): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserContext = createContext<contextValue | undefined>(undefined);
|
const UserContext = createContext<contextValue | undefined>(undefined);
|
||||||
|
|
@ -39,6 +40,7 @@ export default function UserProvider(props: { children: ReactNode }) {
|
||||||
const { load } = useStorage();
|
const { load } = useStorage();
|
||||||
const { contacts } = useSession();
|
const { contacts } = useSession();
|
||||||
const [accountId, setAccountId] = useState<number | null>(null);
|
const [accountId, setAccountId] = useState<number | null>(null);
|
||||||
|
const [cacheVersion, setCacheVersion] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load("user_id").then((accountId) => {
|
void load("user_id").then((accountId) => {
|
||||||
|
|
@ -53,6 +55,7 @@ export default function UserProvider(props: { children: ReactNode }) {
|
||||||
*/
|
*/
|
||||||
const get = useCallback(
|
const get = useCallback(
|
||||||
async (userId: number): Promise<User> => {
|
async (userId: number): Promise<User> => {
|
||||||
|
void cacheVersion;
|
||||||
if (userId == null) {
|
if (userId == null) {
|
||||||
throw new Error("userId is required");
|
throw new Error("userId is required");
|
||||||
}
|
}
|
||||||
|
|
@ -63,28 +66,26 @@ export default function UserProvider(props: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const request = (async () => {
|
const request = (async () => {
|
||||||
const cache = accountId ? createCache(String(accountId)) : null;
|
const cache = createCache(String(accountId ?? userId));
|
||||||
const cachedValue =
|
const cachedValue =
|
||||||
storageRef.current[userId] ?? (await cache?.profiles.get(userId));
|
storageRef.current[userId] ?? (await cache.profiles.get(userId));
|
||||||
const cachedResult =
|
const cachedResult =
|
||||||
schemas.GetUserData.response.safeParse(cachedValue);
|
schemas.GetUserData.response.safeParse(cachedValue);
|
||||||
const cached = cachedResult.success ? cachedResult.data : undefined;
|
const cached = cachedResult.success ? cachedResult.data : undefined;
|
||||||
if (cached) {
|
if (cached) {
|
||||||
storageRef.current[userId] = cached;
|
storageRef.current[userId] = cached;
|
||||||
const checkedAt = checkedAtRef.current[userId];
|
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;
|
return cached;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const userData = await send("GetUserData", { UserId: userId });
|
const userData = await send("GetUserData", { UserId: userId });
|
||||||
if (
|
if (userData.type === "ErrorNotFound" || userData.data.UserId === 0) {
|
||||||
userData.type === "ErrorNotFound" ||
|
|
||||||
userData.data.UserId === 0
|
|
||||||
) {
|
|
||||||
delete storageRef.current[userId];
|
delete storageRef.current[userId];
|
||||||
delete checkedAtRef.current[userId];
|
delete checkedAtRef.current[userId];
|
||||||
await cache?.profiles.delete(userId);
|
await cache.profiles.delete(userId);
|
||||||
throw new Error("GetUserData failed: user not found");
|
throw new Error("GetUserData failed: user not found");
|
||||||
}
|
}
|
||||||
if (userData.type.startsWith("Error")) {
|
if (userData.type.startsWith("Error")) {
|
||||||
|
|
@ -111,7 +112,17 @@ export default function UserProvider(props: { children: ReactNode }) {
|
||||||
delete pendingRef.current[userId];
|
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(() => {
|
useEffect(() => {
|
||||||
|
|
@ -120,7 +131,7 @@ export default function UserProvider(props: { children: ReactNode }) {
|
||||||
}, [accountId, contacts, get]);
|
}, [accountId, contacts, get]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserContext.Provider value={{ get }}>
|
<UserContext.Provider value={{ get, update }}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</UserContext.Provider>
|
</UserContext.Provider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue