(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 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>

View file

@ -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(

View file

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

View file

@ -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,

View file

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

View file

@ -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),

View file

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

View file

@ -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) => (

View file

@ -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={

View file

@ -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,

View file

@ -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,
)} )}

View file

@ -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) {

View file

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

View file

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