feat(notifications): per-chat mute settings
Some checks failed
CI / test (push) Failing after 18s

- add chat_notification_settings table and migration
- add chat notifications API (get/update muted)
- skip message notifications for muted recipients
- add mute/unmute control in chat info panel
This commit is contained in:
2026-03-08 02:17:09 +03:00
parent eddd4bda0b
commit ea8a50ee05
9 changed files with 238 additions and 3 deletions

View File

@@ -0,0 +1,52 @@
"""add chat notification settings
Revision ID: 0009_chat_notification_settings
Revises: 0008_user_last_seen_presence
Create Date: 2026-03-08 14:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0009_chat_notification_settings"
down_revision: Union[str, Sequence[str], None] = "0008_user_last_seen_presence"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"chat_notification_settings",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("chat_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("muted", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(["chat_id"], ["chats.id"], name=op.f("fk_chat_notification_settings_chat_id_chats"), ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], name=op.f("fk_chat_notification_settings_user_id_users"), ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_chat_notification_settings")),
sa.UniqueConstraint("chat_id", "user_id", name="uq_chat_notification_settings_chat_user"),
)
op.create_index(op.f("ix_chat_notification_settings_id"), "chat_notification_settings", ["id"], unique=False)
op.create_index(
op.f("ix_chat_notification_settings_chat_id"),
"chat_notification_settings",
["chat_id"],
unique=False,
)
op.create_index(
op.f("ix_chat_notification_settings_user_id"),
"chat_notification_settings",
["user_id"],
unique=False,
)
def downgrade() -> None:
op.drop_index(op.f("ix_chat_notification_settings_user_id"), table_name="chat_notification_settings")
op.drop_index(op.f("ix_chat_notification_settings_chat_id"), table_name="chat_notification_settings")
op.drop_index(op.f("ix_chat_notification_settings_id"), table_name="chat_notification_settings")
op.drop_table("chat_notification_settings")

View File

@@ -57,3 +57,19 @@ class ChatMember(Base):
chat: Mapped["Chat"] = relationship(back_populates="members") chat: Mapped["Chat"] = relationship(back_populates="members")
user: Mapped["User"] = relationship(back_populates="memberships") user: Mapped["User"] = relationship(back_populates="memberships")
class ChatNotificationSetting(Base):
__tablename__ = "chat_notification_settings"
__table_args__ = (UniqueConstraint("chat_id", "user_id", name="uq_chat_notification_settings_chat_user"),)
id: Mapped[int] = mapped_column(primary_key=True, index=True)
chat_id: Mapped[int] = mapped_column(ForeignKey("chats.id", ondelete="CASCADE"), nullable=False, index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
muted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)

View File

@@ -2,7 +2,7 @@ from sqlalchemy import Select, String, func, or_, select
from sqlalchemy.orm import aliased from sqlalchemy.orm import aliased
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.chats.models import Chat, ChatMember, ChatMemberRole, ChatType from app.chats.models import Chat, ChatMember, ChatMemberRole, ChatNotificationSetting, ChatType
from app.messages.models import Message, MessageHidden, MessageReceipt from app.messages.models import Message, MessageHidden, MessageReceipt
@@ -195,3 +195,38 @@ async def get_unread_count_for_chat(db: AsyncSession, *, chat_id: int, user_id:
) )
result = await db.execute(stmt) result = await db.execute(stmt)
return int(result.scalar_one() or 0) return int(result.scalar_one() or 0)
async def get_chat_notification_setting(
db: AsyncSession, *, chat_id: int, user_id: int
) -> ChatNotificationSetting | None:
result = await db.execute(
select(ChatNotificationSetting).where(
ChatNotificationSetting.chat_id == chat_id,
ChatNotificationSetting.user_id == user_id,
)
)
return result.scalar_one_or_none()
async def upsert_chat_notification_setting(
db: AsyncSession,
*,
chat_id: int,
user_id: int,
muted: bool,
) -> ChatNotificationSetting:
setting = await get_chat_notification_setting(db, chat_id=chat_id, user_id=user_id)
if setting:
setting.muted = muted
await db.flush()
return setting
setting = ChatNotificationSetting(chat_id=chat_id, user_id=user_id, muted=muted)
db.add(setting)
await db.flush()
return setting
async def is_chat_muted_for_user(db: AsyncSession, *, chat_id: int, user_id: int) -> bool:
setting = await get_chat_notification_setting(db, chat_id=chat_id, user_id=user_id)
return bool(setting and setting.muted)

View File

@@ -10,6 +10,8 @@ from app.chats.schemas import (
ChatMemberAddRequest, ChatMemberAddRequest,
ChatMemberRead, ChatMemberRead,
ChatMemberRoleUpdateRequest, ChatMemberRoleUpdateRequest,
ChatNotificationSettingsRead,
ChatNotificationSettingsUpdate,
ChatPinMessageRequest, ChatPinMessageRequest,
ChatRead, ChatRead,
ChatTitleUpdateRequest, ChatTitleUpdateRequest,
@@ -22,6 +24,7 @@ from app.chats.service import (
discover_public_chats_for_user, discover_public_chats_for_user,
ensure_saved_messages_chat, ensure_saved_messages_chat,
get_chat_for_user, get_chat_for_user,
get_chat_notification_settings_for_user,
get_chats_for_user, get_chats_for_user,
join_public_chat_for_user, join_public_chat_for_user,
leave_chat_for_user, leave_chat_for_user,
@@ -30,6 +33,7 @@ from app.chats.service import (
serialize_chat_for_user, serialize_chat_for_user,
serialize_chats_for_user, serialize_chats_for_user,
update_chat_member_role_for_user, update_chat_member_role_for_user,
update_chat_notification_settings_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
@@ -200,3 +204,27 @@ async def pin_chat_message(
) -> ChatRead: ) -> ChatRead:
chat = await pin_chat_message_for_user(db, chat_id=chat_id, user_id=current_user.id, payload=payload) chat = await pin_chat_message_for_user(db, chat_id=chat_id, user_id=current_user.id, payload=payload)
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.get("/{chat_id}/notifications", response_model=ChatNotificationSettingsRead)
async def get_chat_notifications(
chat_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> ChatNotificationSettingsRead:
return await get_chat_notification_settings_for_user(db, chat_id=chat_id, user_id=current_user.id)
@router.put("/{chat_id}/notifications", response_model=ChatNotificationSettingsRead)
async def update_chat_notifications(
chat_id: int,
payload: ChatNotificationSettingsUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> ChatNotificationSettingsRead:
return await update_chat_notification_settings_for_user(
db,
chat_id=chat_id,
user_id=current_user.id,
payload=payload,
)

View File

@@ -74,3 +74,13 @@ class ChatDeleteRequest(BaseModel):
class ChatDiscoverRead(ChatRead): class ChatDiscoverRead(ChatRead):
is_member: bool is_member: bool
class ChatNotificationSettingsRead(BaseModel):
chat_id: int
user_id: int
muted: bool
class ChatNotificationSettingsUpdate(BaseModel):
muted: bool

View File

@@ -4,7 +4,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.chats import repository from app.chats import repository
from app.chats.models import Chat, ChatMember, ChatMemberRole, ChatType from app.chats.models import Chat, ChatMember, ChatMemberRole, ChatType
from app.chats.schemas import ChatCreateRequest, ChatDeleteRequest, ChatDiscoverRead, ChatPinMessageRequest, ChatRead, ChatTitleUpdateRequest from app.chats.schemas import (
ChatCreateRequest,
ChatDeleteRequest,
ChatDiscoverRead,
ChatNotificationSettingsRead,
ChatNotificationSettingsUpdate,
ChatPinMessageRequest,
ChatRead,
ChatTitleUpdateRequest,
)
from app.messages.repository import ( from app.messages.repository import (
delete_messages_in_chat, delete_messages_in_chat,
get_hidden_message, get_hidden_message,
@@ -420,3 +429,41 @@ async def clear_chat_for_user(db: AsyncSession, *, chat_id: int, user_id: int) -
continue continue
await hide_message_for_user(db, message_id=message_id, user_id=user_id) await hide_message_for_user(db, message_id=message_id, user_id=user_id)
await db.commit() await db.commit()
async def get_chat_notification_settings_for_user(
db: AsyncSession,
*,
chat_id: int,
user_id: int,
) -> ChatNotificationSettingsRead:
await ensure_chat_membership(db, chat_id=chat_id, user_id=user_id)
setting = await repository.get_chat_notification_setting(db, chat_id=chat_id, user_id=user_id)
return ChatNotificationSettingsRead(
chat_id=chat_id,
user_id=user_id,
muted=bool(setting and setting.muted),
)
async def update_chat_notification_settings_for_user(
db: AsyncSession,
*,
chat_id: int,
user_id: int,
payload: ChatNotificationSettingsUpdate,
) -> ChatNotificationSettingsRead:
await ensure_chat_membership(db, chat_id=chat_id, user_id=user_id)
setting = await repository.upsert_chat_notification_setting(
db,
chat_id=chat_id,
user_id=user_id,
muted=payload.muted,
)
await db.commit()
await db.refresh(setting)
return ChatNotificationSettingsRead(
chat_id=chat_id,
user_id=user_id,
muted=setting.muted,
)

View File

@@ -3,7 +3,7 @@ import re
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.chats.repository import list_chat_members from app.chats.repository import is_chat_muted_for_user, list_chat_members
from app.messages.models import Message from app.messages.models import Message
from app.notifications.repository import create_notification_log, list_user_notifications from app.notifications.repository import create_notification_log, list_user_notifications
from app.notifications.schemas import NotificationRead, NotificationRequest from app.notifications.schemas import NotificationRead, NotificationRequest
@@ -44,6 +44,8 @@ async def dispatch_message_notifications(db: AsyncSession, message: Message) ->
sender_name = sender_users[0].username if sender_users else "Someone" sender_name = sender_users[0].username if sender_users else "Someone"
for recipient in users: for recipient in users:
if await is_chat_muted_for_user(db, chat_id=message.chat_id, user_id=recipient.id):
continue
base_payload = { base_payload = {
"chat_id": message.chat_id, "chat_id": message.chat_id,
"message_id": message.id, "message_id": message.id,

View File

@@ -2,6 +2,12 @@ import { http } from "./http";
import type { Chat, ChatDetail, ChatMember, ChatMemberRole, ChatType, DiscoverChat, Message, MessageType } from "../chat/types"; import type { Chat, ChatDetail, ChatMember, ChatMemberRole, ChatType, DiscoverChat, Message, MessageType } from "../chat/types";
import axios from "axios"; import axios from "axios";
export interface ChatNotificationSettings {
chat_id: number;
user_id: number;
muted: boolean;
}
export async function getChats(query?: string): Promise<Chat[]> { export async function getChats(query?: string): Promise<Chat[]> {
const { data } = await http.get<Chat[]>("/chats", { const { data } = await http.get<Chat[]>("/chats", {
params: query?.trim() ? { query: query.trim() } : undefined params: query?.trim() ? { query: query.trim() } : undefined
@@ -207,3 +213,13 @@ export async function removeChatMember(chatId: number, userId: number): Promise<
export async function leaveChat(chatId: number): Promise<void> { export async function leaveChat(chatId: number): Promise<void> {
await http.post(`/chats/${chatId}/leave`); await http.post(`/chats/${chatId}/leave`);
} }
export async function getChatNotificationSettings(chatId: number): Promise<ChatNotificationSettings> {
const { data } = await http.get<ChatNotificationSettings>(`/chats/${chatId}/notifications`);
return data;
}
export async function updateChatNotificationSettings(chatId: number, muted: boolean): Promise<ChatNotificationSettings> {
const { data } = await http.put<ChatNotificationSettings>(`/chats/${chatId}/notifications`, { muted });
return data;
}

View File

@@ -2,10 +2,12 @@ import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { import {
addChatMember, addChatMember,
getChatNotificationSettings,
getChatDetail, getChatDetail,
leaveChat, leaveChat,
listChatMembers, listChatMembers,
removeChatMember, removeChatMember,
updateChatNotificationSettings,
updateChatMemberRole, updateChatMemberRole,
updateChatTitle updateChatTitle
} from "../api/chats"; } from "../api/chats";
@@ -33,6 +35,8 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
const [savingTitle, setSavingTitle] = useState(false); const [savingTitle, setSavingTitle] = 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 [savingMute, setSavingMute] = useState(false);
const myRole = useMemo(() => members.find((m) => m.user_id === me?.id)?.role, [members, me?.id]); const myRole = useMemo(() => members.find((m) => m.user_id === me?.id)?.role, [members, me?.id]);
const isGroupLike = chat?.type === "group" || chat?.type === "channel"; const isGroupLike = chat?.type === "group" || chat?.type === "channel";
@@ -65,6 +69,10 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
if (cancelled) return; if (cancelled) return;
setChat(detail); setChat(detail);
setTitleDraft(detail.title ?? ""); setTitleDraft(detail.title ?? "");
const notificationSettings = await getChatNotificationSettings(chatId);
if (!cancelled) {
setMuted(notificationSettings.muted);
}
await refreshMembers(chatId); await refreshMembers(chatId);
} catch { } catch {
if (!cancelled) setError("Failed to load chat info"); if (!cancelled) setError("Failed to load chat info");
@@ -107,6 +115,27 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
{chat ? ( {chat ? (
<> <>
<div className="mb-3 rounded-lg border border-slate-700/70 bg-slate-800/60 p-3"> <div className="mb-3 rounded-lg border border-slate-700/70 bg-slate-800/60 p-3">
<div className="mb-2 flex items-center justify-between">
<p className="text-xs text-slate-400">Notifications</p>
<button
className="rounded bg-slate-700 px-2 py-1 text-xs disabled:opacity-60"
disabled={savingMute}
onClick={async () => {
setSavingMute(true);
try {
const updated = await updateChatNotificationSettings(chatId, !muted);
setMuted(updated.muted);
} catch {
setError("Failed to update notifications");
} finally {
setSavingMute(false);
}
}}
>
{muted ? "Unmute" : "Mute"}
</button>
</div>
<p className="mb-2 text-xs text-slate-300">{muted ? "Chat notifications are muted." : "Chat notifications are enabled."}</p>
<p className="text-xs text-slate-400">Type</p> <p className="text-xs text-slate-400">Type</p>
<p className="text-sm">{chat.type}</p> <p className="text-sm">{chat.type}</p>
<p className="mt-2 text-xs text-slate-400">Title</p> <p className="mt-2 text-xs text-slate-400">Title</p>