Add direct messages (#52)

A DM is a Room with a new is_dm flag, not a separate model -- reuses
all the membership/message/WS plumbing Room already has instead of
duplicating it. The room's `name` (still required + globally unique)
is an internal, never-displayed token derived deterministically from
the two participants' sorted user IDs (dm_room_name), which makes
find-or-create a single indexed lookup and gets free race-condition
safety from the existing unique constraint -- a concurrent double-
start from both people just hits the same IntegrityError->retry-as-
lookup path create_room already established.

Both participants get the plain 'member' role (no owner/admin
distinction makes sense for a 1:1 DM), which incidentally reuses
every existing role gate to block add-member, room-settings edits,
and join-via-browse on a DM for free. update_room also gets an
explicit is_dm guard independent of that, since renaming a DM isn't
just a privacy concern -- it would silently corrupt the find-or-create
invariant. DMs are excluded from both Browse Rooms and the admin
portal's room listing (fully private, per scope).

GET /api/rooms/mine precomputes each DM's other participant (name,
avatar, presence) as dm_partner in one batched query, so the sidebar
can render a DM row without a fetch per row. Frontend: a new "Direct
Messages" sidebar section (searchable by partner name, not the
internal room name), clicking someone in the People list starts or
resumes a DM, and the chat header/composer/RoomInfoPanel all render
the partner's identity instead of a room name where it's a DM.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 16:09:21 -06:00
co-authored by Claude Sonnet 5
parent 8a461ebb13
commit f3f59ad822
17 changed files with 578 additions and 47 deletions
+38 -1
View File
@@ -17,6 +17,7 @@ from app.schemas.message import LinkPreviewInfo, MessageFileInfo, MessageRead
from app.schemas.message_file import MessageFileCreated
from app.schemas.message_image import MessageImageCreated
from app.schemas.room import (
DmPartnerInfo,
MyRoomItem,
RoomAttachmentRead,
RoomCreate,
@@ -26,6 +27,7 @@ from app.schemas.room import (
RoomMemberRoleUpdate,
RoomRead,
RoomUpdate,
StartDmRequest,
TransferOwnershipRequest,
)
from app.schemas.webhook import (
@@ -45,6 +47,8 @@ from app.services.message_service import (
from app.services.upload_settings_service import format_mb, get_upload_settings
from app.services.room_service import (
AlreadyMemberError,
CannotDmSelfError,
CannotModifyDmError,
CannotRemoveOwnerError,
DuplicateRoomError,
InsufficientRoleError,
@@ -57,6 +61,7 @@ from app.services.room_service import (
change_member_role,
create_room,
delete_room,
find_or_create_dm,
get_room,
join_room,
leave_room,
@@ -105,6 +110,20 @@ async def create_room_endpoint(
raise HTTPException(status_code=409, detail="A room with this name already exists")
@router.post("/dm", response_model=RoomRead, status_code=201)
async def start_dm_endpoint(
data: StartDmRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
return await find_or_create_dm(db, current_user.id, data.other_user_id)
except CannotDmSelfError:
raise HTTPException(status_code=400, detail="Cannot start a DM with yourself")
except TargetUserNotFoundError:
raise HTTPException(status_code=404, detail="No user with that ID")
@router.get("", response_model=list[RoomListItem])
async def list_rooms_endpoint(
current_user: User = Depends(get_current_user),
@@ -117,6 +136,7 @@ async def list_rooms_endpoint(
name=room.name,
description=room.description,
is_private=room.is_private,
is_dm=room.is_dm,
owner_id=room.owner_id,
created_at=room.created_at,
is_member=is_member,
@@ -127,23 +147,38 @@ async def list_rooms_endpoint(
@router.get("/mine", response_model=list[MyRoomItem])
async def list_my_rooms_endpoint(
request: Request,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
rooms = await list_member_rooms(db, current_user.id)
partner_ids = [partner.id for *_, partner in rooms if partner is not None]
online_ids = await request.app.state.global_presence.online_user_ids(partner_ids)
return [
MyRoomItem(
id=room.id,
name=room.name,
description=room.description,
is_private=room.is_private,
is_dm=room.is_dm,
owner_id=room.owner_id,
created_at=room.created_at,
role=role,
has_unread=has_unread,
has_mention=has_mention,
dm_partner=(
DmPartnerInfo(
user_id=partner.id,
username=partner.username,
display_name=partner.display_name,
avatar_filename=partner.avatar_filename,
status=_member_status(partner, online_ids),
)
if partner is not None
else None
),
)
for room, role, has_unread, has_mention in rooms
for room, role, has_unread, has_mention, partner in rooms
]
@@ -167,6 +202,8 @@ async def update_room_endpoint(
raise HTTPException(status_code=404, detail="Room not found")
except DuplicateRoomError:
raise HTTPException(status_code=409, detail="A room with this name already exists")
except CannotModifyDmError:
raise HTTPException(status_code=400, detail="DMs can't be edited")
@router.delete("/{room_id}", status_code=204)