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:
26
alembic/versions/0021_chat_avatar_url.py
Normal file
26
alembic/versions/0021_chat_avatar_url.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""add avatar url for chats
|
||||||
|
|
||||||
|
Revision ID: 0021_chat_avatar_url
|
||||||
|
Revises: 0020_attachment_waveform_data
|
||||||
|
Create Date: 2026-03-08
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0021_chat_avatar_url"
|
||||||
|
down_revision: str | None = "0020_attachment_waveform_data"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("chats", sa.Column("avatar_url", sa.String(length=512), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("chats", "avatar_url")
|
||||||
@@ -32,6 +32,7 @@ class Chat(Base):
|
|||||||
public_id: Mapped[str] = mapped_column(String(24), unique=True, index=True, nullable=False, default=generate_public_id)
|
public_id: Mapped[str] = mapped_column(String(24), unique=True, index=True, nullable=False, default=generate_public_id)
|
||||||
type: Mapped[ChatType] = mapped_column(SAEnum(ChatType), nullable=False, index=True)
|
type: Mapped[ChatType] = mapped_column(SAEnum(ChatType), nullable=False, index=True)
|
||||||
title: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
title: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
avatar_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
handle: Mapped[str | None] = mapped_column(String(64), nullable=True, unique=True, index=True)
|
handle: Mapped[str | None] = mapped_column(String(64), nullable=True, unique=True, index=True)
|
||||||
description: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
description: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
is_public: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
is_public: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from app.chats.schemas import (
|
|||||||
ChatNotificationSettingsRead,
|
ChatNotificationSettingsRead,
|
||||||
ChatNotificationSettingsUpdate,
|
ChatNotificationSettingsUpdate,
|
||||||
ChatPinMessageRequest,
|
ChatPinMessageRequest,
|
||||||
|
ChatProfileUpdateRequest,
|
||||||
ChatRead,
|
ChatRead,
|
||||||
ChatTitleUpdateRequest,
|
ChatTitleUpdateRequest,
|
||||||
)
|
)
|
||||||
@@ -40,6 +41,7 @@ from app.chats.service import (
|
|||||||
set_chat_pinned_for_user,
|
set_chat_pinned_for_user,
|
||||||
update_chat_member_role_for_user,
|
update_chat_member_role_for_user,
|
||||||
update_chat_notification_settings_for_user,
|
update_chat_notification_settings_for_user,
|
||||||
|
update_chat_profile_for_user,
|
||||||
update_chat_title_for_user,
|
update_chat_title_for_user,
|
||||||
)
|
)
|
||||||
from app.database.session import get_db
|
from app.database.session import get_db
|
||||||
@@ -139,6 +141,18 @@ async def update_chat_title(
|
|||||||
return await serialize_chat_for_user(db, user_id=current_user.id, chat=chat)
|
return await serialize_chat_for_user(db, user_id=current_user.id, chat=chat)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{chat_id}/profile", response_model=ChatRead)
|
||||||
|
async def update_chat_profile(
|
||||||
|
chat_id: int,
|
||||||
|
payload: ChatProfileUpdateRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> ChatRead:
|
||||||
|
chat = await update_chat_profile_for_user(db, chat_id=chat_id, user_id=current_user.id, payload=payload)
|
||||||
|
await realtime_gateway.publish_chat_updated(chat_id=chat.id)
|
||||||
|
return await serialize_chat_for_user(db, user_id=current_user.id, chat=chat)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{chat_id}/members", response_model=list[ChatMemberRead])
|
@router.get("/{chat_id}/members", response_model=list[ChatMemberRead])
|
||||||
async def list_chat_members(
|
async def list_chat_members(
|
||||||
chat_id: int,
|
chat_id: int,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class ChatRead(BaseModel):
|
|||||||
public_id: str
|
public_id: str
|
||||||
type: ChatType
|
type: ChatType
|
||||||
title: str | None = None
|
title: str | None = None
|
||||||
|
avatar_url: str | None = None
|
||||||
display_title: str | None = None
|
display_title: str | None = None
|
||||||
handle: str | None = None
|
handle: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
@@ -29,6 +30,7 @@ class ChatRead(BaseModel):
|
|||||||
counterpart_user_id: int | None = None
|
counterpart_user_id: int | None = None
|
||||||
counterpart_name: str | None = None
|
counterpart_name: str | None = None
|
||||||
counterpart_username: str | None = None
|
counterpart_username: str | None = None
|
||||||
|
counterpart_avatar_url: str | None = None
|
||||||
counterpart_is_online: bool | None = None
|
counterpart_is_online: bool | None = None
|
||||||
counterpart_last_seen_at: datetime | None = None
|
counterpart_last_seen_at: datetime | None = None
|
||||||
last_message_text: str | None = None
|
last_message_text: str | None = None
|
||||||
@@ -72,6 +74,12 @@ class ChatTitleUpdateRequest(BaseModel):
|
|||||||
title: str = Field(min_length=1, max_length=255)
|
title: str = Field(min_length=1, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class ChatProfileUpdateRequest(BaseModel):
|
||||||
|
title: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
description: str | None = Field(default=None, max_length=512)
|
||||||
|
avatar_url: str | None = Field(default=None, max_length=512)
|
||||||
|
|
||||||
|
|
||||||
class ChatPinMessageRequest(BaseModel):
|
class ChatPinMessageRequest(BaseModel):
|
||||||
message_id: int | None = None
|
message_id: int | None = None
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from app.chats.schemas import (
|
|||||||
ChatNotificationSettingsUpdate,
|
ChatNotificationSettingsUpdate,
|
||||||
ChatInviteLinkRead,
|
ChatInviteLinkRead,
|
||||||
ChatPinMessageRequest,
|
ChatPinMessageRequest,
|
||||||
|
ChatProfileUpdateRequest,
|
||||||
ChatRead,
|
ChatRead,
|
||||||
ChatTitleUpdateRequest,
|
ChatTitleUpdateRequest,
|
||||||
)
|
)
|
||||||
@@ -40,6 +41,16 @@ async def _can_view_last_seen(*, db: AsyncSession, target_user, viewer_user_id:
|
|||||||
return await is_user_in_contacts(db, owner_user_id=target_user.id, candidate_user_id=viewer_user_id)
|
return await is_user_in_contacts(db, owner_user_id=target_user.id, candidate_user_id=viewer_user_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def _can_view_avatar(*, db: AsyncSession, target_user, viewer_user_id: int) -> bool:
|
||||||
|
if target_user.id == viewer_user_id:
|
||||||
|
return True
|
||||||
|
if target_user.privacy_avatar == "everyone":
|
||||||
|
return True
|
||||||
|
if target_user.privacy_avatar == "nobody":
|
||||||
|
return False
|
||||||
|
return await is_user_in_contacts(db, owner_user_id=target_user.id, candidate_user_id=viewer_user_id)
|
||||||
|
|
||||||
|
|
||||||
async def _can_invite_to_group(*, db: AsyncSession, target_user, actor_user_id: int) -> bool:
|
async def _can_invite_to_group(*, db: AsyncSession, target_user, actor_user_id: int) -> bool:
|
||||||
if target_user.id == actor_user_id:
|
if target_user.id == actor_user_id:
|
||||||
return False
|
return False
|
||||||
@@ -66,6 +77,7 @@ async def serialize_chat_for_user(
|
|||||||
counterpart_user_id: int | None = None
|
counterpart_user_id: int | None = None
|
||||||
counterpart_name: str | None = None
|
counterpart_name: str | None = None
|
||||||
counterpart_username: str | None = None
|
counterpart_username: str | None = None
|
||||||
|
counterpart_avatar_url: str | None = None
|
||||||
counterpart_is_online: bool | None = None
|
counterpart_is_online: bool | None = None
|
||||||
counterpart_last_seen_at = None
|
counterpart_last_seen_at = None
|
||||||
if chat.is_saved:
|
if chat.is_saved:
|
||||||
@@ -81,6 +93,8 @@ async def serialize_chat_for_user(
|
|||||||
counterpart_username = counterpart.username
|
counterpart_username = counterpart.username
|
||||||
presence_allowed = await _can_view_last_seen(db=db, target_user=counterpart, viewer_user_id=user_id)
|
presence_allowed = await _can_view_last_seen(db=db, target_user=counterpart, viewer_user_id=user_id)
|
||||||
counterpart_last_seen_at = counterpart.last_seen_at if presence_allowed else None
|
counterpart_last_seen_at = counterpart.last_seen_at if presence_allowed else None
|
||||||
|
avatar_allowed = await _can_view_avatar(db=db, target_user=counterpart, viewer_user_id=user_id)
|
||||||
|
counterpart_avatar_url = counterpart.avatar_url if avatar_allowed else None
|
||||||
presence = await get_users_online_map([counterpart_id])
|
presence = await get_users_online_map([counterpart_id])
|
||||||
if counterpart:
|
if counterpart:
|
||||||
presence_allowed = await _can_view_last_seen(db=db, target_user=counterpart, viewer_user_id=user_id)
|
presence_allowed = await _can_view_last_seen(db=db, target_user=counterpart, viewer_user_id=user_id)
|
||||||
@@ -114,6 +128,7 @@ async def serialize_chat_for_user(
|
|||||||
"public_id": chat.public_id,
|
"public_id": chat.public_id,
|
||||||
"type": chat.type,
|
"type": chat.type,
|
||||||
"title": chat.title,
|
"title": chat.title,
|
||||||
|
"avatar_url": chat.avatar_url,
|
||||||
"display_title": display_title,
|
"display_title": display_title,
|
||||||
"handle": chat.handle,
|
"handle": chat.handle,
|
||||||
"description": chat.description,
|
"description": chat.description,
|
||||||
@@ -130,6 +145,7 @@ async def serialize_chat_for_user(
|
|||||||
"counterpart_user_id": counterpart_user_id,
|
"counterpart_user_id": counterpart_user_id,
|
||||||
"counterpart_name": counterpart_name,
|
"counterpart_name": counterpart_name,
|
||||||
"counterpart_username": counterpart_username,
|
"counterpart_username": counterpart_username,
|
||||||
|
"counterpart_avatar_url": counterpart_avatar_url,
|
||||||
"counterpart_is_online": counterpart_is_online,
|
"counterpart_is_online": counterpart_is_online,
|
||||||
"counterpart_last_seen_at": counterpart_last_seen_at,
|
"counterpart_last_seen_at": counterpart_last_seen_at,
|
||||||
"last_message_text": last_message.text if last_message else None,
|
"last_message_text": last_message.text if last_message else None,
|
||||||
@@ -300,6 +316,29 @@ async def update_chat_title_for_user(
|
|||||||
return chat
|
return chat
|
||||||
|
|
||||||
|
|
||||||
|
async def update_chat_profile_for_user(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
chat_id: int,
|
||||||
|
user_id: int,
|
||||||
|
payload: ChatProfileUpdateRequest,
|
||||||
|
) -> Chat:
|
||||||
|
chat, membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=user_id)
|
||||||
|
_ensure_group_or_channel(chat.type)
|
||||||
|
_ensure_manage_permission(membership.role)
|
||||||
|
|
||||||
|
if payload.title is not None:
|
||||||
|
chat.title = payload.title.strip() or chat.title
|
||||||
|
if payload.description is not None:
|
||||||
|
chat.description = payload.description.strip() or None
|
||||||
|
if payload.avatar_url is not None:
|
||||||
|
chat.avatar_url = payload.avatar_url.strip() or None
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(chat)
|
||||||
|
return chat
|
||||||
|
|
||||||
|
|
||||||
async def add_chat_member_for_user(
|
async def add_chat_member_for_user(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
*,
|
*,
|
||||||
@@ -454,6 +493,7 @@ async def discover_public_chats_for_user(db: AsyncSession, *, user_id: int, quer
|
|||||||
"public_id": chat.public_id,
|
"public_id": chat.public_id,
|
||||||
"type": chat.type,
|
"type": chat.type,
|
||||||
"title": chat.title,
|
"title": chat.title,
|
||||||
|
"avatar_url": chat.avatar_url,
|
||||||
"handle": chat.handle,
|
"handle": chat.handle,
|
||||||
"description": chat.description,
|
"description": chat.description,
|
||||||
"is_public": chat.is_public,
|
"is_public": chat.is_public,
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ All fields are optional.
|
|||||||
"public_id": "A1B2C3D4E5F6G7H8J9K0L1M2",
|
"public_id": "A1B2C3D4E5F6G7H8J9K0L1M2",
|
||||||
"type": "private",
|
"type": "private",
|
||||||
"title": null,
|
"title": null,
|
||||||
|
"avatar_url": null,
|
||||||
"display_title": "Other User",
|
"display_title": "Other User",
|
||||||
"handle": null,
|
"handle": null,
|
||||||
"description": null,
|
"description": null,
|
||||||
@@ -198,6 +199,7 @@ All fields are optional.
|
|||||||
"counterpart_user_id": 2,
|
"counterpart_user_id": 2,
|
||||||
"counterpart_name": "Other User",
|
"counterpart_name": "Other User",
|
||||||
"counterpart_username": "other",
|
"counterpart_username": "other",
|
||||||
|
"counterpart_avatar_url": "https://...",
|
||||||
"counterpart_is_online": true,
|
"counterpart_is_online": true,
|
||||||
"counterpart_last_seen_at": "2026-03-08T10:00:00Z",
|
"counterpart_last_seen_at": "2026-03-08T10:00:00Z",
|
||||||
"last_message_text": "Hello",
|
"last_message_text": "Hello",
|
||||||
@@ -662,6 +664,26 @@ Body:
|
|||||||
|
|
||||||
Response: `200` + `ChatRead`
|
Response: `200` + `ChatRead`
|
||||||
|
|
||||||
|
### PATCH `/api/v1/chats/{chat_id}/profile`
|
||||||
|
|
||||||
|
Auth required.
|
||||||
|
Body (all fields optional):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "New title",
|
||||||
|
"description": "optional description",
|
||||||
|
"avatar_url": "https://..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- only for `group`/`channel`
|
||||||
|
- only for `owner`/`admin`
|
||||||
|
|
||||||
|
Response: `200` + `ChatRead`
|
||||||
|
|
||||||
### GET `/api/v1/chats/{chat_id}/members`
|
### GET `/api/v1/chats/{chat_id}/members`
|
||||||
|
|
||||||
Auth required.
|
Auth required.
|
||||||
|
|||||||
@@ -302,6 +302,17 @@ export async function updateChatTitle(chatId: number, title: string): Promise<Ch
|
|||||||
return data;
|
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> {
|
export async function addChatMember(chatId: number, userId: number): Promise<ChatMember> {
|
||||||
const { data } = await http.post<ChatMember>(`/chats/${chatId}/members`, { user_id: userId });
|
const { data } = await http.post<ChatMember>(`/chats/${chatId}/members`, { user_id: userId });
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface Chat {
|
|||||||
public_id: string;
|
public_id: string;
|
||||||
type: ChatType;
|
type: ChatType;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
|
avatar_url?: string | null;
|
||||||
display_title?: string | null;
|
display_title?: string | null;
|
||||||
handle?: string | null;
|
handle?: string | null;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
@@ -23,6 +24,7 @@ export interface Chat {
|
|||||||
counterpart_user_id?: number | null;
|
counterpart_user_id?: number | null;
|
||||||
counterpart_name?: string | null;
|
counterpart_name?: string | null;
|
||||||
counterpart_username?: string | null;
|
counterpart_username?: string | null;
|
||||||
|
counterpart_avatar_url?: string | null;
|
||||||
counterpart_is_online?: boolean | null;
|
counterpart_is_online?: boolean | null;
|
||||||
counterpart_last_seen_at?: string | null;
|
counterpart_last_seen_at?: string | null;
|
||||||
last_message_text?: string | null;
|
last_message_text?: string | null;
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import {
|
|||||||
getChatDetail,
|
getChatDetail,
|
||||||
leaveChat,
|
leaveChat,
|
||||||
listChatMembers,
|
listChatMembers,
|
||||||
|
requestUploadUrl,
|
||||||
removeChatMember,
|
removeChatMember,
|
||||||
|
updateChatProfile,
|
||||||
updateChatNotificationSettings,
|
updateChatNotificationSettings,
|
||||||
updateChatMemberRole,
|
updateChatMemberRole,
|
||||||
updateChatTitle
|
uploadToPresignedUrl
|
||||||
} from "../api/chats";
|
} from "../api/chats";
|
||||||
import { blockUser, getUserById, listBlockedUsers, searchUsers, unblockUser } from "../api/users";
|
import { blockUser, getUserById, listBlockedUsers, searchUsers, unblockUser } from "../api/users";
|
||||||
import type { AuthUser, ChatAttachment, ChatDetail, ChatMember, Message, UserSearchItem } from "../chat/types";
|
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 [error, setError] = useState<string | null>(null);
|
||||||
const [titleDraft, setTitleDraft] = useState("");
|
const [titleDraft, setTitleDraft] = useState("");
|
||||||
const [savingTitle, setSavingTitle] = useState(false);
|
const [savingTitle, setSavingTitle] = useState(false);
|
||||||
|
const [chatAvatarUploading, setChatAvatarUploading] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [searchResults, setSearchResults] = useState<UserSearchItem[]>([]);
|
const [searchResults, setSearchResults] = useState<UserSearchItem[]>([]);
|
||||||
const [muted, setMuted] = useState(false);
|
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 showMembersSection = Boolean(chat && isGroupLike && !chat.is_saved);
|
||||||
const canManageMembers = Boolean(isGroupLike && (myRoleNormalized === "owner" || myRoleNormalized === "admin"));
|
const canManageMembers = Boolean(isGroupLike && (myRoleNormalized === "owner" || myRoleNormalized === "admin"));
|
||||||
const canEditTitle = 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 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 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]);
|
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();
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open || !chatId) {
|
if (!open || !chatId) {
|
||||||
return;
|
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="mb-3 rounded-lg border border-slate-700/70 bg-slate-800/60 p-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{chat.type === "private" && 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={counterpartProfile.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">
|
<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)}
|
{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>
|
<p className="mb-2 text-xs text-slate-300">{muted ? "Chat notifications are muted." : "Chat notifications are enabled."}</p>
|
||||||
{isGroupLike && canEditTitle ? (
|
{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>
|
<p className="mt-2 text-xs text-slate-400">Title</p>
|
||||||
<input
|
<input
|
||||||
className="mt-1 w-full rounded bg-slate-800 px-3 py-2 text-sm outline-none"
|
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
|
<button
|
||||||
className="mt-2 w-full rounded bg-sky-500 px-3 py-2 text-sm font-semibold text-slate-950 disabled:opacity-60"
|
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 () => {
|
onClick={async () => {
|
||||||
setSavingTitle(true);
|
setSavingTitle(true);
|
||||||
try {
|
try {
|
||||||
const updated = await updateChatTitle(chatId, titleDraft.trim());
|
const updated = await updateChatProfile(chatId, { title: titleDraft.trim() });
|
||||||
setChat((prev) => (prev ? { ...prev, ...updated } : prev));
|
setChat((prev) => (prev ? { ...prev, ...updated } : prev));
|
||||||
await loadChats();
|
await loadChats();
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
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, 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 { 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 } 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";
|
||||||
@@ -19,7 +18,6 @@ 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[]>([]);
|
||||||
@@ -41,14 +39,6 @@ export function ChatList() {
|
|||||||
const [profileOpen, setProfileOpen] = useState(false);
|
const [profileOpen, setProfileOpen] = useState(false);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [settingsOpen, setSettingsOpen] = 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 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);
|
||||||
@@ -197,33 +187,6 @@ export function ChatList() {
|
|||||||
applyAppearancePreferences(getAppPreferences());
|
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() {
|
async function openSavedMessages() {
|
||||||
const saved = await getSavedMessagesChat();
|
const saved = await getSavedMessagesChat();
|
||||||
const updatedChats = await getChats();
|
const updatedChats = await getChats();
|
||||||
@@ -275,9 +238,13 @@ export function ChatList() {
|
|||||||
>
|
>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="relative mt-0.5">
|
<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">
|
{chatAvatar(chat) ? (
|
||||||
{chat.pinned ? "📌" : (chat.display_title || chat.title || chat.type).slice(0, 1)}
|
<img alt="chat avatar" className="h-10 w-10 rounded-full object-cover" src={chatAvatar(chat)!} />
|
||||||
</div>
|
) : (
|
||||||
|
<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 ? (
|
{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" />
|
<span className="absolute bottom-0 right-0 h-2.5 w-2.5 rounded-full border border-slate-900 bg-emerald-400" />
|
||||||
) : null}
|
) : null}
|
||||||
@@ -771,79 +738,30 @@ export function ChatList() {
|
|||||||
{profileOpen ? (
|
{profileOpen ? (
|
||||||
<div className="absolute inset-0 z-50 flex items-end justify-center bg-slate-950/55 p-3">
|
<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">
|
<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-3 text-sm font-semibold">My Profile</p>
|
||||||
<div className="space-y-2">
|
<div className="rounded-xl border border-slate-700/70 bg-slate-800/50 p-4 text-center">
|
||||||
<div className="flex items-center gap-3 rounded border border-slate-700/70 bg-slate-800/40 p-2">
|
{me?.avatar_url ? (
|
||||||
{profileAvatarUrl.trim() ? (
|
<img alt="my avatar" className="mx-auto h-24 w-24 rounded-full object-cover" src={me.avatar_url} />
|
||||||
<img alt="avatar preview" className="h-12 w-12 rounded-full object-cover" src={profileAvatarUrl.trim()} />
|
) : (
|
||||||
) : (
|
<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">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-slate-700 text-xs text-slate-300">No avatar</div>
|
{(me?.name || me?.username || "Me").slice(0, 2)}
|
||||||
)}
|
|
||||||
<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>
|
||||||
</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)} />
|
<p className="mt-3 truncate text-base font-semibold">{me?.name || "No name"}</p>
|
||||||
<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("@", ""))} />
|
<p className="truncate text-sm text-sky-300">@{me?.username || "username"}</p>
|
||||||
<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)} />
|
<p className="mt-1 truncate text-xs text-slate-400">{me?.email || ""}</p>
|
||||||
<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)} />
|
{me?.bio ? <p className="mt-2 text-sm text-slate-300">{me.bio}</p> : null}
|
||||||
<label className="flex items-center gap-2 rounded bg-slate-800 px-3 py-2 text-sm">
|
<p className="mt-2 text-xs text-slate-400">Profile editing is available in Settings</p>
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={profileAllowPrivateMessages}
|
|
||||||
onChange={(e) => setProfileAllowPrivateMessages(e.target.checked)}
|
|
||||||
/>
|
|
||||||
Allow private messages
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
{profileError ? <p className="mt-2 text-xs text-red-400">{profileError}</p> : null}
|
|
||||||
<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={profileSaving || profileAvatarUploading}
|
onClick={() => {
|
||||||
onClick={async () => {
|
setProfileOpen(false);
|
||||||
setProfileSaving(true);
|
setSettingsOpen(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);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Save
|
Open settings
|
||||||
</button>
|
</button>
|
||||||
<button className="flex-1 rounded bg-slate-700 px-3 py-2 text-sm" onClick={() => setProfileOpen(false)}>
|
<button className="flex-1 rounded bg-slate-700 px-3 py-2 text-sm" onClick={() => setProfileOpen(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
@@ -916,6 +834,21 @@ function chatMetaLabel(chat: {
|
|||||||
return `${subscribers} subscribers`;
|
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: {
|
function chatPreviewLabel(chat: {
|
||||||
last_message_text?: string | null;
|
last_message_text?: string | null;
|
||||||
last_message_type?: "text" | "image" | "video" | "audio" | "voice" | "file" | "circle_video" | null;
|
last_message_type?: "text" | "image" | "video" | "audio" | "voice" | "file" | "circle_video" | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user