web: add avatar file upload in profile editors
All checks were successful
CI / test (push) Successful in 28s
All checks were successful
CI / test (push) Successful in 28s
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
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 type { DiscoverChat, Message, UserSearchItem } from "../chat/types";
|
||||
import { addContact, addContactByEmail, listContacts, removeContact, updateMyProfile } from "../api/users";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
import { useChatStore } from "../store/chatStore";
|
||||
import { useUiStore } from "../store/uiStore";
|
||||
import { NewChatPanel } from "./NewChatPanel";
|
||||
import { SettingsPanel } from "./SettingsPanel";
|
||||
import { applyAppearancePreferences, getAppPreferences } from "../utils/preferences";
|
||||
@@ -18,6 +19,7 @@ export function ChatList() {
|
||||
const loadChats = useChatStore((s) => s.loadChats);
|
||||
const me = useAuthStore((s) => s.me);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const showToast = useUiStore((s) => s.showToast);
|
||||
const [search, setSearch] = useState("");
|
||||
const [userResults, setUserResults] = useState<UserSearchItem[]>([]);
|
||||
const [discoverResults, setDiscoverResults] = useState<DiscoverChat[]>([]);
|
||||
@@ -46,6 +48,7 @@ export function ChatList() {
|
||||
const [profileAllowPrivateMessages, setProfileAllowPrivateMessages] = useState(true);
|
||||
const [profileError, setProfileError] = useState<string | null>(null);
|
||||
const [profileSaving, setProfileSaving] = useState(false);
|
||||
const [profileAvatarUploading, setProfileAvatarUploading] = useState(false);
|
||||
const sidebarRef = useRef<HTMLElement | null>(null);
|
||||
const burgerMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const burgerButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
@@ -205,6 +208,22 @@ export function ChatList() {
|
||||
setProfileAllowPrivateMessages(me.allow_private_messages ?? true);
|
||||
}, [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() {
|
||||
const saved = await getSavedMessagesChat();
|
||||
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">
|
||||
<p className="mb-2 text-sm font-semibold">Edit profile</p>
|
||||
<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="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)} />
|
||||
@@ -771,7 +821,7 @@ export function ChatList() {
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
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 () => {
|
||||
setProfileSaving(true);
|
||||
setProfileError(null);
|
||||
|
||||
@@ -2,10 +2,12 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import QRCode from "qrcode";
|
||||
import { listBlockedUsers, updateMyProfile } from "../api/users";
|
||||
import { requestUploadUrl, uploadToPresignedUrl } from "../api/chats";
|
||||
import { disableTwoFactor, enableTwoFactor, listSessions, revokeAllSessions, revokeSession, setupTwoFactor } from "../api/auth";
|
||||
import { getNotifications, type NotificationItem } from "../api/notifications";
|
||||
import type { AuthSession, AuthUser } from "../chat/types";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
import { useUiStore } from "../store/uiStore";
|
||||
import { AppPreferences, getAppPreferences, updateAppPreferences } from "../utils/preferences";
|
||||
|
||||
type SettingsPage = "main" | "general" | "notifications" | "privacy";
|
||||
@@ -18,6 +20,7 @@ interface Props {
|
||||
export function SettingsPanel({ open, onClose }: Props) {
|
||||
const me = useAuthStore((s) => s.me);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const showToast = useUiStore((s) => s.showToast);
|
||||
const [page, setPage] = useState<SettingsPage>("main");
|
||||
const [prefs, setPrefs] = useState<AppPreferences>(() => getAppPreferences());
|
||||
const [blockedCount, setBlockedCount] = useState(0);
|
||||
@@ -41,6 +44,7 @@ export function SettingsPanel({ open, onClose }: Props) {
|
||||
bio: "",
|
||||
avatarUrl: "",
|
||||
});
|
||||
const [profileAvatarUploading, setProfileAvatarUploading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!me) {
|
||||
@@ -181,6 +185,20 @@ export function SettingsPanel({ open, onClose }: Props) {
|
||||
|
||||
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(
|
||||
<div className="fixed inset-0 z-[150] bg-slate-950/60" onClick={onClose}>
|
||||
<aside
|
||||
@@ -207,6 +225,41 @@ export function SettingsPanel({ open, onClose }: Props) {
|
||||
<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>
|
||||
<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
|
||||
className="w-full rounded bg-slate-800 px-3 py-2 text-sm"
|
||||
placeholder="Name"
|
||||
@@ -227,12 +280,13 @@ export function SettingsPanel({ open, onClose }: Props) {
|
||||
/>
|
||||
<input
|
||||
className="w-full rounded bg-slate-800 px-3 py-2 text-sm"
|
||||
placeholder="Avatar URL"
|
||||
placeholder="Avatar URL (optional)"
|
||||
value={profileDraft.avatarUrl}
|
||||
onChange={(e) => setProfileDraft((prev) => ({ ...prev, avatarUrl: e.target.value }))}
|
||||
/>
|
||||
<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 () => {
|
||||
const updated = await updateMyProfile({
|
||||
name: profileDraft.name.trim() || undefined,
|
||||
|
||||
Reference in New Issue
Block a user