(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 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>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import {
|
|||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
useIsMobile,
|
||||
cn,
|
||||
} from "@methanium/ui";
|
||||
import { useEffect, useState } from "react";
|
||||
import MuteButton from "./buttons/mute";
|
||||
|
|
@ -77,8 +79,16 @@ export default function Actions() {
|
|||
(state) => state.usersInFocusedViewHidden,
|
||||
);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<div className="w-full flex justify-between items-center">
|
||||
<div
|
||||
className={cn(
|
||||
"w-full flex items-center",
|
||||
isMobile ? "justify-center" : "justify-between",
|
||||
)}
|
||||
>
|
||||
{!isMobile && (
|
||||
<div className="w-30 flex justify-start">
|
||||
{view === "focused" && (
|
||||
<Tooltip>
|
||||
|
|
@ -107,6 +117,7 @@ export default function Actions() {
|
|||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Card className="p-1.75">
|
||||
<CardContent className="p-0! flex gap-1.75">
|
||||
<MuteButton
|
||||
|
|
@ -167,6 +178,7 @@ export default function Actions() {
|
|||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{!isMobile && (
|
||||
<div className="w-30 flex justify-end gap-1.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
|
|
@ -217,6 +229,7 @@ export default function Actions() {
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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,6 +142,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|||
onMouseMove={showImmersiveChrome}
|
||||
onMouseLeave={hideImmersiveChrome}
|
||||
>
|
||||
{!isMobile && (
|
||||
<div
|
||||
ref={topBarRef}
|
||||
className={`shrink-0 w-full z-40 transition-all duration-200 ${
|
||||
|
|
@ -156,6 +160,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|||
>
|
||||
<TopBar />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="min-h-0 flex-1 w-full flex justify-center items-center overflow-hidden"
|
||||
style={
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ async function prepImage(
|
|||
const scale = Math.max(size / bitmap.width, size / bitmap.height);
|
||||
const width = bitmap.width * scale;
|
||||
const height = bitmap.height * scale;
|
||||
try {
|
||||
context.drawImage(
|
||||
bitmap,
|
||||
(size - width) / 2,
|
||||
|
|
@ -36,11 +37,14 @@ async function prepImage(
|
|||
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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue