(feat): add profile page
(feat): show user online-status (feat): make markdown input more modular
This commit is contained in:
parent
168694ae86
commit
55da8026a7
12 changed files with 609 additions and 63 deletions
|
|
@ -26,6 +26,7 @@
|
||||||
"@tensamin/tauri": "workspace:*",
|
"@tensamin/tauri": "workspace:*",
|
||||||
"@tensamin/ttp": "workspace:*",
|
"@tensamin/ttp": "workspace:*",
|
||||||
"@tensamin/tauth": "workspace:*",
|
"@tensamin/tauth": "workspace:*",
|
||||||
|
"@tensamin/markdown": "workspace:*",
|
||||||
"@tensamin/ui": "*",
|
"@tensamin/ui": "*",
|
||||||
"@tensamin/user": "workspace:*",
|
"@tensamin/user": "workspace:*",
|
||||||
"@tensamin/notifications": "workspace:*",
|
"@tensamin/notifications": "workspace:*",
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,58 @@
|
||||||
import type { User } from "@tensamin/user/context";
|
import type { User } from "@tensamin/user/context";
|
||||||
import { Avatar, AvatarImage, AvatarFallback } from "@tensamin/ui";
|
import {
|
||||||
import { reduceDisplay } from "./utils";
|
Avatar,
|
||||||
|
AvatarImage,
|
||||||
|
AvatarFallback,
|
||||||
|
Tooltip,
|
||||||
|
TooltipTrigger,
|
||||||
|
TooltipContent,
|
||||||
|
} from "@tensamin/ui";
|
||||||
import { Card, CardHeader } from "@tensamin/ui";
|
import { Card, CardHeader } from "@tensamin/ui";
|
||||||
import { Skeleton } from "@tensamin/ui";
|
import { Skeleton } from "@tensamin/ui";
|
||||||
|
import { getStatusColor } from "@tensamin/shared/data";
|
||||||
|
|
||||||
export function Basic(props: { user: User }) {
|
export function Basic({
|
||||||
|
user,
|
||||||
|
extra,
|
||||||
|
}: {
|
||||||
|
user: User;
|
||||||
|
extra?: React.ReactNode;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card className="animate-in fade-in duration-300 rounded-2xl py-0 m-px">
|
<Card className="animate-in fade-in duration-300 rounded-2xl py-0 m-px">
|
||||||
<CardHeader className="flex flex-row gap-2.5 items-center justify-start p-2">
|
<CardHeader className="flex flex-row gap-2.5 items-center justify-start p-2">
|
||||||
<Avatar>
|
<div className="relative shrink-0 overflow-visible">
|
||||||
<AvatarImage src={props.user.avatar} />
|
<Avatar>
|
||||||
<AvatarFallback>{reduceDisplay(props.user.display)}</AvatarFallback>
|
<AvatarImage src={user.avatar} />
|
||||||
</Avatar>
|
<AvatarFallback>
|
||||||
<div className="flex flex-col gap-1 w-full items-start justify-center text-[15px]">
|
{user.display.slice(0, 2).toUpperCase()}
|
||||||
<p>{props.user.display}</p>
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<div className="absolute -bottom-0.75 -right-0.75 z-10 flex h-4 w-4 items-center justify-center rounded-full bg-card">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: getStatusColor(user.online_status),
|
||||||
|
}}
|
||||||
|
className="h-2.5 w-2.5 rounded-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TooltipContent>
|
||||||
|
{user.online_status
|
||||||
|
.split("_")
|
||||||
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(" ")}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 w-full items-start justify-center text-[15px]">
|
||||||
|
<p>{user.display}</p>
|
||||||
|
</div>
|
||||||
|
<div className="pr-2">{extra}</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
/**
|
|
||||||
* Executes reduceDisplay.
|
|
||||||
* @param display Parameter display.
|
|
||||||
* @returns unknown.
|
|
||||||
*/
|
|
||||||
export function reduceDisplay(display: string) {
|
|
||||||
const words = display.split(" ");
|
|
||||||
if (words.length === 1) {
|
|
||||||
return display.slice(0, 2).toUpperCase();
|
|
||||||
} else {
|
|
||||||
return words[0].charAt(0).toUpperCase() + words[1].charAt(0).toUpperCase();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -7,6 +7,26 @@ import {
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
SidebarFooter,
|
SidebarFooter,
|
||||||
useSidebar,
|
useSidebar,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
Input,
|
||||||
|
DialogClose,
|
||||||
|
Button,
|
||||||
|
Label,
|
||||||
|
Select,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
} from "@tensamin/ui";
|
} from "@tensamin/ui";
|
||||||
import { isTauri } from "@tauri-apps/api/core";
|
import { isTauri } from "@tauri-apps/api/core";
|
||||||
import { useIsMobile } from "@tensamin/ui";
|
import { useIsMobile } from "@tensamin/ui";
|
||||||
|
|
@ -14,18 +34,168 @@ import { MobileNavbar } from "./navbar";
|
||||||
|
|
||||||
import SidebarBox from "@tensamin/call/sidebarBox";
|
import SidebarBox from "@tensamin/call/sidebarBox";
|
||||||
import { useShowMobileNavbar } from "@/routes/app/layout";
|
import { useShowMobileNavbar } from "@/routes/app/layout";
|
||||||
/**
|
import { Ellipsis, Check } from "lucide-react";
|
||||||
* Renders the conversation sidebar with account summary and conversation list.
|
import { useState } from "react";
|
||||||
* On mobile the sidebar is always kept in the DOM and hidden via CSS
|
import type { User } from "@tensamin/user/context";
|
||||||
* (opacity + translateX) instead of being unmounted. This keeps the DOM and
|
import type z from "zod";
|
||||||
* React state alive while the drawer is closed, allowing the sidebar to open
|
import { ttp } from "@tensamin/shared/data";
|
||||||
* instantly on subsequent toggles.
|
import { useTTP } from "@tensamin/ttp";
|
||||||
* @returns Sidebar JSX.
|
|
||||||
*/
|
type OnlineStatus = z.infer<
|
||||||
|
typeof ttp.get_user_data.response.shape.online_status
|
||||||
|
>;
|
||||||
|
|
||||||
|
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 useTTP>["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.online_status);
|
||||||
|
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 }),
|
||||||
|
online_status: draftOnlineStatus,
|
||||||
|
};
|
||||||
|
|
||||||
|
const validation =
|
||||||
|
ttp.change_user_data.request.safeParse(payload);
|
||||||
|
|
||||||
|
if (!validation.success) {
|
||||||
|
setSaveSucceeded(false);
|
||||||
|
setErrorMessage(
|
||||||
|
validation.error.issues[0]?.message ?? "Invalid status data",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await send("change_user_data", 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() {
|
export default function Sidebar() {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const showMobileNavbar = useShowMobileNavbar();
|
const showMobileNavbar = useShowMobileNavbar();
|
||||||
const { openMobile, setOpenMobile } = useSidebar();
|
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 } = useTTP();
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
|
|
@ -39,7 +209,67 @@ export default function Sidebar() {
|
||||||
<Wrapper
|
<Wrapper
|
||||||
loading={<Loading />}
|
loading={<Loading />}
|
||||||
userId={"own"}
|
userId={"own"}
|
||||||
component={(user) => <Basic user={user} />}
|
component={(user) => (
|
||||||
|
<>
|
||||||
|
<Basic
|
||||||
|
user={user}
|
||||||
|
extra={
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Open profile menu"
|
||||||
|
className="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>
|
||||||
|
<DropdownMenuLabel>Profile</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setDraftStatus(user.status ?? "");
|
||||||
|
setDraftOnlineStatus(user.online_status);
|
||||||
|
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.online_status);
|
||||||
|
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>
|
||||||
<div className="h-full">
|
<div className="h-full">
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,211 @@
|
||||||
|
import { useStorage } from "@tensamin/storage/context";
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarImage,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
} from "@tensamin/ui";
|
||||||
|
import { useUser, type User } from "@tensamin/user/context";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import MDInput from "@tensamin/markdown/input";
|
||||||
|
import { ttp } from "@tensamin/shared/data";
|
||||||
|
import { useTTP } from "@tensamin/ttp";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
|
||||||
|
async function prepImage(
|
||||||
|
file: File,
|
||||||
|
size = 300,
|
||||||
|
quality = 0.8,
|
||||||
|
): Promise<string> {
|
||||||
|
const bitmap = await createImageBitmap(file);
|
||||||
|
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = size;
|
||||||
|
canvas.height = size;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) throw new Error("Could not get canvas context");
|
||||||
|
|
||||||
|
const scale = Math.max(size / bitmap.width, size / bitmap.height);
|
||||||
|
const width = bitmap.width * scale;
|
||||||
|
const height = bitmap.height * scale;
|
||||||
|
const x = (size - width) / 2;
|
||||||
|
const y = (size - height) / 2;
|
||||||
|
|
||||||
|
ctx.drawImage(bitmap, x, y, width, height);
|
||||||
|
|
||||||
|
return canvas.toDataURL("image/webp", quality);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return <div></div>;
|
const { get } = useUser();
|
||||||
|
const { load } = useStorage();
|
||||||
|
const { send } = useTTP();
|
||||||
|
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||||
|
const [draftUser, setDraftUser] = useState<Partial<User>>({});
|
||||||
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
const [saveSucceeded, setSaveSucceeded] = useState(false);
|
||||||
|
const avatarUploadRef = useRef<HTMLInputElement>(null);
|
||||||
|
const draftInitializedRef = useRef(false);
|
||||||
|
const effectiveAvatar =
|
||||||
|
draftUser.avatar === "none" ? undefined : draftUser.avatar;
|
||||||
|
|
||||||
|
const updateDraftUser = (
|
||||||
|
updater: (previous: Partial<User>) => Partial<User>,
|
||||||
|
) => {
|
||||||
|
setSaveSucceeded(false);
|
||||||
|
setErrorMessage("");
|
||||||
|
setDraftUser(updater);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchUser = async () => {
|
||||||
|
const user = await get(await load("user_id"));
|
||||||
|
setCurrentUser(user);
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchUser();
|
||||||
|
}, [load, get]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentUser || draftInitializedRef.current) return;
|
||||||
|
|
||||||
|
setDraftUser(currentUser);
|
||||||
|
draftInitializedRef.current = true;
|
||||||
|
}, [currentUser]);
|
||||||
|
|
||||||
|
const handleAvatarUpload = async (file: File) => {
|
||||||
|
const final = await prepImage(file);
|
||||||
|
updateDraftUser((prev) => ({ ...prev, avatar: final }));
|
||||||
|
if (avatarUploadRef.current) {
|
||||||
|
avatarUploadRef.current.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return currentUser ? (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
ref={avatarUploadRef}
|
||||||
|
hidden
|
||||||
|
onChange={(e) =>
|
||||||
|
e.target.files?.[0] && handleAvatarUpload(e.target.files[0])
|
||||||
|
}
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-5 w-80">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar className="size-14">
|
||||||
|
<AvatarImage src={effectiveAvatar} />
|
||||||
|
<AvatarFallback className="text-2xl">
|
||||||
|
{draftUser.display?.slice(0, 2).toUpperCase() ||
|
||||||
|
currentUser.display.slice(0, 2).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p>Avatar</p>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button onClick={() => avatarUploadRef.current?.click()}>
|
||||||
|
Upload avatar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
updateDraftUser((prev) => {
|
||||||
|
return { ...prev, avatar: "none" };
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
variant="destructive"
|
||||||
|
disabled={effectiveAvatar === undefined}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
GIFs are supported in decentralised mode or with Tensamin Premium
|
||||||
|
<br />
|
||||||
|
Maximum file size is 16mb.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
onChange={(event) =>
|
||||||
|
updateDraftUser((prev) => ({
|
||||||
|
...prev,
|
||||||
|
display: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="Display Name"
|
||||||
|
value={draftUser.display || ""}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
onChange={(event) =>
|
||||||
|
updateDraftUser((prev) => ({
|
||||||
|
...prev,
|
||||||
|
username: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="Username"
|
||||||
|
value={draftUser.username || ""}
|
||||||
|
/>
|
||||||
|
<MDInput
|
||||||
|
styled
|
||||||
|
paddingY="4px"
|
||||||
|
paddingX="10px"
|
||||||
|
fontSize=".875rem"
|
||||||
|
placeholder="About Me"
|
||||||
|
setValue={(value) =>
|
||||||
|
updateDraftUser((prev) => ({ ...prev, about: value }))
|
||||||
|
}
|
||||||
|
value={draftUser.about || ""}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
const payload = {
|
||||||
|
...draftUser,
|
||||||
|
avatar:
|
||||||
|
typeof draftUser.avatar === "string" &&
|
||||||
|
draftUser.avatar.startsWith("data:")
|
||||||
|
? (draftUser.avatar.split(",", 2)[1] ?? "")
|
||||||
|
: draftUser.avatar,
|
||||||
|
};
|
||||||
|
|
||||||
|
const validation = ttp.change_user_data.request.safeParse(payload);
|
||||||
|
|
||||||
|
if (!validation.success) {
|
||||||
|
setSaveSucceeded(false);
|
||||||
|
setErrorMessage(
|
||||||
|
validation.error.issues[0]?.message ?? "Invalid profile data",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await send("change_user_data", validation.data);
|
||||||
|
setSaveSucceeded(true);
|
||||||
|
setErrorMessage("");
|
||||||
|
} catch (err) {
|
||||||
|
setSaveSucceeded(false);
|
||||||
|
setErrorMessage("Failed to update profile: " + err);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saveSucceeded ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<Check className="size-4" />
|
||||||
|
Saved
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"Save"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{errorMessage && (
|
||||||
|
<p className="text-sm text-destructive">{errorMessage}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p>Loading...</p>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
bun.lock
1
bun.lock
|
|
@ -55,6 +55,7 @@
|
||||||
"@tensamin/call": "workspace:*",
|
"@tensamin/call": "workspace:*",
|
||||||
"@tensamin/chat": "workspace:*",
|
"@tensamin/chat": "workspace:*",
|
||||||
"@tensamin/crypto": "workspace:*",
|
"@tensamin/crypto": "workspace:*",
|
||||||
|
"@tensamin/markdown": "workspace:*",
|
||||||
"@tensamin/notifications": "workspace:*",
|
"@tensamin/notifications": "workspace:*",
|
||||||
"@tensamin/shared": "workspace:*",
|
"@tensamin/shared": "workspace:*",
|
||||||
"@tensamin/storage": "workspace:*",
|
"@tensamin/storage": "workspace:*",
|
||||||
|
|
|
||||||
|
|
@ -161,7 +161,8 @@ export default function View() {
|
||||||
const focusedParticipantHasActiveScreenShare =
|
const focusedParticipantHasActiveScreenShare =
|
||||||
activeScreenShareParticipantIdSet.has(focusedParticipantId);
|
activeScreenShareParticipantIdSet.has(focusedParticipantId);
|
||||||
const focusedTileType: "user" | "stream" =
|
const focusedTileType: "user" | "stream" =
|
||||||
focusedParticipantType === "stream" && focusedParticipantHasActiveScreenShare
|
focusedParticipantType === "stream" &&
|
||||||
|
focusedParticipantHasActiveScreenShare
|
||||||
? "stream"
|
? "stream"
|
||||||
: "user";
|
: "user";
|
||||||
const isImmersiveFocusedView = callIsFullscreen && usersInFocusedViewHidden;
|
const isImmersiveFocusedView = callIsFullscreen && usersInFocusedViewHidden;
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,8 @@ export default function InputComponent({
|
||||||
>
|
>
|
||||||
<CardHeader className="relative p-0 flex flex-col">
|
<CardHeader className="relative p-0 flex flex-col">
|
||||||
<Input
|
<Input
|
||||||
|
paddingY="13px"
|
||||||
|
paddingX="13px"
|
||||||
placeholder="Send a message..."
|
placeholder="Send a message..."
|
||||||
value={value}
|
value={value}
|
||||||
setValue={setValue}
|
setValue={setValue}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import {
|
||||||
indentWithTab,
|
indentWithTab,
|
||||||
} from "@codemirror/commands";
|
} from "@codemirror/commands";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
import { collectInlineRanges, ensureMarkdownStyles } from "./markdown";
|
import { collectInlineRanges, ensureMarkdownStyles } from "./markdown";
|
||||||
|
|
||||||
|
|
@ -33,8 +34,36 @@ export type InputProps = {
|
||||||
setValue: (value: string) => void;
|
setValue: (value: string) => void;
|
||||||
onSubmit?: () => void;
|
onSubmit?: () => void;
|
||||||
invertEnterBehavior?: boolean;
|
invertEnterBehavior?: boolean;
|
||||||
|
styled?: boolean;
|
||||||
|
fontSize?: CSSProperties["fontSize"];
|
||||||
|
paddingX?: CSSProperties["padding"];
|
||||||
|
paddingY?: CSSProperties["padding"];
|
||||||
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type InputStyle = CSSProperties & {
|
||||||
|
"--tm-md-content-padding"?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toCssLength(value: CSSProperties["padding"]): string | undefined {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof value === "number" ? `${value}px` : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCssPadding(
|
||||||
|
vertical: CSSProperties["padding"],
|
||||||
|
horizontal: CSSProperties["padding"],
|
||||||
|
styled: boolean,
|
||||||
|
): string {
|
||||||
|
const defaultVertical = styled ? "0.25rem" : "0";
|
||||||
|
const defaultHorizontal = styled ? "0.625rem" : "0";
|
||||||
|
|
||||||
|
return `${toCssLength(vertical) ?? defaultVertical} ${toCssLength(horizontal) ?? defaultHorizontal}`;
|
||||||
|
}
|
||||||
|
|
||||||
type TokenRange = {
|
type TokenRange = {
|
||||||
from: number;
|
from: number;
|
||||||
to: number;
|
to: number;
|
||||||
|
|
@ -80,6 +109,10 @@ const markdownDecorations = ViewPlugin.fromClass(
|
||||||
export default function Input(props: InputProps) {
|
export default function Input(props: InputProps) {
|
||||||
ensureMarkdownStyles();
|
ensureMarkdownStyles();
|
||||||
|
|
||||||
|
const shellClassName = props.styled
|
||||||
|
? "min-h-8 w-full min-w-0 rounded-lg border border-input bg-transparent text-base transition-colors outline-none placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40"
|
||||||
|
: "";
|
||||||
|
|
||||||
const elementRef = useRef<HTMLDivElement | null>(null);
|
const elementRef = useRef<HTMLDivElement | null>(null);
|
||||||
const viewRef = useRef<EditorView | undefined>(undefined);
|
const viewRef = useRef<EditorView | undefined>(undefined);
|
||||||
const ignoreSyncRef = useRef(false);
|
const ignoreSyncRef = useRef(false);
|
||||||
|
|
@ -143,7 +176,22 @@ export default function Input(props: InputProps) {
|
||||||
});
|
});
|
||||||
}, [props.value]);
|
}, [props.value]);
|
||||||
|
|
||||||
return <div ref={elementRef} className="tm-md-root w-full" />;
|
return (
|
||||||
|
<div
|
||||||
|
ref={elementRef}
|
||||||
|
className={`tm-md-root ${shellClassName} ${props.className ?? ""}`}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
fontSize: props.fontSize ?? "1rem",
|
||||||
|
"--tm-md-content-padding": toCssPadding(
|
||||||
|
props.paddingY,
|
||||||
|
props.paddingX,
|
||||||
|
Boolean(props.styled),
|
||||||
|
),
|
||||||
|
} as InputStyle
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -204,7 +252,7 @@ function createEditorExtensions(
|
||||||
}),
|
}),
|
||||||
EditorView.theme({
|
EditorView.theme({
|
||||||
"&": {
|
"&": {
|
||||||
fontSize: "1rem",
|
fontSize: "inherit",
|
||||||
},
|
},
|
||||||
"&.cm-editor": {
|
"&.cm-editor": {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
|
|
|
||||||
|
|
@ -665,12 +665,12 @@ export const markdownStyles = `
|
||||||
.tm-md-table th { background: hsl(var(--muted)); font-weight: 600; }
|
.tm-md-table th { background: hsl(var(--muted)); font-weight: 600; }
|
||||||
.tm-md-hr { margin: 0.55rem 0; }
|
.tm-md-hr { margin: 0.55rem 0; }
|
||||||
|
|
||||||
.cm-editor.tm-md-editor { border-radius: 0.65rem; background: hsl(var(--card)); caret-color: var(--foreground); }
|
.cm-editor.tm-md-editor { border-radius: inherit; background: transparent; caret-color: var(--foreground); }
|
||||||
.cm-editor.tm-md-editor.cm-focused { outline: none; box-shadow: none; }
|
.cm-editor.tm-md-editor.cm-focused { outline: none; box-shadow: none; }
|
||||||
.cm-editor.tm-md-editor .cm-scroller { font-family: inherit; line-height: 1.55; max-height: 30vh; overflow-y: auto; overflow-x: hidden; }
|
.cm-editor.tm-md-editor .cm-scroller { font-family: inherit; line-height: 1.55; max-height: 30vh; overflow-y: auto; overflow-x: hidden; }
|
||||||
.cm-editor.tm-md-editor .cm-content { caret-color: var(--foreground); }
|
.cm-editor.tm-md-editor .cm-content { caret-color: var(--foreground); }
|
||||||
.cm-editor.tm-md-editor .cm-content { padding: 0.7rem 0.85rem; min-height: 2.75rem; }
|
.cm-editor.tm-md-editor .cm-content { padding: var(--tm-md-content-padding, 0.25rem 0.625rem); min-height: 2rem; }
|
||||||
.cm-editor.tm-md-editor .cm-line { padding: 0 1px; color: hsl(var(--foreground)); }
|
.cm-editor.tm-md-editor .cm-line { padding: 0; color: hsl(var(--foreground)); }
|
||||||
.cm-editor.tm-md-editor .tm-md-hidden-token { color: transparent; opacity: 0; font-size: inherit; }
|
.cm-editor.tm-md-editor .tm-md-hidden-token { color: transparent; opacity: 0; font-size: inherit; }
|
||||||
.cm-editor.tm-md-editor .tm-md-code-line { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; background: hsl(var(--muted)); border-radius: 0.3rem; }
|
.cm-editor.tm-md-editor .tm-md-code-line { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; background: hsl(var(--muted)); border-radius: 0.3rem; }
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,31 @@ export type Communities = z.infer<
|
||||||
export type Calls = z.infer<typeof ttp.challenge_response.response.shape.calls>;
|
export type Calls = z.infer<typeof ttp.challenge_response.response.shape.calls>;
|
||||||
|
|
||||||
// TTP
|
// TTP
|
||||||
|
const user = z.object({
|
||||||
|
about: z.string().max(255).optional(),
|
||||||
|
avatar: z.string().optional(),
|
||||||
|
display: z.string().min(1).max(15),
|
||||||
|
iota_id: z.number(),
|
||||||
|
omikron_connections: z.array(z.number()),
|
||||||
|
omikron_id: z.number().optional(),
|
||||||
|
online_status: z.enum([
|
||||||
|
"user_offline",
|
||||||
|
"user_online",
|
||||||
|
"user_dnd",
|
||||||
|
"user_idle",
|
||||||
|
"user_wc",
|
||||||
|
"user_borked",
|
||||||
|
"iota_offline",
|
||||||
|
"iota_online",
|
||||||
|
"iota_borked",
|
||||||
|
]),
|
||||||
|
public_key: z.base64(),
|
||||||
|
status: z.string().max(15).optional(),
|
||||||
|
sub_end: z.number(),
|
||||||
|
sub_level: z.number(),
|
||||||
|
user_id: z.number(),
|
||||||
|
username: z.string().min(1).max(15),
|
||||||
|
});
|
||||||
export const ttp = {
|
export const ttp = {
|
||||||
identification: {
|
identification: {
|
||||||
request: z.object({
|
request: z.object({
|
||||||
|
|
@ -92,31 +117,11 @@ export const ttp = {
|
||||||
request: z.object({
|
request: z.object({
|
||||||
user_id: z.number(),
|
user_id: z.number(),
|
||||||
}),
|
}),
|
||||||
response: z.object({
|
response: user,
|
||||||
about: z.string().max(255).optional(),
|
},
|
||||||
avatar: z.string().optional(),
|
change_user_data: {
|
||||||
display: z.string().max(15),
|
request: user.partial(),
|
||||||
iota_id: z.number(),
|
response: z.object({}),
|
||||||
omikron_connections: z.array(z.number()),
|
|
||||||
omikron_id: z.number().optional(),
|
|
||||||
online_status: z.enum([
|
|
||||||
"user_offline",
|
|
||||||
"user_online",
|
|
||||||
"user_dnd",
|
|
||||||
"user_idle",
|
|
||||||
"user_wc",
|
|
||||||
"user_borked",
|
|
||||||
"iota_offline",
|
|
||||||
"iota_online",
|
|
||||||
"iota_borked",
|
|
||||||
]),
|
|
||||||
public_key: z.base64(),
|
|
||||||
status: z.string().max(15).optional(),
|
|
||||||
sub_end: z.number(),
|
|
||||||
sub_level: z.number(),
|
|
||||||
user_id: z.number(),
|
|
||||||
username: z.string().max(15),
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
ping: {
|
ping: {
|
||||||
request: z.object({
|
request: z.object({
|
||||||
|
|
@ -283,3 +288,28 @@ export const storageDefaults: Storage = {
|
||||||
call_mute_range_start: -55,
|
call_mute_range_start: -55,
|
||||||
call_mute_range_end: -45,
|
call_mute_range_end: -45,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// User Status
|
||||||
|
export function getStatusColor(
|
||||||
|
status: z.infer<typeof ttp.get_user_data.response.shape.online_status>,
|
||||||
|
) {
|
||||||
|
switch (status) {
|
||||||
|
case "user_online":
|
||||||
|
return "#22c55e";
|
||||||
|
case "iota_online":
|
||||||
|
return "#22c55e";
|
||||||
|
case "user_dnd":
|
||||||
|
return "#ef4444";
|
||||||
|
case "user_idle":
|
||||||
|
return "#f59e0b";
|
||||||
|
case "user_wc":
|
||||||
|
return "#3b82f6";
|
||||||
|
case "user_borked":
|
||||||
|
case "iota_borked":
|
||||||
|
return "#6b7280";
|
||||||
|
case "user_offline":
|
||||||
|
case "iota_offline":
|
||||||
|
default:
|
||||||
|
return "#9ca3af";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ export default function UserProvider(props: { children: React.ReactNode }) {
|
||||||
const user = {
|
const user = {
|
||||||
...userData.data,
|
...userData.data,
|
||||||
avatar: userData.data.avatar
|
avatar: userData.data.avatar
|
||||||
? `data:image/png;base64,${userData.data.avatar}`
|
? `data:image/webp;base64,${atob(userData.data.avatar)}`
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue