Private
Public Access
Add the ability to hide a DM conversation (#52 follow-up)
Neither participant could get rid of a DM at all -- Leave/Delete were both deliberately hidden for DMs during the initial build to sidestep an edge case (removing a membership would break find_or_create_dm's exactly-two-members assumption), but that left no way out whatsoever. RoomMembership.hidden_at is a per-viewer display flag, not a membership deletion: hiding a DM only sets it on your own membership row, so it disappears from just your sidebar without touching the other participant's copy or any messages. It's automatically cleared (reappearing) in two cases: a new message arrives in the room (broadcast_new_message), or find_or_create_dm resolves back to the same room because either person re-opens it from the People list -- both count as the conversation being active again. Also fixes two now-flaky tests (test_message_edit, test_reactions): broadcast_new_message doing more work before returning shifted timing enough to expose a pre-existing race where a per-user-channel frame (desktop_notification/unread_update) could legitimately arrive before a connection's own "joined" ack. Broadened their existing _recv() noise-filtering helper (already used for member_updated) to cover those types too, and used it at the two call sites that were reading raw receive_json() instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 ###
|
||||||
@@ -33,6 +33,15 @@ class RoomMembership(Base):
|
|||||||
last_read_at: Mapped[datetime] = mapped_column(
|
last_read_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
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")
|
room = relationship("Room", back_populates="memberships")
|
||||||
user = relationship("User")
|
user = relationship("User")
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ from app.services.room_service import (
|
|||||||
DuplicateRoomError,
|
DuplicateRoomError,
|
||||||
InsufficientRoleError,
|
InsufficientRoleError,
|
||||||
MembershipNotFoundError,
|
MembershipNotFoundError,
|
||||||
|
NotADmError,
|
||||||
OwnerMustTransferError,
|
OwnerMustTransferError,
|
||||||
RoomIsPrivateError,
|
RoomIsPrivateError,
|
||||||
RoomNotFoundError,
|
RoomNotFoundError,
|
||||||
@@ -63,6 +64,7 @@ from app.services.room_service import (
|
|||||||
delete_room,
|
delete_room,
|
||||||
find_or_create_dm,
|
find_or_create_dm,
|
||||||
get_room,
|
get_room,
|
||||||
|
hide_dm,
|
||||||
join_room,
|
join_room,
|
||||||
leave_room,
|
leave_room,
|
||||||
list_member_rooms,
|
list_member_rooms,
|
||||||
@@ -260,6 +262,24 @@ async def leave_room_endpoint(
|
|||||||
raise HTTPException(status_code=404, detail="Not a member of this room")
|
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)
|
@router.post("/{room_id}/read", status_code=204)
|
||||||
async def mark_room_read_endpoint(
|
async def mark_room_read_endpoint(
|
||||||
room_id: uuid.UUID,
|
room_id: uuid.UUID,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models import Message, MessageFile, MessageMention, Room, RoomMembership, User
|
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."""
|
trigger identical fan-out/push/event behavior."""
|
||||||
payload = await _message_payload(db, message, sender.username)
|
payload = await _message_payload(db, message, sender.username)
|
||||||
await broadcaster.publish(room_id, payload)
|
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 _notify_offline_members(db, broadcaster, presence, room_id, sender, message)
|
||||||
await dispatch_event(db, "message.created", room_id, payload)
|
await dispatch_event(db, "message.created", room_id, payload)
|
||||||
_maybe_fetch_link_preview(broadcaster, room_id, message)
|
_maybe_fetch_link_preview(broadcaster, room_id, message)
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ class CannotModifyDmError(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class NotADmError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def dm_room_name(user_a_id: uuid.UUID, user_b_id: uuid.UUID) -> str:
|
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
|
"""Deterministic, internal-only name for the DM room between these two
|
||||||
users -- same canonical string regardless of argument order, so
|
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]}"
|
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:
|
async def create_room(db: AsyncSession, owner_id: uuid.UUID, data: RoomCreate) -> Room:
|
||||||
room = Room(
|
room = Room(
|
||||||
name=data.name,
|
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))
|
result = await db.execute(select(Room).where(Room.name == name))
|
||||||
room = result.scalar_one_or_none()
|
room = result.scalar_one_or_none()
|
||||||
if room is not None:
|
if room is not None:
|
||||||
|
await _unhide(db, room.id, user_id)
|
||||||
return room
|
return room
|
||||||
|
|
||||||
# is_private=True is belt-and-suspenders here -- list_open_rooms also
|
# 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.
|
# race is the room we actually want.
|
||||||
await db.rollback()
|
await db.rollback()
|
||||||
result = await db.execute(select(Room).where(Room.name == name))
|
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=user_id, role=RoomRole.member))
|
||||||
db.add(RoomMembership(room_id=room.id, user_id=other_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
|
Room, RoomMembership.role, RoomMembership.last_read_at, last_message_at, has_unread_mention
|
||||||
)
|
)
|
||||||
.join(RoomMembership, RoomMembership.room_id == Room.id)
|
.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
|
# A secondary key on the primary key -- without it, Postgres has no
|
||||||
# obligation to return two same-instant rooms (a plausible tie:
|
# obligation to return two same-instant rooms (a plausible tie:
|
||||||
# bulk-created/migrated rooms, or just two created in quick
|
# bulk-created/migrated rooms, or just two created in quick
|
||||||
|
|||||||
@@ -3,10 +3,32 @@ import uuid
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.models import Room, RoomMembership
|
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 app.services.room_service import dm_room_name
|
||||||
from tests.conftest import login_as, register_and_login
|
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):
|
async def test_start_dm_creates_private_room_with_both_members(client, db_session):
|
||||||
alice = await register_and_login(client, db_session, username="alice")
|
alice = await register_and_login(client, db_session, username="alice")
|
||||||
await client.post("/api/auth/logout")
|
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")
|
await login_as(client, "carol")
|
||||||
resp = await client.post(f"/api/rooms/{dm['id']}/join")
|
resp = await client.post(f"/api/rooms/{dm['id']}/join")
|
||||||
assert resp.status_code == 400
|
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())
|
||||||
|
|||||||
@@ -8,13 +8,19 @@ def _unique(prefix: str) -> str:
|
|||||||
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
|
||||||
|
_NOISE_TYPES = {"member_updated", "desktop_notification", "unread_update"}
|
||||||
|
|
||||||
|
|
||||||
def _recv(ws) -> dict:
|
def _recv(ws) -> dict:
|
||||||
"""Reads the next frame, transparently discarding member_updated
|
"""Reads the next frame, transparently discarding presence/offline-
|
||||||
presence-change broadcasts -- another connection in the same room going
|
notify noise -- another connection in the same room going online/
|
||||||
online/offline is real, expected noise these tests aren't about."""
|
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:
|
while True:
|
||||||
msg = ws.receive_json()
|
msg = ws.receive_json()
|
||||||
if msg.get("type") != "member_updated":
|
if msg.get("type") not in _NOISE_TYPES:
|
||||||
return msg
|
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:
|
with ws_client.websocket_connect("/ws/chat") as bob_ws:
|
||||||
bob_ws.send_json({"type": "join", "room_id": room["id"]})
|
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(
|
bob_ws.send_json(
|
||||||
{
|
{
|
||||||
"type": "edit",
|
"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:
|
with instance2.websocket_connect("/ws/chat") as bob_ws:
|
||||||
bob_ws.send_json({"type": "join", "room_id": room["id"]})
|
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:
|
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
||||||
alice_ws.send_json({"type": "join", "room_id": room["id"]})
|
alice_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||||
|
|||||||
@@ -9,13 +9,19 @@ def _unique(prefix: str) -> str:
|
|||||||
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
|
||||||
|
_NOISE_TYPES = {"member_updated", "desktop_notification", "unread_update"}
|
||||||
|
|
||||||
|
|
||||||
def _recv(ws) -> dict:
|
def _recv(ws) -> dict:
|
||||||
"""Reads the next frame, transparently discarding member_updated
|
"""Reads the next frame, transparently discarding presence/offline-
|
||||||
presence-change broadcasts -- another connection in the same room going
|
notify noise -- another connection in the same room going online/
|
||||||
online/offline is real, expected noise these tests aren't about."""
|
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:
|
while True:
|
||||||
msg = ws.receive_json()
|
msg = ws.receive_json()
|
||||||
if msg.get("type") != "member_updated":
|
if msg.get("type") not in _NOISE_TYPES:
|
||||||
return msg
|
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:
|
with ws_client.websocket_connect("/ws/chat") as bob_ws:
|
||||||
bob_ws.send_json({"type": "join", "room_id": room["id"]})
|
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(
|
ws_client.post(
|
||||||
"/api/auth/login",
|
"/api/auth/login",
|
||||||
|
|||||||
@@ -60,6 +60,14 @@ export function leaveRoom(roomId: string): Promise<void> {
|
|||||||
return apiFetch<void>(`/api/rooms/${roomId}/leave`, { method: 'POST' })
|
return apiFetch<void>(`/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<void> {
|
||||||
|
return apiFetch<void>(`/api/rooms/${roomId}/hide`, { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
export function listRoomMembers(roomId: string): Promise<RoomMember[]> {
|
export function listRoomMembers(roomId: string): Promise<RoomMember[]> {
|
||||||
return apiFetch<RoomMember[]>(`/api/rooms/${roomId}/members`)
|
return apiFetch<RoomMember[]>(`/api/rooms/${roomId}/members`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
deleteRoom,
|
deleteRoom,
|
||||||
getRoomFileUrl,
|
getRoomFileUrl,
|
||||||
getRoomImageUrl,
|
getRoomImageUrl,
|
||||||
|
hideDm,
|
||||||
leaveRoom,
|
leaveRoom,
|
||||||
listRoomAttachments,
|
listRoomAttachments,
|
||||||
removeMember,
|
removeMember,
|
||||||
@@ -267,6 +268,15 @@ export function RoomInfoPanel({
|
|||||||
onLeft()
|
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) {
|
async function handleSaveSettings(e: FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setRoomError(null)
|
setRoomError(null)
|
||||||
@@ -596,7 +606,11 @@ export function RoomInfoPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!room.is_dm && (
|
{room.is_dm ? (
|
||||||
|
<button type="button" className="room-info-leave" onClick={handleHideDm}>
|
||||||
|
Hide conversation
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="room-info-leave"
|
className="room-info-leave"
|
||||||
|
|||||||
Reference in New Issue
Block a user