Make archiving a room actually affect existing members (#57)

is_archived was previously only exposed on the admin-only AdminRoom
schema and checked in one place (excluding a room from Browse rooms) --
for anyone already a member it was a complete no-op: still in their
sidebar, still fully postable, no indication anywhere it was archived.

Expose is_archived on the regular RoomRead/MyRoomItem schemas, drop
archived rooms from the sidebar list (while keeping them directly
reachable via URL so history stays readable), and reject new messages
in one -- both the WS "message" handler and incoming webhooks -- with a
clear "archived and read-only" response instead of silently no-op'ing
or a confusing membership error.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 21:24:23 -06:00
co-authored by Claude Sonnet 5
parent 2fd055f7d6
commit 072405eb2d
10 changed files with 197 additions and 17 deletions
+1
View File
@@ -272,6 +272,7 @@ export function ChatPane({
members={members}
rooms={rooms}
disabled={!connected}
archived={room.is_archived}
onSend={send}
/>
</section>
+31 -14
View File
@@ -33,6 +33,12 @@ interface ComposerProps {
// while typing and what actually renders as a link later agree.
rooms: MyRoomItem[]
disabled?: boolean
// #57: an archived room is permanently read-only, not just transiently
// disconnected -- kept as its own prop rather than folded into `disabled`
// so the placeholder/status text can say why, instead of the connecting/
// offline copy below (which would be actively misleading here: waiting
// won't ever re-enable this).
archived?: boolean
onSend: (content: string, imageId?: string, fileId?: string) => void
}
@@ -106,7 +112,12 @@ function AttachMenu({ onPickPhoto, onPickFile, onClose }: AttachMenuProps) {
)
}
export function Composer({ roomId, roomName, isDm, members, rooms, disabled, onSend }: ComposerProps) {
export function Composer({ roomId, roomName, isDm, members, rooms, disabled, archived, onSend }: ComposerProps) {
// Every gate below (attach/emoji buttons, textarea, send button) reads
// this instead of the raw `disabled` prop -- an archived room must be
// just as unwritable as a disconnected one, it just says why differently
// (see the placeholder/status text further down).
const isDisabled = disabled || archived
const [value, setValue] = useState('')
const [pendingImage, setPendingImage] = useState<{ id: string; previewUrl: string } | null>(null)
const [pendingFile, setPendingFile] = useState<{ id: string; filename: string; size: number } | null>(
@@ -379,7 +390,7 @@ export function Composer({ roomId, roomName, isDm, members, rooms, disabled, onS
function handleDragEnter(e: DragEvent<HTMLDivElement>) {
e.preventDefault()
if (disabled) return
if (isDisabled) return
dragCounterRef.current++
setDragActive(true)
}
@@ -401,7 +412,7 @@ export function Composer({ roomId, roomName, isDm, members, rooms, disabled, onS
e.preventDefault()
dragCounterRef.current = 0
setDragActive(false)
if (disabled) return
if (isDisabled) return
// Only the first dropped file, matching the existing single-attachment-
// per-message limit (the button-triggered file input isn't `multiple`
// either).
@@ -503,7 +514,7 @@ export function Composer({ roomId, roomName, isDm, members, rooms, disabled, onS
type="button"
className="composer-attach"
onClick={() => setAttachMenuOpen((v) => !v)}
disabled={disabled || uploading}
disabled={isDisabled || uploading}
aria-label="Attach a photo or file"
aria-expanded={attachMenuOpen}
>
@@ -542,7 +553,7 @@ export function Composer({ roomId, roomName, isDm, members, rooms, disabled, onS
type="button"
className="composer-emoji-trigger"
onClick={() => setEmojiPickerOpen((v) => !v)}
disabled={disabled}
disabled={isDisabled}
aria-label="Insert an emoji"
>
🙂
@@ -561,7 +572,7 @@ export function Composer({ roomId, roomName, isDm, members, rooms, disabled, onS
ref={textareaRef}
rows={1}
value={value}
disabled={disabled}
disabled={isDisabled}
onChange={(e) => {
setValue(e.target.value)
autoGrow()
@@ -576,11 +587,13 @@ export function Composer({ roomId, roomName, isDm, members, rooms, disabled, onS
onSelect={handleSelectionChange}
onKeyDown={handleKeyDown}
placeholder={
disabled
? online
? 'Connecting…'
: "You're offline"
: `Message ${isDm ? roomName : `#${roomName}`}`
archived
? 'This room has been archived'
: disabled
? online
? 'Connecting…'
: "You're offline"
: `Message ${isDm ? roomName : `#${roomName}`}`
}
spellCheck
/>
@@ -613,7 +626,7 @@ export function Composer({ roomId, roomName, isDm, members, rooms, disabled, onS
type="button"
className="composer-send"
onClick={handleSend}
disabled={disabled || (!value.trim() && !pendingImage && !pendingFile)}
disabled={isDisabled || (!value.trim() && !pendingImage && !pendingFile)}
aria-label="Send message"
>
<svg width="15" height="15" viewBox="0 0 20 20" aria-hidden="true">
@@ -621,8 +634,12 @@ export function Composer({ roomId, roomName, isDm, members, rooms, disabled, onS
</svg>
</button>
</div>
{disabled && (
<div className="composer-status">{online ? 'Connecting…' : "You're offline — messages can't be sent right now"}</div>
{archived ? (
<div className="composer-status">This room has been archived and is read-only</div>
) : (
disabled && (
<div className="composer-status">{online ? 'Connecting…' : "You're offline — messages can't be sent right now"}</div>
)
)}
</div>
)
+5 -1
View File
@@ -37,7 +37,11 @@ export function Sidebar({
}
return room.name.toLowerCase().includes(query)
}
const filtered = rooms.filter(matchesQuery)
// #57: archived rooms keep flowing through in `rooms` (so a member who
// still has one open via a direct link resolves fine -- see ChatPane),
// but they're a dead end going forward, so they don't belong in the list
// you'd browse/search from.
const filtered = rooms.filter((r) => !r.is_archived).filter(matchesQuery)
const directMessages = filtered.filter((r) => r.is_dm)
const regularRooms = filtered.filter((r) => !r.is_dm)
+5
View File
@@ -57,6 +57,11 @@ export interface Room {
description: string | null
is_private: boolean
is_dm: boolean
// #57: exposed here (not just the admin-only AdminRoom) so a member who
// still has this room -- direct link, or before their sidebar list next
// refreshes -- can be shown it's read-only instead of just silently
// failing to send.
is_archived: boolean
owner_id: string
created_at: string
}