client/apps/web/src/components/sidebar.tsx
Alois 6a5236bafe
All checks were successful
/ build-web (push) Successful in 7m40s
/ build-desktop (linux) (push) Successful in 12m6s
/ build-mobile (push) Successful in 19m4s
/ release (push) Successful in 3m25s
(feat): add reply box to messages
(feat): update methanium ui
(fix): user data caching
(fix): other random stuff
(qol): update todo
2026-07-30 23:01:42 +02:00

315 lines
9.8 KiB
TypeScript

import Wrapper from "@tensamin/user/wrapper";
import { Basic, Loading } from "./modals/basic";
import List from "@/features/conversation/list/body";
import {
Sidebar as SidebarRoot,
SidebarContent,
SidebarFooter,
useSidebar,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuGroup,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
DialogClose,
Button,
Label,
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@methanium/ui";
import { isTauri } from "@tauri-apps/api/core";
import { useIsMobile } from "@methanium/ui";
import { MobileNavbar } from "./navbar";
import SidebarBox from "@tensamin/call/sidebarBox";
import { useShowMobileNavbar } from "@/routes/app/useShowMobileNavbar";
import { Ellipsis, Check } from "lucide-react";
import { useState } from "react";
import type { User } from "@tensamin/user/context";
import type z from "zod";
import { mtp } from "@tensamin/shared/data";
import { useMTP } from "@tensamin/mtp";
type OnlineStatus = z.infer<typeof mtp.GetUserData.response.shape.OnlineStatus>;
const onlineStatusLabels: Record<OnlineStatus, string> = {
user_online: "Online",
user_offline: "Offline",
user_dnd: "Do not disturb",
user_idle: "Idle",
user_wc: "Away",
user_borked: "Borked",
iota_offline: "Iota offline",
iota_online: "Iota online",
iota_borked: "Iota borked",
};
function StatusDialog({
user,
open,
onOpenChange,
send,
draftStatus,
setDraftStatus,
draftOnlineStatus,
setDraftOnlineStatus,
errorMessage,
setErrorMessage,
saveSucceeded,
setSaveSucceeded,
}: {
user: User;
open: boolean;
onOpenChange: (open: boolean) => void;
send: ReturnType<typeof useMTP>["send"];
draftStatus: string;
setDraftStatus: (value: string) => void;
draftOnlineStatus: OnlineStatus;
setDraftOnlineStatus: (value: OnlineStatus) => void;
errorMessage: string;
setErrorMessage: (value: string) => void;
saveSucceeded: boolean;
setSaveSucceeded: (value: boolean) => void;
}) {
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
setDraftStatus(user.Status ?? "");
setDraftOnlineStatus(user.OnlineStatus);
setErrorMessage("");
setSaveSucceeded(false);
}
onOpenChange(nextOpen);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Update Status</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-2">
<Label htmlFor="user-status">Status message</Label>
<Input
id="user-status"
placeholder="Getting snacks..."
value={draftStatus}
onChange={(e) => {
setSaveSucceeded(false);
setErrorMessage("");
setDraftStatus(e.target.value);
}}
/>
<Label htmlFor="user-online-status">Online status</Label>
<Select
value={draftOnlineStatus}
onValueChange={(value) => {
setSaveSucceeded(false);
setErrorMessage("");
setDraftOnlineStatus(value ?? "user_online");
}}
>
<SelectTrigger className="w-full" id="user-online-status">
<SelectValue placeholder="Online">
{onlineStatusLabels[draftOnlineStatus]}
</SelectValue>
</SelectTrigger>
<SelectContent className="p-1">
<SelectItem value="user_online">Online</SelectItem>
<SelectItem value="user_offline">Offline</SelectItem>
<SelectItem value="user_idle">Idle</SelectItem>
<SelectItem value="user_dnd">Do not disturb</SelectItem>
</SelectContent>
</Select>
</div>
{errorMessage && (
<p className="text-sm text-destructive">{errorMessage}</p>
)}
<DialogFooter>
<DialogClose render={<Button variant="destructive">Cancel</Button>} />
<Button
onClick={async () => {
const payload = {
...(draftStatus && { status: draftStatus }),
OnlineStatus: draftOnlineStatus,
};
const validation = mtp.ChangeUserData.request.safeParse(payload);
if (!validation.success) {
setSaveSucceeded(false);
setErrorMessage(
validation.error.issues[0]?.message ?? "Invalid status data",
);
return;
}
try {
await send("ChangeUserData", validation.data);
setSaveSucceeded(true);
setErrorMessage("");
} catch (err) {
setSaveSucceeded(false);
setErrorMessage("Failed to update status: " + err);
}
}}
>
{saveSucceeded ? (
<span className="inline-flex items-center gap-1.5">
<Check className="size-4" />
Saved
</span>
) : (
"Save"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default function Sidebar() {
const isMobile = useIsMobile();
const showMobileNavbar = useShowMobileNavbar();
const { openMobile, setOpenMobile } = useSidebar();
const [draftStatus, setDraftStatus] = useState("");
const [draftOnlineStatus, setDraftOnlineStatus] =
useState<OnlineStatus>("user_online");
const [dialogOpen, setDialogOpen] = useState(false);
const [statusErrorMessage, setStatusErrorMessage] = useState("");
const [statusSaveSucceeded, setStatusSaveSucceeded] = useState(false);
const { send } = useMTP();
const content = (
<>
<SidebarContent
className={
isTauri() && isMobile ? "pt-[env(safe-area-inset-top)]" : "pt-2"
}
>
<div className="h-full w-full flex flex-col gap-3 p-2 pt-0!">
<div>
<Wrapper
loading={<Loading />}
userId={"own"}
component={(user) => (
<>
<Basic
user={user}
extra={
<DropdownMenu>
<DropdownMenuTrigger
render={
<button
type="button"
aria-label="Open profile menu"
className="cursor-pointer inline-flex h-8 w-8 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<Ellipsis />
</button>
}
/>
<DropdownMenuContent>
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() => {
setDraftStatus(user.Status ?? "");
setDraftOnlineStatus(user.OnlineStatus);
setStatusErrorMessage("");
setStatusSaveSucceeded(false);
setDialogOpen(true);
}}
>
Set Status
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
}
/>
<StatusDialog
user={user}
open={dialogOpen}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
setDraftStatus(user.Status ?? "");
setDraftOnlineStatus(user.OnlineStatus);
setStatusErrorMessage("");
setStatusSaveSucceeded(false);
}
setDialogOpen(nextOpen);
}}
send={send}
draftStatus={draftStatus}
setDraftStatus={setDraftStatus}
draftOnlineStatus={draftOnlineStatus}
setDraftOnlineStatus={setDraftOnlineStatus}
errorMessage={statusErrorMessage}
setErrorMessage={setStatusErrorMessage}
saveSucceeded={statusSaveSucceeded}
setSaveSucceeded={setStatusSaveSucceeded}
/>
</>
)}
/>
</div>
<div className="h-full">
<List />
</div>
</div>
</SidebarContent>
{isMobile && !showMobileNavbar && (
<SidebarFooter className="p-0!">
<MobileNavbar />
</SidebarFooter>
)}
{!isMobile && (
<SidebarFooter>
<SidebarBox />
</SidebarFooter>
)}
</>
);
if (isMobile) {
return (
<>
{openMobile && (
<div
className="fixed inset-0 z-40 bg-black/10"
onClick={() => setOpenMobile(false)}
/>
)}
<div
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="fixed inset-y-0 left-0 z-50 w-screen bg-sidebar p-0 text-sidebar-foreground"
style={{
transform: openMobile ? "translateX(0)" : "translateX(-100%)",
opacity: openMobile ? 1 : 0,
pointerEvents: openMobile ? "auto" : "none",
}}
>
<div className="flex h-full w-full flex-col">{content}</div>
</div>
</>
);
}
return <SidebarRoot className="border-0!">{content}</SidebarRoot>;
}