diff --git a/alembic/versions/0021_chat_avatar_url.py b/alembic/versions/0021_chat_avatar_url.py new file mode 100644 index 0000000..9c32831 --- /dev/null +++ b/alembic/versions/0021_chat_avatar_url.py @@ -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") diff --git a/app/chats/models.py b/app/chats/models.py index f4e448a..c4abccb 100644 --- a/app/chats/models.py +++ b/app/chats/models.py @@ -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) type: Mapped[ChatType] = mapped_column(SAEnum(ChatType), nullable=False, index=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) description: Mapped[str | None] = mapped_column(String(512), nullable=True) is_public: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false") diff --git a/app/chats/router.py b/app/chats/router.py index a121cf3..aa43808 100644 --- a/app/chats/router.py +++ b/app/chats/router.py @@ -15,6 +15,7 @@ from app.chats.schemas import ( ChatNotificationSettingsRead, ChatNotificationSettingsUpdate, ChatPinMessageRequest, + ChatProfileUpdateRequest, ChatRead, ChatTitleUpdateRequest, ) @@ -40,6 +41,7 @@ from app.chats.service import ( set_chat_pinned_for_user, update_chat_member_role_for_user, update_chat_notification_settings_for_user, + update_chat_profile_for_user, update_chat_title_for_user, ) 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) +@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]) async def list_chat_members( chat_id: int, diff --git a/app/chats/schemas.py b/app/chats/schemas.py index b095bee..41abebf 100644 --- a/app/chats/schemas.py +++ b/app/chats/schemas.py @@ -13,6 +13,7 @@ class ChatRead(BaseModel): public_id: str type: ChatType title: str | None = None + avatar_url: str | None = None display_title: str | None = None handle: str | None = None description: str | None = None @@ -29,6 +30,7 @@ class ChatRead(BaseModel): counterpart_user_id: int | None = None counterpart_name: str | None = None counterpart_username: str | None = None + counterpart_avatar_url: str | None = None counterpart_is_online: bool | None = None counterpart_last_seen_at: datetime | None = None last_message_text: str | None = None @@ -72,6 +74,12 @@ class ChatTitleUpdateRequest(BaseModel): 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): message_id: int | None = None diff --git a/app/chats/service.py b/app/chats/service.py index 6519cef..c7ff190 100644 --- a/app/chats/service.py +++ b/app/chats/service.py @@ -15,6 +15,7 @@ from app.chats.schemas import ( ChatNotificationSettingsUpdate, ChatInviteLinkRead, ChatPinMessageRequest, + ChatProfileUpdateRequest, ChatRead, 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) +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: if target_user.id == actor_user_id: return False @@ -66,6 +77,7 @@ async def serialize_chat_for_user( counterpart_user_id: int | None = None counterpart_name: str | None = None counterpart_username: str | None = None + counterpart_avatar_url: str | None = None counterpart_is_online: bool | None = None counterpart_last_seen_at = None if chat.is_saved: @@ -81,6 +93,8 @@ async def serialize_chat_for_user( counterpart_username = counterpart.username 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 + 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]) if counterpart: 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, "type": chat.type, "title": chat.title, + "avatar_url": chat.avatar_url, "display_title": display_title, "handle": chat.handle, "description": chat.description, @@ -130,6 +145,7 @@ async def serialize_chat_for_user( "counterpart_user_id": counterpart_user_id, "counterpart_name": counterpart_name, "counterpart_username": counterpart_username, + "counterpart_avatar_url": counterpart_avatar_url, "counterpart_is_online": counterpart_is_online, "counterpart_last_seen_at": counterpart_last_seen_at, "last_message_text": last_message.text if last_message else None, @@ -300,6 +316,29 @@ async def update_chat_title_for_user( 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( db: AsyncSession, *, @@ -454,6 +493,7 @@ async def discover_public_chats_for_user(db: AsyncSession, *, user_id: int, quer "public_id": chat.public_id, "type": chat.type, "title": chat.title, + "avatar_url": chat.avatar_url, "handle": chat.handle, "description": chat.description, "is_public": chat.is_public, diff --git a/docs/api-reference.md b/docs/api-reference.md index b1d2b35..16920fc 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -182,6 +182,7 @@ All fields are optional. "public_id": "A1B2C3D4E5F6G7H8J9K0L1M2", "type": "private", "title": null, + "avatar_url": null, "display_title": "Other User", "handle": null, "description": null, @@ -198,6 +199,7 @@ All fields are optional. "counterpart_user_id": 2, "counterpart_name": "Other User", "counterpart_username": "other", + "counterpart_avatar_url": "https://...", "counterpart_is_online": true, "counterpart_last_seen_at": "2026-03-08T10:00:00Z", "last_message_text": "Hello", @@ -662,6 +664,26 @@ Body: 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` Auth required. diff --git a/web/src/api/chats.ts b/web/src/api/chats.ts index a247fd7..98ea85e 100644 --- a/web/src/api/chats.ts +++ b/web/src/api/chats.ts @@ -302,6 +302,17 @@ export async function updateChatTitle(chatId: number, title: string): Promise { + const { data } = await http.patch(`/chats/${chatId}/profile`, payload); + return data; +} + export async function addChatMember(chatId: number, userId: number): Promise { const { data } = await http.post(`/chats/${chatId}/members`, { user_id: userId }); return data; diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index fb1b17c..eaf6a53 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -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; diff --git a/web/src/components/ChatInfoPanel.tsx b/web/src/components/ChatInfoPanel.tsx index 0cb8c36..5501f5f 100644 --- a/web/src/components/ChatInfoPanel.tsx +++ b/web/src/components/ChatInfoPanel.tsx @@ -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(null); const [titleDraft, setTitleDraft] = useState(""); const [savingTitle, setSavingTitle] = useState(false); + const [chatAvatarUploading, setChatAvatarUploading] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [searchResults, setSearchResults] = useState([]); 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) { <>
- {chat.type === "private" && counterpartProfile?.avatar_url ? ( - avatar + {(chat.type === "private" ? (counterpartProfile?.avatar_url || chat.counterpart_avatar_url) : chat.avatar_url) ? ( + avatar ) : (
{initialsFromName(chat.display_title || chat.title || counterpartProfile?.name || chat.type)} @@ -273,6 +300,44 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {

{muted ? "Chat notifications are muted." : "Chat notifications are enabled."}

{isGroupLike && canEditTitle ? ( <> +

Avatar

+
+ + {chat.avatar_url ? ( + + ) : null} +

Title

- ) : null} +

My Profile

+
+ {me?.avatar_url ? ( + my avatar + ) : ( +
+ {(me?.name || me?.username || "Me").slice(0, 2)}
-
- setProfileName(e.target.value)} /> - setProfileUsername(e.target.value.replace("@", ""))} /> - setProfileBio(e.target.value)} /> - setProfileAvatarUrl(e.target.value)} /> - + )} +

{me?.name || "No name"}

+

@{me?.username || "username"}

+

{me?.email || ""}

+ {me?.bio ?

{me.bio}

: null} +

Profile editing is available in Settings

- {profileError ?

{profileError}

: null}