From 55da8026a7e13c5e16e749bcf734f017bc798d1a Mon Sep 17 00:00:00 2001 From: Alois Date: Sat, 16 May 2026 14:41:43 +0200 Subject: [PATCH] (feat): add profile page (feat): show user online-status (feat): make markdown input more modular --- apps/web/package.json | 1 + apps/web/src/components/modals/basic.tsx | 56 ++++- apps/web/src/components/modals/utils.ts | 13 -- apps/web/src/components/sidebar.tsx | 248 ++++++++++++++++++++++- apps/web/src/routes/settings/profile.tsx | 210 ++++++++++++++++++- bun.lock | 1 + packages/call/src/views/main/focused.tsx | 3 +- packages/chat/src/components/input.tsx | 2 + packages/markdown/src/input.tsx | 52 ++++- packages/markdown/src/markdown.tsx | 6 +- packages/shared/src/data.ts | 80 +++++--- packages/user/src/context.tsx | 2 +- 12 files changed, 610 insertions(+), 64 deletions(-) delete mode 100644 apps/web/src/components/modals/utils.ts diff --git a/apps/web/package.json b/apps/web/package.json index 07bfd07..0748a1f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -26,6 +26,7 @@ "@tensamin/tauri": "workspace:*", "@tensamin/ttp": "workspace:*", "@tensamin/tauth": "workspace:*", + "@tensamin/markdown": "workspace:*", "@tensamin/ui": "*", "@tensamin/user": "workspace:*", "@tensamin/notifications": "workspace:*", diff --git a/apps/web/src/components/modals/basic.tsx b/apps/web/src/components/modals/basic.tsx index 6105d7a..be3e967 100644 --- a/apps/web/src/components/modals/basic.tsx +++ b/apps/web/src/components/modals/basic.tsx @@ -1,20 +1,58 @@ import type { User } from "@tensamin/user/context"; -import { Avatar, AvatarImage, AvatarFallback } from "@tensamin/ui"; -import { reduceDisplay } from "./utils"; +import { + Avatar, + AvatarImage, + AvatarFallback, + Tooltip, + TooltipTrigger, + TooltipContent, +} from "@tensamin/ui"; import { Card, CardHeader } 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 ( - - - {reduceDisplay(props.user.display)} - -
-

{props.user.display}

+
+ + + + {user.display.slice(0, 2).toUpperCase()} + + + + +
+
+ } + /> + + {user.online_status + .split("_") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" ")} + +
+
+

{user.display}

+
+
{extra}
); diff --git a/apps/web/src/components/modals/utils.ts b/apps/web/src/components/modals/utils.ts deleted file mode 100644 index 248dcd5..0000000 --- a/apps/web/src/components/modals/utils.ts +++ /dev/null @@ -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(); - } -} diff --git a/apps/web/src/components/sidebar.tsx b/apps/web/src/components/sidebar.tsx index 6b4b6cd..f8ce056 100644 --- a/apps/web/src/components/sidebar.tsx +++ b/apps/web/src/components/sidebar.tsx @@ -7,6 +7,26 @@ import { SidebarContent, SidebarFooter, useSidebar, + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuGroup, + DropdownMenuLabel, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + DialogClose, + Button, + Label, + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem, } from "@tensamin/ui"; import { isTauri } from "@tauri-apps/api/core"; import { useIsMobile } from "@tensamin/ui"; @@ -14,18 +34,168 @@ import { MobileNavbar } from "./navbar"; import SidebarBox from "@tensamin/call/sidebarBox"; import { useShowMobileNavbar } from "@/routes/app/layout"; -/** - * Renders the conversation sidebar with account summary and conversation list. - * On mobile the sidebar is always kept in the DOM and hidden via CSS - * (opacity + translateX) instead of being unmounted. This keeps the DOM and - * React state alive while the drawer is closed, allowing the sidebar to open - * instantly on subsequent toggles. - * @returns Sidebar JSX. - */ +import { Ellipsis, Check } from "lucide-react"; +import { useState } from "react"; +import type { User } from "@tensamin/user/context"; +import type z from "zod"; +import { ttp } from "@tensamin/shared/data"; +import { useTTP } from "@tensamin/ttp"; + +type OnlineStatus = z.infer< + typeof ttp.get_user_data.response.shape.online_status +>; + +const onlineStatusLabels: Record = { + 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["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 ( + { + if (!nextOpen) { + setDraftStatus(user.status ?? ""); + setDraftOnlineStatus(user.online_status); + setErrorMessage(""); + setSaveSucceeded(false); + } + + onOpenChange(nextOpen); + }} + > + + + Update Status + +
+ + { + setSaveSucceeded(false); + setErrorMessage(""); + setDraftStatus(e.target.value); + }} + /> + + +
+ {errorMessage && ( +

{errorMessage}

+ )} + + Cancel} /> + + +
+
+ ); +} + export default function Sidebar() { const isMobile = useIsMobile(); const showMobileNavbar = useShowMobileNavbar(); const { openMobile, setOpenMobile } = useSidebar(); + const [draftStatus, setDraftStatus] = useState(""); + const [draftOnlineStatus, setDraftOnlineStatus] = + useState("user_online"); + const [dialogOpen, setDialogOpen] = useState(false); + const [statusErrorMessage, setStatusErrorMessage] = useState(""); + const [statusSaveSucceeded, setStatusSaveSucceeded] = useState(false); + + const { send } = useTTP(); const content = ( <> @@ -39,7 +209,67 @@ export default function Sidebar() { } userId={"own"} - component={(user) => } + component={(user) => ( + <> + + + + + } + /> + + + Profile + { + setDraftStatus(user.status ?? ""); + setDraftOnlineStatus(user.online_status); + setStatusErrorMessage(""); + setStatusSaveSucceeded(false); + setDialogOpen(true); + }} + > + Set Status + + + + + } + /> + { + 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} + /> + + )} />
diff --git a/apps/web/src/routes/settings/profile.tsx b/apps/web/src/routes/settings/profile.tsx index 0b98f2d..e46cc62 100644 --- a/apps/web/src/routes/settings/profile.tsx +++ b/apps/web/src/routes/settings/profile.tsx @@ -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 { + 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() { - return
; + const { get } = useUser(); + const { load } = useStorage(); + const { send } = useTTP(); + const [currentUser, setCurrentUser] = useState(null); + const [draftUser, setDraftUser] = useState>({}); + const [errorMessage, setErrorMessage] = useState(""); + const [saveSucceeded, setSaveSucceeded] = useState(false); + const avatarUploadRef = useRef(null); + const draftInitializedRef = useRef(false); + const effectiveAvatar = + draftUser.avatar === "none" ? undefined : draftUser.avatar; + + const updateDraftUser = ( + updater: (previous: Partial) => Partial, + ) => { + 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 ? ( + <> + + e.target.files?.[0] && handleAvatarUpload(e.target.files[0]) + } + type="file" + /> +
+
+ + + + {draftUser.display?.slice(0, 2).toUpperCase() || + currentUser.display.slice(0, 2).toUpperCase()} + + +
+

Avatar

+
+ + +
+

+ GIFs are supported in decentralised mode or with Tensamin Premium +
+ Maximum file size is 16mb. +

+
+
+ + updateDraftUser((prev) => ({ + ...prev, + display: event.target.value, + })) + } + placeholder="Display Name" + value={draftUser.display || ""} + /> + + updateDraftUser((prev) => ({ + ...prev, + username: event.target.value, + })) + } + placeholder="Username" + value={draftUser.username || ""} + /> + + updateDraftUser((prev) => ({ ...prev, about: value })) + } + value={draftUser.about || ""} + /> + + {errorMessage && ( +

{errorMessage}

+ )} +
+ + ) : ( +

Loading...

+ ); } diff --git a/bun.lock b/bun.lock index 325be20..c7fc5aa 100644 --- a/bun.lock +++ b/bun.lock @@ -55,6 +55,7 @@ "@tensamin/call": "workspace:*", "@tensamin/chat": "workspace:*", "@tensamin/crypto": "workspace:*", + "@tensamin/markdown": "workspace:*", "@tensamin/notifications": "workspace:*", "@tensamin/shared": "workspace:*", "@tensamin/storage": "workspace:*", diff --git a/packages/call/src/views/main/focused.tsx b/packages/call/src/views/main/focused.tsx index ec654d8..78d1740 100644 --- a/packages/call/src/views/main/focused.tsx +++ b/packages/call/src/views/main/focused.tsx @@ -161,7 +161,8 @@ export default function View() { const focusedParticipantHasActiveScreenShare = activeScreenShareParticipantIdSet.has(focusedParticipantId); const focusedTileType: "user" | "stream" = - focusedParticipantType === "stream" && focusedParticipantHasActiveScreenShare + focusedParticipantType === "stream" && + focusedParticipantHasActiveScreenShare ? "stream" : "user"; const isImmersiveFocusedView = callIsFullscreen && usersInFocusedViewHidden; diff --git a/packages/chat/src/components/input.tsx b/packages/chat/src/components/input.tsx index 487c813..b2aef9e 100644 --- a/packages/chat/src/components/input.tsx +++ b/packages/chat/src/components/input.tsx @@ -97,6 +97,8 @@ export default function InputComponent({ > void; onSubmit?: () => void; 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 = { from: number; to: number; @@ -80,6 +109,10 @@ const markdownDecorations = ViewPlugin.fromClass( export default function Input(props: InputProps) { 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(null); const viewRef = useRef(undefined); const ignoreSyncRef = useRef(false); @@ -143,7 +176,22 @@ export default function Input(props: InputProps) { }); }, [props.value]); - return
; + return ( +
+ ); } /** @@ -204,7 +252,7 @@ function createEditorExtensions( }), EditorView.theme({ "&": { - fontSize: "1rem", + fontSize: "inherit", }, "&.cm-editor": { width: "100%", diff --git a/packages/markdown/src/markdown.tsx b/packages/markdown/src/markdown.tsx index c3a17a6..59d0a6a 100644 --- a/packages/markdown/src/markdown.tsx +++ b/packages/markdown/src/markdown.tsx @@ -665,12 +665,12 @@ export const markdownStyles = ` .tm-md-table th { background: hsl(var(--muted)); font-weight: 600; } .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-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 { padding: 0.7rem 0.85rem; min-height: 2.75rem; } -.cm-editor.tm-md-editor .cm-line { padding: 0 1px; color: hsl(var(--foreground)); } +.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; 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-code-line { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; background: hsl(var(--muted)); border-radius: 0.3rem; } `; diff --git a/packages/shared/src/data.ts b/packages/shared/src/data.ts index 857e9af..54af40f 100644 --- a/packages/shared/src/data.ts +++ b/packages/shared/src/data.ts @@ -48,6 +48,31 @@ export type Communities = z.infer< export type Calls = z.infer; // 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 = { identification: { request: z.object({ @@ -92,31 +117,11 @@ export const ttp = { request: z.object({ user_id: z.number(), }), - response: z.object({ - about: z.string().max(255).optional(), - avatar: z.string().optional(), - display: z.string().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().max(15), - }), + response: user, + }, + change_user_data: { + request: user.partial(), + response: z.object({}), }, ping: { request: z.object({ @@ -283,3 +288,28 @@ export const storageDefaults: Storage = { call_mute_range_start: -55, call_mute_range_end: -45, }; + +// User Status +export function getStatusColor( + status: z.infer, +) { + 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"; + } +} diff --git a/packages/user/src/context.tsx b/packages/user/src/context.tsx index 1ab964b..962152a 100644 --- a/packages/user/src/context.tsx +++ b/packages/user/src/context.tsx @@ -51,7 +51,7 @@ export default function UserProvider(props: { children: React.ReactNode }) { const user = { ...userData.data, avatar: userData.data.avatar - ? `data:image/png;base64,${userData.data.avatar}` + ? `data:image/webp;base64,${atob(userData.data.avatar)}` : undefined, };