Private
Public Access
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>
203 lines
6.5 KiB
TypeScript
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>
|
|
)
|
|
}
|