(feat): big call and chatting stuff #23

Merged
alois merged 33 commits from dev into main 2026-08-01 03:05:40 +03:00
14 changed files with 443 additions and 155 deletions
Showing only changes of commit b5ce3c554d - Show all commits

(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
Alois 2026-07-31 22:17:08 +02:00
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24

View file

@ -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() {
<Session>
<UserProvider>
<CallInit />
<CallPopout />
<TAuthWrapper>
<AppLayout>
<ChatContext>

View file

@ -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 }) {
<div className="w-full h-full min-h-0 flex overflow-hidden bg-sidebar">
<SidebarProvider className="h-full min-h-0 overflow-hidden">
<Sidebar />
<CallPopout />
<div
// Background of ui that is overlapping with the system ui
className={cn(

View file

@ -5,6 +5,8 @@ import {
Tooltip,
TooltipContent,
TooltipTrigger,
useIsMobile,
cn,
} from "@methanium/ui";
import { useEffect, useState } from "react";
import MuteButton from "./buttons/mute";
@ -77,36 +79,45 @@ export default function Actions() {
(state) => state.usersInFocusedViewHidden,
);
const isMobile = useIsMobile();
return (
<div className="w-full flex justify-between items-center">
<div className="w-30 flex justify-start">
{view === "focused" && (
<Tooltip>
<TooltipTrigger
render={({ ref, onClick }) => (
<Button
ref={ref as React.Ref<HTMLButtonElement>}
onClick={(event) => {
onClick?.(event);
setUsersInFocusedViewHidden(!usersInFocusedViewHidden);
}}
variant="ghost"
className="w-11 h-11! p-0! ml-3 text-foreground border-0!"
>
{usersInFocusedViewHidden ? (
<ChevronUp className="size-6" />
) : (
<ChevronDown className="size-6" />
)}
</Button>
)}
/>
<TooltipContent portalProps={{ container: portalContainer }}>
Hide users
</TooltipContent>
</Tooltip>
)}
</div>
<div
className={cn(
"w-full flex items-center",
isMobile ? "justify-center" : "justify-between",
)}
>
{!isMobile && (
<div className="w-30 flex justify-start">
{view === "focused" && (
<Tooltip>
<TooltipTrigger
render={({ ref, onClick }) => (
<Button
ref={ref as React.Ref<HTMLButtonElement>}
onClick={(event) => {
onClick?.(event);
setUsersInFocusedViewHidden(!usersInFocusedViewHidden);
}}
variant="ghost"
className="w-11 h-11! p-0! ml-3 text-foreground border-0!"
>
{usersInFocusedViewHidden ? (
<ChevronUp className="size-6" />
) : (
<ChevronDown className="size-6" />
)}
</Button>
)}
/>
<TooltipContent portalProps={{ container: portalContainer }}>
Hide users
</TooltipContent>
</Tooltip>
)}
</div>
)}
<Card className="p-1.75">
<CardContent className="p-0! flex gap-1.75">
<MuteButton
@ -167,56 +178,58 @@ export default function Actions() {
)}
</CardContent>
</Card>
<div className="w-30 flex justify-end gap-1.5">
<Tooltip>
<TooltipTrigger
render={({ ref, onClick }) => (
<Button
ref={ref}
onClick={(event) => {
onClick?.(event);
setCallIsPopout(!callIsPopout);
}}
variant="ghost"
className="w-11 h-11! p-0! text-foreground border-0!"
>
{callIsPopout ? (
<SquareArrowOutDownLeft className="size-6" />
) : (
<SquareArrowOutUpRight className="size-6" />
)}
</Button>
)}
/>
<TooltipContent portalProps={{ container: portalContainer }}>
Popout
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={({ ref, onClick }) => (
<Button
ref={ref}
onClick={(event) => {
onClick?.(event);
void toggleFullscreen();
}}
variant="ghost"
className="w-11 h-11! p-0! mr-3 text-foreground border-0!"
>
{callIsFullscreen ? (
<Minimize className="size-6" />
) : (
<Maximize className="size-6" />
)}
</Button>
)}
/>
<TooltipContent portalProps={{ container: portalContainer }}>
Fullscreen
</TooltipContent>
</Tooltip>
</div>
{!isMobile && (
<div className="w-30 flex justify-end gap-1.5">
<Tooltip>
<TooltipTrigger
render={({ ref, onClick }) => (
<Button
ref={ref}
onClick={(event) => {
onClick?.(event);
setCallIsPopout(!callIsPopout);
}}
variant="ghost"
className="w-11 h-11! p-0! text-foreground border-0!"
>
{callIsPopout ? (
<SquareArrowOutDownLeft className="size-6" />
) : (
<SquareArrowOutUpRight className="size-6" />
)}
</Button>
)}
/>
<TooltipContent portalProps={{ container: portalContainer }}>
Popout
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={({ ref, onClick }) => (
<Button
ref={ref}
onClick={(event) => {
onClick?.(event);
void toggleFullscreen();
}}
variant="ghost"
className="w-11 h-11! p-0! mr-3 text-foreground border-0!"
>
{callIsFullscreen ? (
<Minimize className="size-6" />
) : (
<Maximize className="size-6" />
)}
</Button>
)}
/>
<TooltipContent portalProps={{ container: portalContainer }}>
Fullscreen
</TooltipContent>
</Tooltip>
</div>
)}
</div>
);
}

View file

@ -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<string | undefined>((resolve) => {
const image = new Image();
@ -264,15 +265,14 @@ export default function Base({
render={
<div
onClick={onClick}
className={`relative w-full ${fill ? "h-full" : "aspect-video"} ${
flush || fill ? "rounded-none" : "rounded-md"
} ${
type === "user" && isSpeaking
? "border-4 border-(--primary-foreground-alt)/75!"
: fill
? "border-0"
: "border"
} ${flush && !fill ? "border-x-0!" : ""}`}
className={cn(
"relative w-full",
fill ? "h-full" : "aspect-video",
flush && !fill && "border-x-0!",
type === "user" &&
isSpeaking &&
"border-4 border-(--primary-foreground-alt)/75!",
)}
>
<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
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 ${
flush ? "rounded-none" : "rounded-sm"
flush ? "rounded-none" : "rounded-lg"
}`}
style={{
backgroundColor: avatarBackgroundColor,

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;
}

View file

@ -2,11 +2,13 @@ import { create } from "zustand";
type SpeakingState = {
speakingParticipantIds: Set<number>;
lastSpeakingParticipantId: number | null;
micGated: boolean;
};
const useSpeakingState = create<SpeakingState>(() => ({
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<number, boolean>) {
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<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 {
return useSpeakingState((state) =>
state.speakingParticipantIds.has(participantId),

View file

@ -69,7 +69,8 @@ type IncomingCallInvite = {
senderId: number;
};
type CurrentCallData =
(z.infer<typeof mtp.CallData.response> & { exists: boolean }) | null;
| (z.infer<typeof mtp.CallData.response> & { 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;
}

View file

@ -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 (
<div
ref={containerRef}
@ -221,7 +224,7 @@ export default function View() {
height: "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">
{rows.map((row) => (

View file

@ -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<HTMLDivElement>(null);
@ -129,6 +130,8 @@ export default function Layout({ children }: { children: React.ReactNode }) {
setIsImmersiveChromeVisible(false);
};
const isMobile = useIsMobile();
return (
<div
ref={screenRef}
@ -139,23 +142,25 @@ export default function Layout({ children }: { children: React.ReactNode }) {
onMouseMove={showImmersiveChrome}
onMouseLeave={hideImmersiveChrome}
>
<div
ref={topBarRef}
className={`shrink-0 w-full z-40 transition-all duration-200 ${
isImmersiveFocusedView
? immersiveChromeVisible
? "opacity-100 translate-y-0 pointer-events-auto"
: "opacity-0 -translate-y-2 pointer-events-none"
: "opacity-100 translate-y-0"
}`}
style={{
position: usersInFocusedViewHidden ? "absolute" : "relative",
top: 0,
left: 0,
}}
>
<TopBar />
</div>
{!isMobile && (
<div
ref={topBarRef}
className={`shrink-0 w-full z-40 transition-all duration-200 ${
isImmersiveFocusedView
? immersiveChromeVisible
? "opacity-100 translate-y-0 pointer-events-auto"
: "opacity-0 -translate-y-2 pointer-events-none"
: "opacity-100 translate-y-0"
}`}
style={{
position: usersInFocusedViewHidden ? "absolute" : "relative",
top: 0,
left: 0,
}}
>
<TopBar />
</div>
)}
<div
className="min-h-0 flex-1 w-full flex justify-center items-center overflow-hidden"
style={

View file

@ -26,11 +26,7 @@ import {
import { log } from "@tensamin/shared/log";
import { useStorage } from "@tensamin/storage/context";
import {
RECONNECT_RESET,
RECONNECT_TRIES,
RETRY_INTERVAL,
} from "./values";
import { RECONNECT_RESET, RECONNECT_TRIES, RETRY_INTERVAL } from "./values";
function base64ToUint8Array(b64: string) {
const bin = atob(b64);
@ -57,9 +53,7 @@ export type BoundSendFn = <T extends keyof Schemas & string>(
options?: { id?: number },
) => Promise<ProtocolMessage<T>>;
export type PushHandler = (
message: ProtocolMessage,
) => void | Promise<void>;
export type PushHandler = (message: ProtocolMessage) => void | Promise<void>;
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,

View file

@ -52,9 +52,7 @@ export function SettingsSidebar({
return (
<div
className={cn(
mobile
? "w-full p-1"
: "p-3 rounded-tl-2xl border-r bg-input/15 w-50",
mobile ? "w-full p-1" : "p-3 rounded-tl-2xl border-r bg-input/15 w-50",
"flex flex-col gap-6",
className,
)}

View file

@ -29,18 +29,22 @@ async function prepImage(
const scale = Math.max(size / bitmap.width, size / bitmap.height);
const width = bitmap.width * scale;
const height = bitmap.height * scale;
context.drawImage(
bitmap,
(size - width) / 2,
(size - height) / 2,
width,
height,
);
try {
context.drawImage(
bitmap,
(size - width) / 2,
(size - height) / 2,
width,
height,
);
} finally {
bitmap.close();
}
return canvas.toDataURL("image/webp", quality);
}
export default function Page() {
const { get } = useUser();
const { get, update } = useUser();
const { load } = useStorage();
const { send } = useMTP();
const isMobile = useIsMobile();
@ -69,7 +73,7 @@ export default function Page() {
}, [currentUser]);
async function handleAvatarUpload(file: File) {
const avatar = await prepImage(file);
updateDraftUser((previous) => ({ ...previous, avatar }));
updateDraftUser((previous) => ({ ...previous, Avatar: avatar }));
if (avatarUploadRef.current) avatarUploadRef.current.value = "";
}
if (!currentUser) return <p>Loading...</p>;
@ -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) {

View file

@ -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;
};

View file

@ -21,6 +21,7 @@ const USER_CACHE_MAX_AGE = 5 * 60 * 1000;
interface contextValue {
get(userId: number): Promise<User>;
update(user: User): Promise<void>;
}
const UserContext = createContext<contextValue | undefined>(undefined);
@ -39,6 +40,7 @@ export default function UserProvider(props: { children: ReactNode }) {
const { load } = useStorage();
const { contacts } = useSession();
const [accountId, setAccountId] = useState<number | null>(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<User> => {
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 (
<UserContext.Provider value={{ get }}>
<UserContext.Provider value={{ get, update }}>
{props.children}
</UserContext.Provider>
);