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