Files
ds-chat/frontend/src/pages/ChatShellPage.tsx
T
ksmithandClaude Sonnet 5 3349cadea2 Fix desktop tray unread indicator: clear only on genuine room entry
The tray indicator must stay visible while a message is unread and
clear only when the user actually opens the room containing it --
never merely because the window was shown, focused, restored, or
opened from the tray.

Adds window.dsDesktop.roomOpened() (called exactly once per genuine
transition to a real room id, via useDesktopRoomBridge) and wires up
window.dsDesktop.setUnreadCount() (declared but never actually called
before this) to fire whenever the unread-room count changes. Both are
no-ops in browser builds, matching this bridge's existing optional-
chaining convention.

Frontend unread state stays authoritative -- clearRoomIndicators is
pulled out of ChatShellPage as a pure function (same behavior, now
directly testable) so opening one room only clears that room's
unread/mention flags, leaving every other room's state untouched.

Also adds a frontend test runner (vitest + React Testing Library) --
none existed in this project before. 10 new tests cover: roomOpened
firing once on entry, not refiring on an unrelated rerender with the
same room id, refiring on a genuine room-to-room transition, never
firing with no room id, setUnreadCount firing independently of
roomOpened, clearing one room preserving every other room's unread
state, and the desktop bridge remaining fully optional.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 17:21:45 -06:00

203 lines
6.5 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { NetworkError } from '../api/client'
import { listMyRooms, listRoomMembers } from '../api/rooms'
import { BrowseRoomsModal } from '../components/BrowseRoomsModal'
import { ChatPane } from '../components/ChatPane'
import { NewRoomModal } from '../components/NewRoomModal'
import { OfflineBanner } from '../components/OfflineBanner'
import { PeopleModal } from '../components/PeopleModal'
import { RoomInfoPanel } from '../components/RoomInfoPanel'
import { Sidebar } from '../components/Sidebar'
import { TopBar } from '../components/TopBar'
import { useAuth } from '../context/AuthContext'
import { useChatSocketContext } from '../context/ChatSocketContext'
import { useDesktopRoomBridge } from '../hooks/useDesktopRoomBridge'
import { MOBILE_BREAKPOINT, useWindowWidth } from '../hooks/useWindowWidth'
import { clearRoomIndicators } from '../lib/roomIndicators'
import type { MyRoomItem, RoomMember } from '../types'
import './ChatShellPage.css'
type ModalKind = 'new' | 'browse' | 'people' | null
export function ChatShellPage() {
const { roomId } = useParams<{ roomId?: string }>()
const navigate = useNavigate()
const { user } = useAuth()
const width = useWindowWidth()
const isMobile = width < MOBILE_BREAKPOINT
const [rooms, setRooms] = useState<MyRoomItem[]>([])
const [search, setSearch] = useState('')
const [members, setMembers] = useState<RoomMember[]>([])
const [infoOpen, setInfoOpen] = useState(false)
const [modal, setModal] = useState<ModalKind>(null)
const [roomsUnavailableOffline, setRoomsUnavailableOffline] = useState(false)
const activeRoom = rooms.find((r) => r.id === roomId)
const refreshRooms = useCallback(async () => {
try {
const list = await listMyRooms()
setRooms(list)
setRoomsUnavailableOffline(false)
return list
} catch (err) {
if (err instanceof NetworkError) {
setRoomsUnavailableOffline(true)
return []
}
throw err
}
}, [])
const refreshMembers = useCallback(() => {
if (!roomId) return
listRoomMembers(roomId).then(setMembers).catch(() => setMembers([]))
}, [roomId])
useEffect(() => {
refreshRooms().catch(() => {})
}, [refreshRooms])
const socket = useChatSocketContext()
const handleRoomRead = useCallback((id: string) => {
setRooms((prev) => clearRoomIndicators(prev, id))
}, [])
const unreadRoomCount = useMemo(() => rooms.filter((r) => r.has_unread).length, [rooms])
useDesktopRoomBridge(activeRoom?.id, unreadRoomCount)
useEffect(
() =>
socket.subscribe((envelope) => {
if (envelope.type === 'room_added') refreshRooms()
else if (envelope.type === 'member_updated' && envelope.room_id === roomId) refreshMembers()
else if (envelope.type === 'unread_update') {
setRooms((prev) =>
prev.map((r) =>
r.id === envelope.room_id
? { ...r, has_unread: true, has_mention: r.has_mention || envelope.mentioned }
: r,
),
)
} else if (envelope.type === 'dm_presence_update') {
setRooms((prev) =>
prev.map((r) =>
r.dm_partner && r.dm_partner.user_id === envelope.user_id
? { ...r, dm_partner: { ...r.dm_partner, status: envelope.status } }
: r,
),
)
}
}),
[socket, refreshRooms, refreshMembers, roomId],
)
useEffect(() => {
refreshMembers()
// Also re-run when the logged-in user's own profile changes (display
// name/avatar) -- refreshMembers() itself doesn't change identity when
// only roomId is the same, so without this the currently open room's
// member list (and anything resolving avatar/name from it, like
// MessageList) would keep showing the pre-edit profile until the room
// is reopened.
}, [refreshMembers, user?.display_name, user?.avatar_filename])
function goToRoom(id: string) {
navigate(`/rooms/${id}`)
}
return (
<div className="chat-shell">
<TopBar />
<OfflineBanner />
<div className="chat-shell-body">
{(!isMobile || !roomId) && (
<Sidebar
rooms={rooms}
activeRoomId={roomId}
searchQuery={search}
onSearchChange={setSearch}
onOpenNewRoom={() => setModal('new')}
onOpenBrowse={() => setModal('browse')}
onOpenPeople={() => setModal('people')}
unavailableOffline={roomsUnavailableOffline && rooms.length === 0}
/>
)}
{(!isMobile || roomId) &&
(activeRoom ? (
<ChatPane
key={activeRoom.id}
room={activeRoom}
rooms={rooms}
members={members}
isMobile={isMobile}
onBack={() => navigate('/rooms')}
onToggleInfo={() => setInfoOpen((v) => !v)}
infoOpen={infoOpen}
socket={socket}
onRoomRead={handleRoomRead}
/>
) : (
!isMobile && (
<div className="chat-empty-state">
<p>Select a room to start chatting.</p>
</div>
)
))}
{infoOpen && activeRoom && (
<RoomInfoPanel
room={activeRoom}
members={members}
onClose={() => setInfoOpen(false)}
onMembersChanged={refreshMembers}
onRoomUpdated={() => refreshRooms()}
onRoomDeleted={() => {
setInfoOpen(false)
navigate('/rooms')
refreshRooms()
}}
onLeft={() => {
setInfoOpen(false)
navigate('/rooms')
refreshRooms()
}}
/>
)}
</div>
{modal === 'new' && (
<NewRoomModal
onClose={() => setModal(null)}
onCreated={(id) => {
setModal(null)
refreshRooms().then(() => goToRoom(id))
}}
/>
)}
{modal === 'browse' && (
<BrowseRoomsModal
onClose={() => setModal(null)}
onJoined={(id) => {
setModal(null)
refreshRooms().then(() => goToRoom(id))
}}
/>
)}
{modal === 'people' && (
<PeopleModal
onClose={() => setModal(null)}
onOpenRoom={(id) => {
setModal(null)
refreshRooms().then(() => goToRoom(id))
}}
/>
)}
</div>
)
}