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:
2026-08-19 16:58:02 -06:00
co-authored by Claude Sonnet 5
parent 1d322d9516
commit 766883c992
10 changed files with 247 additions and 15 deletions
+8
View File
@@ -60,6 +60,14 @@ export function leaveRoom(roomId: string): Promise<void> {
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[]> {
return apiFetch<RoomMember[]>(`/api/rooms/${roomId}/members`)
}
+15 -1
View File
@@ -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({
</div>
)}
{!room.is_dm && (
{room.is_dm ? (
<button type="button" className="room-info-leave" onClick={handleHideDm}>
Hide conversation
</button>
) : (
<button
type="button"
className="room-info-leave"