diff --git a/backend/alembic/versions/f3f255da9c96_hide_dm_conversations_per_participant.py b/backend/alembic/versions/f3f255da9c96_hide_dm_conversations_per_participant.py new file mode 100644 index 0000000..fd7f857 --- /dev/null +++ b/backend/alembic/versions/f3f255da9c96_hide_dm_conversations_per_participant.py @@ -0,0 +1,32 @@ +"""hide DM conversations per-participant + +Revision ID: f3f255da9c96 +Revises: 9ca717f837c2 +Create Date: 2026-08-19 16:36:28.332581 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'f3f255da9c96' +down_revision: Union[str, Sequence[str], None] = '9ca717f837c2' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('room_memberships', sa.Column('hidden_at', sa.DateTime(timezone=True), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('room_memberships', 'hidden_at') + # ### end Alembic commands ### diff --git a/backend/app/models/membership.py b/backend/app/models/membership.py index 90fbcfd..83ce024 100644 --- a/backend/app/models/membership.py +++ b/backend/app/models/membership.py @@ -33,6 +33,15 @@ class RoomMembership(Base): last_read_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) + # #52 follow-up: lets a DM be hidden from one participant's own sidebar + # without touching the other participant's copy or deleting anything -- + # a DM has no sensible "leave" (it would corrupt find_or_create_dm's + # exactly-two-members assumption), so this is deliberately a per-viewer + # display flag on their own membership row, not a membership deletion. + # Cleared automatically (see message_events.py) whenever a new message + # arrives in the room, or when find_or_create_dm resolves back to it -- + # both count as the conversation being active again. + hidden_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) room = relationship("Room", back_populates="memberships") user = relationship("User") diff --git a/backend/app/routers/rooms.py b/backend/app/routers/rooms.py index 96b3f53..e8297d6 100644 --- a/backend/app/routers/rooms.py +++ b/backend/app/routers/rooms.py @@ -53,6 +53,7 @@ from app.services.room_service import ( DuplicateRoomError, InsufficientRoleError, MembershipNotFoundError, + NotADmError, OwnerMustTransferError, RoomIsPrivateError, RoomNotFoundError, @@ -63,6 +64,7 @@ from app.services.room_service import ( delete_room, find_or_create_dm, get_room, + hide_dm, join_room, leave_room, list_member_rooms, @@ -260,6 +262,24 @@ async def leave_room_endpoint( raise HTTPException(status_code=404, detail="Not a member of this room") +@router.post("/{room_id}/hide", status_code=204) +async def hide_dm_endpoint( + room_id: uuid.UUID, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + await require_room_member(room_id, current_user, db) + try: + room = await get_room(db, room_id) + await hide_dm(db, room, current_user.id) + except RoomNotFoundError: + raise HTTPException(status_code=404, detail="Room not found") + except NotADmError: + raise HTTPException(status_code=400, detail="Only DMs can be hidden") + except MembershipNotFoundError: + raise HTTPException(status_code=404, detail="Not a member of this room") + + @router.post("/{room_id}/read", status_code=204) async def mark_room_read_endpoint( room_id: uuid.UUID, diff --git a/backend/app/services/message_events.py b/backend/app/services/message_events.py index bb27287..ac026c7 100644 --- a/backend/app/services/message_events.py +++ b/backend/app/services/message_events.py @@ -1,7 +1,7 @@ import asyncio import uuid -from sqlalchemy import select +from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from app.models import Message, MessageFile, MessageMention, Room, RoomMembership, User @@ -140,6 +140,16 @@ async def broadcast_new_message( trigger identical fan-out/push/event behavior.""" payload = await _message_payload(db, message, sender.username) await broadcaster.publish(room_id, payload) + # A no-op for a regular room (hidden_at is only ever set on a DM's + # membership row -- see RoomMembership.hidden_at) -- new activity + # un-hiding a DM someone closed matches find_or_create_dm's own + # un-hide-on-reopen behavior. + await db.execute( + update(RoomMembership) + .where(RoomMembership.room_id == room_id, RoomMembership.hidden_at.is_not(None)) + .values(hidden_at=None) + ) + await db.commit() await _notify_offline_members(db, broadcaster, presence, room_id, sender, message) await dispatch_event(db, "message.created", room_id, payload) _maybe_fetch_link_preview(broadcaster, room_id, message) diff --git a/backend/app/services/room_service.py b/backend/app/services/room_service.py index 58a1c6b..59e4367 100644 --- a/backend/app/services/room_service.py +++ b/backend/app/services/room_service.py @@ -68,6 +68,10 @@ class CannotModifyDmError(Exception): pass +class NotADmError(Exception): + pass + + def dm_room_name(user_a_id: uuid.UUID, user_b_id: uuid.UUID) -> str: """Deterministic, internal-only name for the DM room between these two users -- same canonical string regardless of argument order, so @@ -79,6 +83,27 @@ def dm_room_name(user_a_id: uuid.UUID, user_b_id: uuid.UUID) -> str: return f"dm:{ids[0]}:{ids[1]}" +async def _unhide(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID) -> None: + membership = ( + await db.execute( + select(RoomMembership).where( + RoomMembership.room_id == room_id, RoomMembership.user_id == user_id + ) + ) + ).scalar_one_or_none() + if membership is not None and membership.hidden_at is not None: + membership.hidden_at = None + await db.commit() + + +async def hide_dm(db: AsyncSession, room: Room, user_id: uuid.UUID) -> None: + if not room.is_dm: + raise NotADmError() + membership = await _get_membership(db, room.id, user_id) + membership.hidden_at = func.now() + await db.commit() + + async def create_room(db: AsyncSession, owner_id: uuid.UUID, data: RoomCreate) -> Room: room = Room( name=data.name, @@ -110,6 +135,7 @@ async def find_or_create_dm(db: AsyncSession, user_id: uuid.UUID, other_user_id: result = await db.execute(select(Room).where(Room.name == name)) room = result.scalar_one_or_none() if room is not None: + await _unhide(db, room.id, user_id) return room # is_private=True is belt-and-suspenders here -- list_open_rooms also @@ -130,7 +156,9 @@ async def find_or_create_dm(db: AsyncSession, user_id: uuid.UUID, other_user_id: # race is the room we actually want. await db.rollback() result = await db.execute(select(Room).where(Room.name == name)) - return result.scalar_one() + room = result.scalar_one() + await _unhide(db, room.id, user_id) + return room db.add(RoomMembership(room_id=room.id, user_id=user_id, role=RoomRole.member)) db.add(RoomMembership(room_id=room.id, user_id=other_user_id, role=RoomRole.member)) @@ -180,7 +208,7 @@ async def list_member_rooms( Room, RoomMembership.role, RoomMembership.last_read_at, last_message_at, has_unread_mention ) .join(RoomMembership, RoomMembership.room_id == Room.id) - .where(RoomMembership.user_id == user_id) + .where(RoomMembership.user_id == user_id, RoomMembership.hidden_at.is_(None)) # A secondary key on the primary key -- without it, Postgres has no # obligation to return two same-instant rooms (a plausible tie: # bulk-created/migrated rooms, or just two created in quick diff --git a/backend/tests/test_direct_messages.py b/backend/tests/test_direct_messages.py index 8528dc4..ca08450 100644 --- a/backend/tests/test_direct_messages.py +++ b/backend/tests/test_direct_messages.py @@ -3,10 +3,32 @@ import uuid from sqlalchemy import select from app.models import Room, RoomMembership +from app.schemas.user import UserCreate +from app.services.auth_service import register_user from app.services.room_service import dm_room_name from tests.conftest import login_as, register_and_login +def _unique(prefix: str) -> str: + return f"{prefix}-{uuid.uuid4().hex[:8]}" + + +def _register_ws(ws_client, username: str) -> dict: + async def _seed(): + async with ws_client.session_factory() as session: + await register_user( + session, + UserCreate(username=username, email=f"{username}@example.com", password="password123"), + ) + + ws_client.portal.call(_seed) + resp = ws_client.post( + "/api/auth/login", json={"username_or_email": username, "password": "password123"} + ) + assert resp.status_code == 200, resp.text + return resp.json() + + async def test_start_dm_creates_private_room_with_both_members(client, db_session): alice = await register_and_login(client, db_session, username="alice") await client.post("/api/auth/logout") @@ -176,3 +198,80 @@ async def test_dm_rejects_add_member_and_join(client, db_session): await login_as(client, "carol") resp = await client.post(f"/api/rooms/{dm['id']}/join") assert resp.status_code == 400 + + +async def test_hide_dm_removes_it_from_mine_for_that_user_only(client, db_session): + alice = await register_and_login(client, db_session, username="alice") + await client.post("/api/auth/logout") + bob = await register_and_login(client, db_session, username="bob") + dm = (await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})).json() + + resp = await client.post(f"/api/rooms/{dm['id']}/hide") + assert resp.status_code == 204 + + mine = (await client.get("/api/rooms/mine")).json() + assert all(r["id"] != dm["id"] for r in mine) + + # Alice never hid it -- still sees it, proving this is per-viewer, not + # something that touched the room or bob's membership for everyone. + await client.post("/api/auth/logout") + await login_as(client, "alice") + mine = (await client.get("/api/rooms/mine")).json() + assert any(r["id"] == dm["id"] for r in mine) + + +async def test_hide_dm_rejects_regular_rooms(client, db_session): + await register_and_login(client, db_session, username="alice") + room = (await client.post("/api/rooms", json={"name": "general"})).json() + resp = await client.post(f"/api/rooms/{room['id']}/hide") + assert resp.status_code == 400 + + +async def test_starting_a_dm_again_unhides_it(client, db_session): + alice = await register_and_login(client, db_session, username="alice") + await client.post("/api/auth/logout") + bob = await register_and_login(client, db_session, username="bob") + dm = (await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})).json() + + await client.post(f"/api/rooms/{dm['id']}/hide") + mine = (await client.get("/api/rooms/mine")).json() + assert all(r["id"] != dm["id"] for r in mine) + + # bob clicking alice in the People list again -- find_or_create_dm + # resolves to the same room and un-hides it for him. + resp = await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]}) + assert resp.status_code == 201 + assert resp.json()["id"] == dm["id"] + + mine = (await client.get("/api/rooms/mine")).json() + assert any(r["id"] == dm["id"] for r in mine) + + +def test_new_message_unhides_dm_for_both_participants(ws_client): + alice = _register_ws(ws_client, _unique("alice")) + bob = _register_ws(ws_client, _unique("bob")) # ws_client is now logged in as bob + dm = ws_client.post("/api/rooms/dm", json={"other_user_id": alice["id"]}).json() + + ws_client.post(f"/api/rooms/{dm['id']}/hide") + assert all(r["id"] != dm["id"] for r in ws_client.get("/api/rooms/mine").json()) + + ws_client.post( + "/api/auth/login", json={"username_or_email": alice["username"], "password": "password123"} + ) + with ws_client.websocket_connect("/ws/chat") as ws: + ws.send_json({"type": "join", "room_id": dm["id"]}) + assert ws.receive_json()["type"] == "joined" + ws.send_json({"type": "message", "room_id": dm["id"], "content": "you there?"}) + ws.receive_json() + # Sync barrier (see test_mentions.py's identical helper): the + # message ack only proves the room-level broadcast happened, not + # that broadcast_new_message's own continuation (which un-hides + # the room) has finished -- a second frame's own ack proves that. + ws.send_json({"type": "join", "room_id": dm["id"]}) + assert ws.receive_json()["type"] == "joined" + + # bob never re-opened the DM himself -- alice's message alone unhid it. + ws_client.post( + "/api/auth/login", json={"username_or_email": bob["username"], "password": "password123"} + ) + assert any(r["id"] == dm["id"] for r in ws_client.get("/api/rooms/mine").json()) diff --git a/backend/tests/test_message_edit.py b/backend/tests/test_message_edit.py index 92307aa..0bbc856 100644 --- a/backend/tests/test_message_edit.py +++ b/backend/tests/test_message_edit.py @@ -8,13 +8,19 @@ def _unique(prefix: str) -> str: return f"{prefix}-{uuid.uuid4().hex[:8]}" +_NOISE_TYPES = {"member_updated", "desktop_notification", "unread_update"} + + def _recv(ws) -> dict: - """Reads the next frame, transparently discarding member_updated - presence-change broadcasts -- another connection in the same room going - online/offline is real, expected noise these tests aren't about.""" + """Reads the next frame, transparently discarding presence/offline- + notify noise -- another connection in the same room going online/ + offline, or a per-user-channel side effect of an earlier offline + member's own message, can legitimately arrive right as a connection is + established, before its own "joined" ack. Not what these tests are + about.""" while True: msg = ws.receive_json() - if msg.get("type") != "member_updated": + if msg.get("type") not in _NOISE_TYPES: return msg @@ -90,7 +96,7 @@ def test_ws_edit_rejects_non_author(ws_client): ) with ws_client.websocket_connect("/ws/chat") as bob_ws: bob_ws.send_json({"type": "join", "room_id": room["id"]}) - assert bob_ws.receive_json()["type"] == "joined" + assert _recv(bob_ws)["type"] == "joined" bob_ws.send_json( { "type": "edit", @@ -116,7 +122,7 @@ def test_edit_fans_out_across_instances(ws_client_factory): with instance2.websocket_connect("/ws/chat") as bob_ws: bob_ws.send_json({"type": "join", "room_id": room["id"]}) - assert bob_ws.receive_json()["type"] == "joined" + assert _recv(bob_ws)["type"] == "joined" with instance1.websocket_connect("/ws/chat") as alice_ws: alice_ws.send_json({"type": "join", "room_id": room["id"]}) diff --git a/backend/tests/test_reactions.py b/backend/tests/test_reactions.py index b53679e..9b33a44 100644 --- a/backend/tests/test_reactions.py +++ b/backend/tests/test_reactions.py @@ -9,13 +9,19 @@ def _unique(prefix: str) -> str: return f"{prefix}-{uuid.uuid4().hex[:8]}" +_NOISE_TYPES = {"member_updated", "desktop_notification", "unread_update"} + + def _recv(ws) -> dict: - """Reads the next frame, transparently discarding member_updated - presence-change broadcasts -- another connection in the same room going - online/offline is real, expected noise these tests aren't about.""" + """Reads the next frame, transparently discarding presence/offline- + notify noise -- another connection in the same room going online/ + offline, or a per-user-channel side effect of an earlier offline + member's own message, can legitimately arrive right as a connection is + established, before its own "joined" ack. Not what these tests are + about.""" while True: msg = ws.receive_json() - if msg.get("type") != "member_updated": + if msg.get("type") not in _NOISE_TYPES: return msg @@ -122,7 +128,7 @@ def test_reaction_broadcasts_to_other_room_members(ws_client): ) with ws_client.websocket_connect("/ws/chat") as bob_ws: bob_ws.send_json({"type": "join", "room_id": room["id"]}) - assert bob_ws.receive_json()["type"] == "joined" + assert _recv(bob_ws)["type"] == "joined" ws_client.post( "/api/auth/login", diff --git a/frontend/src/api/rooms.ts b/frontend/src/api/rooms.ts index 3524bb0..0aa271f 100644 --- a/frontend/src/api/rooms.ts +++ b/frontend/src/api/rooms.ts @@ -60,6 +60,14 @@ export function leaveRoom(roomId: string): Promise { return apiFetch(`/api/rooms/${roomId}/leave`, { method: 'POST' }) } +// #52 follow-up: only for DMs -- hides it from this user's own sidebar +// without touching the other participant's copy. Reversible: messaging +// again (startDm, above) or a new message from the other person un-hides +// it automatically. +export function hideDm(roomId: string): Promise { + return apiFetch(`/api/rooms/${roomId}/hide`, { method: 'POST' }) +} + export function listRoomMembers(roomId: string): Promise { return apiFetch(`/api/rooms/${roomId}/members`) } diff --git a/frontend/src/components/RoomInfoPanel.tsx b/frontend/src/components/RoomInfoPanel.tsx index 7ccf3da..128aa91 100644 --- a/frontend/src/components/RoomInfoPanel.tsx +++ b/frontend/src/components/RoomInfoPanel.tsx @@ -7,6 +7,7 @@ import { deleteRoom, getRoomFileUrl, getRoomImageUrl, + hideDm, leaveRoom, listRoomAttachments, removeMember, @@ -267,6 +268,15 @@ export function RoomInfoPanel({ onLeft() } + async function handleHideDm() { + const name = room.dm_partner?.display_name || room.dm_partner?.username || 'this conversation' + if (!confirm(`Hide your conversation with ${name}? It'll come back if either of you sends a new message.`)) { + return + } + await hideDm(room.id) + onLeft() + } + async function handleSaveSettings(e: FormEvent) { e.preventDefault() setRoomError(null) @@ -596,7 +606,11 @@ export function RoomInfoPanel({ )} - {!room.is_dm && ( + {room.is_dm ? ( + + ) : (