web: add avatar file upload in profile editors
All checks were successful
CI / test (push) Successful in 28s

This commit is contained in:
2026-03-08 13:45:47 +03:00
parent 688cf0dd39
commit f7413bc626
2 changed files with 108 additions and 4 deletions

View File

@@ -1,11 +1,12 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { archiveChat, clearChat, createPrivateChat, deleteChat, getChats, getSavedMessagesChat, joinChat, leaveChat, pinChat, unarchiveChat, unpinChat } from "../api/chats"; import { archiveChat, clearChat, createPrivateChat, deleteChat, getChats, getSavedMessagesChat, joinChat, leaveChat, pinChat, requestUploadUrl, unarchiveChat, unpinChat, uploadToPresignedUrl } from "../api/chats";
import { globalSearch } from "../api/search"; import { globalSearch } from "../api/search";
import type { DiscoverChat, Message, UserSearchItem } from "../chat/types"; import type { DiscoverChat, Message, UserSearchItem } from "../chat/types";
import { addContact, addContactByEmail, listContacts, removeContact, updateMyProfile } from "../api/users"; import { addContact, addContactByEmail, listContacts, removeContact, updateMyProfile } from "../api/users";
import { useAuthStore } from "../store/authStore"; import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore"; import { useChatStore } from "../store/chatStore";
import { useUiStore } from "../store/uiStore";
import { NewChatPanel } from "./NewChatPanel"; import { NewChatPanel } from "./NewChatPanel";
import { SettingsPanel } from "./SettingsPanel"; import { SettingsPanel } from "./SettingsPanel";
import { applyAppearancePreferences, getAppPreferences } from "../utils/preferences"; import { applyAppearancePreferences, getAppPreferences } from "../utils/preferences";
@@ -18,6 +19,7 @@ export function ChatList() {
const loadChats = useChatStore((s) => s.loadChats); const loadChats = useChatStore((s) => s.loadChats);
const me = useAuthStore((s) => s.me); const me = useAuthStore((s) => s.me);
const logout = useAuthStore((s) => s.logout); const logout = useAuthStore((s) => s.logout);
const showToast = useUiStore((s) => s.showToast);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [userResults, setUserResults] = useState<UserSearchItem[]>([]); const [userResults, setUserResults] = useState<UserSearchItem[]>([]);
const [discoverResults, setDiscoverResults] = useState<DiscoverChat[]>([]); const [discoverResults, setDiscoverResults] = useState<DiscoverChat[]>([]);
@@ -46,6 +48,7 @@ export function ChatList() {
const [profileAllowPrivateMessages, setProfileAllowPrivateMessages] = useState(true); const [profileAllowPrivateMessages, setProfileAllowPrivateMessages] = useState(true);
const [profileError, setProfileError] = useState<string | null>(null); const [profileError, setProfileError] = useState<string | null>(null);
const [profileSaving, setProfileSaving] = useState(false); const [profileSaving, setProfileSaving] = useState(false);
const [profileAvatarUploading, setProfileAvatarUploading] = useState(false);
const sidebarRef = useRef<HTMLElement | null>(null); const sidebarRef = useRef<HTMLElement | null>(null);
const burgerMenuRef = useRef<HTMLDivElement | null>(null); const burgerMenuRef = useRef<HTMLDivElement | null>(null);
const burgerButtonRef = useRef<HTMLButtonElement | null>(null); const burgerButtonRef = useRef<HTMLButtonElement | null>(null);
@@ -205,6 +208,22 @@ export function ChatList() {
setProfileAllowPrivateMessages(me.allow_private_messages ?? true); setProfileAllowPrivateMessages(me.allow_private_messages ?? true);
}, [me]); }, [me]);
async function uploadProfileAvatar(file: File) {
setProfileAvatarUploading(true);
setProfileError(null);
try {
const upload = await requestUploadUrl(file);
await uploadToPresignedUrl(upload.upload_url, upload.required_headers, file);
setProfileAvatarUrl(upload.file_url);
showToast("Avatar uploaded");
} catch {
setProfileError("Failed to upload avatar");
showToast("Avatar upload failed");
} finally {
setProfileAvatarUploading(false);
}
}
async function openSavedMessages() { async function openSavedMessages() {
const saved = await getSavedMessagesChat(); const saved = await getSavedMessagesChat();
const updatedChats = await getChats(); const updatedChats = await getChats();
@@ -754,6 +773,37 @@ export function ChatList() {
<div className="w-full rounded-xl border border-slate-700/80 bg-slate-900 p-3"> <div className="w-full rounded-xl border border-slate-700/80 bg-slate-900 p-3">
<p className="mb-2 text-sm font-semibold">Edit profile</p> <p className="mb-2 text-sm font-semibold">Edit profile</p>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-3 rounded border border-slate-700/70 bg-slate-800/40 p-2">
{profileAvatarUrl.trim() ? (
<img alt="avatar preview" className="h-12 w-12 rounded-full object-cover" src={profileAvatarUrl.trim()} />
) : (
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-slate-700 text-xs text-slate-300">No avatar</div>
)}
<div className="flex flex-wrap items-center gap-2">
<label className="cursor-pointer rounded bg-sky-500 px-3 py-1.5 text-xs font-semibold text-slate-950">
{profileAvatarUploading ? "Uploading..." : "Upload avatar"}
<input
accept="image/*"
className="hidden"
disabled={profileAvatarUploading}
onChange={(e) => {
const file = e.target.files?.[0];
e.currentTarget.value = "";
if (!file) {
return;
}
void uploadProfileAvatar(file);
}}
type="file"
/>
</label>
{profileAvatarUrl.trim() ? (
<button className="rounded bg-slate-700 px-3 py-1.5 text-xs" onClick={() => setProfileAvatarUrl("")} type="button">
Remove
</button>
) : null}
</div>
</div>
<input className="w-full rounded bg-slate-800 px-3 py-2 text-sm" placeholder="Name" value={profileName} onChange={(e) => setProfileName(e.target.value)} /> <input className="w-full rounded bg-slate-800 px-3 py-2 text-sm" placeholder="Name" value={profileName} onChange={(e) => setProfileName(e.target.value)} />
<input className="w-full rounded bg-slate-800 px-3 py-2 text-sm" placeholder="Username" value={profileUsername} onChange={(e) => setProfileUsername(e.target.value.replace("@", ""))} /> <input className="w-full rounded bg-slate-800 px-3 py-2 text-sm" placeholder="Username" value={profileUsername} onChange={(e) => setProfileUsername(e.target.value.replace("@", ""))} />
<input className="w-full rounded bg-slate-800 px-3 py-2 text-sm" placeholder="Bio (optional)" value={profileBio} onChange={(e) => setProfileBio(e.target.value)} /> <input className="w-full rounded bg-slate-800 px-3 py-2 text-sm" placeholder="Bio (optional)" value={profileBio} onChange={(e) => setProfileBio(e.target.value)} />
@@ -771,7 +821,7 @@ export function ChatList() {
<div className="mt-3 flex gap-2"> <div className="mt-3 flex gap-2">
<button <button
className="flex-1 rounded bg-sky-500 px-3 py-2 text-sm font-semibold text-slate-950 disabled:opacity-60" className="flex-1 rounded bg-sky-500 px-3 py-2 text-sm font-semibold text-slate-950 disabled:opacity-60"
disabled={profileSaving} disabled={profileSaving || profileAvatarUploading}
onClick={async () => { onClick={async () => {
setProfileSaving(true); setProfileSaving(true);
setProfileError(null); setProfileError(null);

View File

@@ -2,10 +2,12 @@ import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import QRCode from "qrcode"; import QRCode from "qrcode";
import { listBlockedUsers, updateMyProfile } from "../api/users"; import { listBlockedUsers, updateMyProfile } from "../api/users";
import { requestUploadUrl, uploadToPresignedUrl } from "../api/chats";
import { disableTwoFactor, enableTwoFactor, listSessions, revokeAllSessions, revokeSession, setupTwoFactor } from "../api/auth"; import { disableTwoFactor, enableTwoFactor, listSessions, revokeAllSessions, revokeSession, setupTwoFactor } from "../api/auth";
import { getNotifications, type NotificationItem } from "../api/notifications"; import { getNotifications, type NotificationItem } from "../api/notifications";
import type { AuthSession, AuthUser } from "../chat/types"; import type { AuthSession, AuthUser } from "../chat/types";
import { useAuthStore } from "../store/authStore"; import { useAuthStore } from "../store/authStore";
import { useUiStore } from "../store/uiStore";
import { AppPreferences, getAppPreferences, updateAppPreferences } from "../utils/preferences"; import { AppPreferences, getAppPreferences, updateAppPreferences } from "../utils/preferences";
type SettingsPage = "main" | "general" | "notifications" | "privacy"; type SettingsPage = "main" | "general" | "notifications" | "privacy";
@@ -18,6 +20,7 @@ interface Props {
export function SettingsPanel({ open, onClose }: Props) { export function SettingsPanel({ open, onClose }: Props) {
const me = useAuthStore((s) => s.me); const me = useAuthStore((s) => s.me);
const logout = useAuthStore((s) => s.logout); const logout = useAuthStore((s) => s.logout);
const showToast = useUiStore((s) => s.showToast);
const [page, setPage] = useState<SettingsPage>("main"); const [page, setPage] = useState<SettingsPage>("main");
const [prefs, setPrefs] = useState<AppPreferences>(() => getAppPreferences()); const [prefs, setPrefs] = useState<AppPreferences>(() => getAppPreferences());
const [blockedCount, setBlockedCount] = useState(0); const [blockedCount, setBlockedCount] = useState(0);
@@ -41,6 +44,7 @@ export function SettingsPanel({ open, onClose }: Props) {
bio: "", bio: "",
avatarUrl: "", avatarUrl: "",
}); });
const [profileAvatarUploading, setProfileAvatarUploading] = useState(false);
useEffect(() => { useEffect(() => {
if (!me) { if (!me) {
@@ -181,6 +185,20 @@ export function SettingsPanel({ open, onClose }: Props) {
const notificationStatus = "Notification" in window ? Notification.permission : "denied"; const notificationStatus = "Notification" in window ? Notification.permission : "denied";
async function uploadAvatar(file: File) {
setProfileAvatarUploading(true);
try {
const upload = await requestUploadUrl(file);
await uploadToPresignedUrl(upload.upload_url, upload.required_headers, file);
setProfileDraft((prev) => ({ ...prev, avatarUrl: upload.file_url }));
showToast("Avatar uploaded");
} catch {
showToast("Avatar upload failed");
} finally {
setProfileAvatarUploading(false);
}
}
return createPortal( return createPortal(
<div className="fixed inset-0 z-[150] bg-slate-950/60" onClick={onClose}> <div className="fixed inset-0 z-[150] bg-slate-950/60" onClick={onClose}>
<aside <aside
@@ -207,6 +225,41 @@ export function SettingsPanel({ open, onClose }: Props) {
<section className="border-b border-slate-700/60 px-4 py-4"> <section className="border-b border-slate-700/60 px-4 py-4">
<p className="mb-2 text-xs uppercase tracking-wide text-slate-400">Profile</p> <p className="mb-2 text-xs uppercase tracking-wide text-slate-400">Profile</p>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-3 rounded border border-slate-700/70 bg-slate-800/40 p-2">
{profileDraft.avatarUrl.trim() ? (
<img alt="avatar preview" className="h-12 w-12 rounded-full object-cover" src={profileDraft.avatarUrl.trim()} />
) : (
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-slate-700 text-xs text-slate-300">No avatar</div>
)}
<div className="flex flex-wrap items-center gap-2">
<label className="cursor-pointer rounded bg-sky-500 px-3 py-1.5 text-xs font-semibold text-slate-950">
{profileAvatarUploading ? "Uploading..." : "Upload avatar"}
<input
accept="image/*"
className="hidden"
disabled={profileAvatarUploading}
onChange={(e) => {
const file = e.target.files?.[0];
e.currentTarget.value = "";
if (!file) {
return;
}
void uploadAvatar(file);
}}
type="file"
/>
</label>
{profileDraft.avatarUrl.trim() ? (
<button
className="rounded bg-slate-700 px-3 py-1.5 text-xs"
onClick={() => setProfileDraft((prev) => ({ ...prev, avatarUrl: "" }))}
type="button"
>
Remove
</button>
) : null}
</div>
</div>
<input <input
className="w-full rounded bg-slate-800 px-3 py-2 text-sm" className="w-full rounded bg-slate-800 px-3 py-2 text-sm"
placeholder="Name" placeholder="Name"
@@ -227,12 +280,13 @@ export function SettingsPanel({ open, onClose }: Props) {
/> />
<input <input
className="w-full rounded bg-slate-800 px-3 py-2 text-sm" className="w-full rounded bg-slate-800 px-3 py-2 text-sm"
placeholder="Avatar URL" placeholder="Avatar URL (optional)"
value={profileDraft.avatarUrl} value={profileDraft.avatarUrl}
onChange={(e) => setProfileDraft((prev) => ({ ...prev, avatarUrl: e.target.value }))} onChange={(e) => setProfileDraft((prev) => ({ ...prev, avatarUrl: e.target.value }))}
/> />
<button <button
className="w-full rounded bg-sky-500 px-3 py-2 text-sm font-semibold text-slate-950" className="w-full rounded bg-sky-500 px-3 py-2 text-sm font-semibold text-slate-950 disabled:opacity-60"
disabled={profileAvatarUploading}
onClick={async () => { onClick={async () => {
const updated = await updateMyProfile({ const updated = await updateMyProfile({
name: profileDraft.name.trim() || undefined, name: profileDraft.name.trim() || undefined,