chats: add chat avatars and profile view-only modal
All checks were successful
CI / test (push) Successful in 23s
All checks were successful
CI / test (push) Successful in 23s
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user