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 { 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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user