chats: add chat avatars and profile view-only modal
All checks were successful
CI / test (push) Successful in 23s

This commit is contained in:
2026-03-08 13:53:29 +03:00
parent f7413bc626
commit bc9d943d11
10 changed files with 236 additions and 114 deletions

View File

@@ -302,6 +302,17 @@ export async function updateChatTitle(chatId: number, title: string): Promise<Ch
return data;
}
export interface ChatProfilePatch {
title?: string;
description?: string | null;
avatar_url?: string | null;
}
export async function updateChatProfile(chatId: number, payload: ChatProfilePatch): Promise<Chat> {
const { data } = await http.patch<Chat>(`/chats/${chatId}/profile`, payload);
return data;
}
export async function addChatMember(chatId: number, userId: number): Promise<ChatMember> {
const { data } = await http.post<ChatMember>(`/chats/${chatId}/members`, { user_id: userId });
return data;

View File

@@ -7,6 +7,7 @@ export interface Chat {
public_id: string;
type: ChatType;
title: string | null;
avatar_url?: string | null;
display_title?: string | null;
handle?: string | null;
description?: string | null;
@@ -23,6 +24,7 @@ export interface Chat {
counterpart_user_id?: number | null;
counterpart_name?: string | null;
counterpart_username?: string | null;
counterpart_avatar_url?: string | null;
counterpart_is_online?: boolean | null;
counterpart_last_seen_at?: string | null;
last_message_text?: string | null;

View File

@@ -9,10 +9,12 @@ import {
getChatDetail,
leaveChat,
listChatMembers,
requestUploadUrl,
removeChatMember,
updateChatProfile,
updateChatNotificationSettings,
updateChatMemberRole,
updateChatTitle
uploadToPresignedUrl
} from "../api/chats";
import { blockUser, getUserById, listBlockedUsers, searchUsers, unblockUser } from "../api/users";
import type { AuthUser, ChatAttachment, ChatDetail, ChatMember, Message, UserSearchItem } from "../chat/types";
@@ -41,6 +43,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
const [error, setError] = useState<string | null>(null);
const [titleDraft, setTitleDraft] = useState("");
const [savingTitle, setSavingTitle] = useState(false);
const [chatAvatarUploading, setChatAvatarUploading] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<UserSearchItem[]>([]);
const [muted, setMuted] = useState(false);
@@ -76,6 +79,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
const showMembersSection = Boolean(chat && isGroupLike && !chat.is_saved);
const canManageMembers = Boolean(isGroupLike && (myRoleNormalized === "owner" || myRoleNormalized === "admin"));
const canEditTitle = Boolean(isGroupLike && (myRoleNormalized === "owner" || myRoleNormalized === "admin"));
const canEditChatProfile = Boolean(isGroupLike && (myRoleNormalized === "owner" || myRoleNormalized === "admin"));
const photoAttachments = useMemo(() => attachments.filter((item) => item.file_type.startsWith("image/")).sort((a, b) => b.id - a.id), [attachments]);
const videoAttachments = useMemo(() => attachments.filter((item) => item.file_type.startsWith("video/")).sort((a, b) => b.id - a.id), [attachments]);
const voiceAttachments = useMemo(() => attachments.filter((item) => item.message_type === "voice").sort((a, b) => b.id - a.id), [attachments]);
@@ -109,6 +113,25 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
onClose();
}
async function uploadChatAvatar(file: File) {
if (!chatId || !canEditChatProfile) {
return;
}
setChatAvatarUploading(true);
try {
const upload = await requestUploadUrl(file);
await uploadToPresignedUrl(upload.upload_url, upload.required_headers, file);
const updated = await updateChatProfile(chatId, { avatar_url: upload.file_url });
setChat((prev) => (prev ? { ...prev, ...updated } : prev));
await loadChats();
showToast("Chat avatar updated");
} catch {
setError("Failed to upload chat avatar");
} finally {
setChatAvatarUploading(false);
}
}
useEffect(() => {
if (!open || !chatId) {
return;
@@ -225,8 +248,12 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
<>
<div className="mb-3 rounded-lg border border-slate-700/70 bg-slate-800/60 p-4">
<div className="flex items-center gap-3">
{chat.type === "private" && counterpartProfile?.avatar_url ? (
<img alt="avatar" className="h-16 w-16 rounded-full object-cover" src={counterpartProfile.avatar_url} />
{(chat.type === "private" ? (counterpartProfile?.avatar_url || chat.counterpart_avatar_url) : chat.avatar_url) ? (
<img
alt="avatar"
className="h-16 w-16 rounded-full object-cover"
src={chat.type === "private" ? (counterpartProfile?.avatar_url || chat.counterpart_avatar_url || "") : (chat.avatar_url || "")}
/>
) : (
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-sky-500/30 text-xl font-semibold uppercase text-sky-100">
{initialsFromName(chat.display_title || chat.title || counterpartProfile?.name || chat.type)}
@@ -273,6 +300,44 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
<p className="mb-2 text-xs text-slate-300">{muted ? "Chat notifications are muted." : "Chat notifications are enabled."}</p>
{isGroupLike && canEditTitle ? (
<>
<p className="mt-2 text-xs text-slate-400">Avatar</p>
<div className="mt-1 flex items-center gap-2">
<label className="cursor-pointer rounded bg-slate-700 px-2 py-1 text-xs hover:bg-slate-600">
{chatAvatarUploading ? "Uploading..." : "Upload avatar"}
<input
accept="image/*"
className="hidden"
disabled={chatAvatarUploading}
onChange={(e) => {
const file = e.target.files?.[0];
e.currentTarget.value = "";
if (!file) {
return;
}
void uploadChatAvatar(file);
}}
type="file"
/>
</label>
{chat.avatar_url ? (
<button
className="rounded bg-slate-700 px-2 py-1 text-xs hover:bg-slate-600"
onClick={async () => {
try {
const updated = await updateChatProfile(chatId, { avatar_url: null });
setChat((prev) => (prev ? { ...prev, ...updated } : prev));
await loadChats();
showToast("Chat avatar removed");
} catch {
setError("Failed to remove chat avatar");
}
}}
type="button"
>
Remove avatar
</button>
) : null}
</div>
<p className="mt-2 text-xs text-slate-400">Title</p>
<input
className="mt-1 w-full rounded bg-slate-800 px-3 py-2 text-sm outline-none"
@@ -281,11 +346,11 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
/>
<button
className="mt-2 w-full rounded bg-sky-500 px-3 py-2 text-sm font-semibold text-slate-950 disabled:opacity-60"
disabled={savingTitle || !titleDraft.trim()}
disabled={savingTitle || !titleDraft.trim() || chatAvatarUploading}
onClick={async () => {
setSavingTitle(true);
try {
const updated = await updateChatTitle(chatId, titleDraft.trim());
const updated = await updateChatProfile(chatId, { title: titleDraft.trim() });
setChat((prev) => (prev ? { ...prev, ...updated } : prev));
await loadChats();
} catch {

View File

@@ -1,12 +1,11 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { archiveChat, clearChat, createPrivateChat, deleteChat, getChats, getSavedMessagesChat, joinChat, leaveChat, pinChat, requestUploadUrl, unarchiveChat, unpinChat, uploadToPresignedUrl } from "../api/chats";
import { archiveChat, clearChat, createPrivateChat, deleteChat, getChats, getSavedMessagesChat, joinChat, leaveChat, pinChat, unarchiveChat, unpinChat } 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 { addContact, addContactByEmail, listContacts, removeContact } 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";
@@ -19,7 +18,6 @@ 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[]>([]);
@@ -41,14 +39,6 @@ export function ChatList() {
const [profileOpen, setProfileOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [profileName, setProfileName] = useState("");
const [profileUsername, setProfileUsername] = useState("");
const [profileBio, setProfileBio] = useState("");
const [profileAvatarUrl, setProfileAvatarUrl] = useState("");
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);
@@ -197,33 +187,6 @@ export function ChatList() {
applyAppearancePreferences(getAppPreferences());
}, []);
useEffect(() => {
if (!me) {
return;
}
setProfileName(me.name || "");
setProfileUsername(me.username || "");
setProfileBio(me.bio || "");
setProfileAvatarUrl(me.avatar_url || "");
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();
@@ -275,9 +238,13 @@ export function ChatList() {
>
<div className="flex items-start gap-3">
<div className="relative mt-0.5">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-sky-500/30 text-sm font-semibold uppercase text-sky-100">
{chat.pinned ? "📌" : (chat.display_title || chat.title || chat.type).slice(0, 1)}
</div>
{chatAvatar(chat) ? (
<img alt="chat avatar" className="h-10 w-10 rounded-full object-cover" src={chatAvatar(chat)!} />
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-sky-500/30 text-sm font-semibold uppercase text-sky-100">
{chat.pinned ? "📌" : (chat.display_title || chat.title || chat.type).slice(0, 1)}
</div>
)}
{chat.type === "private" && chat.counterpart_is_online ? (
<span className="absolute bottom-0 right-0 h-2.5 w-2.5 rounded-full border border-slate-900 bg-emerald-400" />
) : null}
@@ -771,79 +738,30 @@ export function ChatList() {
{profileOpen ? (
<div className="absolute inset-0 z-50 flex items-end justify-center bg-slate-950/55 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>
<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}
<p className="mb-3 text-sm font-semibold">My Profile</p>
<div className="rounded-xl border border-slate-700/70 bg-slate-800/50 p-4 text-center">
{me?.avatar_url ? (
<img alt="my avatar" className="mx-auto h-24 w-24 rounded-full object-cover" src={me.avatar_url} />
) : (
<div className="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-sky-500/30 text-2xl font-semibold uppercase text-sky-100">
{(me?.name || me?.username || "Me").slice(0, 2)}
</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)} />
<input className="w-full rounded bg-slate-800 px-3 py-2 text-sm" placeholder="Avatar URL (optional)" value={profileAvatarUrl} onChange={(e) => setProfileAvatarUrl(e.target.value)} />
<label className="flex items-center gap-2 rounded bg-slate-800 px-3 py-2 text-sm">
<input
type="checkbox"
checked={profileAllowPrivateMessages}
onChange={(e) => setProfileAllowPrivateMessages(e.target.checked)}
/>
Allow private messages
</label>
)}
<p className="mt-3 truncate text-base font-semibold">{me?.name || "No name"}</p>
<p className="truncate text-sm text-sky-300">@{me?.username || "username"}</p>
<p className="mt-1 truncate text-xs text-slate-400">{me?.email || ""}</p>
{me?.bio ? <p className="mt-2 text-sm text-slate-300">{me.bio}</p> : null}
<p className="mt-2 text-xs text-slate-400">Profile editing is available in Settings</p>
</div>
{profileError ? <p className="mt-2 text-xs text-red-400">{profileError}</p> : null}
<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 || profileAvatarUploading}
onClick={async () => {
setProfileSaving(true);
setProfileError(null);
try {
const updated = await updateMyProfile({
name: profileName.trim() || undefined,
username: profileUsername.trim() || undefined,
bio: profileBio.trim() || null,
avatar_url: profileAvatarUrl.trim() || null,
allow_private_messages: profileAllowPrivateMessages
});
useAuthStore.setState({ me: updated });
await loadChats();
setProfileOpen(false);
} catch {
setProfileError("Failed to update profile");
} finally {
setProfileSaving(false);
}
className="flex-1 rounded bg-sky-500 px-3 py-2 text-sm font-semibold text-slate-950"
onClick={() => {
setProfileOpen(false);
setSettingsOpen(true);
}}
>
Save
Open settings
</button>
<button className="flex-1 rounded bg-slate-700 px-3 py-2 text-sm" onClick={() => setProfileOpen(false)}>
Cancel
@@ -916,6 +834,21 @@ function chatMetaLabel(chat: {
return `${subscribers} subscribers`;
}
function chatAvatar(chat: {
type: "private" | "group" | "channel";
is_saved?: boolean;
avatar_url?: string | null;
counterpart_avatar_url?: string | null;
}): string | null {
if (chat.is_saved) {
return null;
}
if (chat.type === "private") {
return chat.counterpart_avatar_url || null;
}
return chat.avatar_url || null;
}
function chatPreviewLabel(chat: {
last_message_text?: string | null;
last_message_type?: "text" | "image" | "video" | "audio" | "voice" | "file" | "circle_video" | null;