Files
ds-chat/frontend/src/pages/ChatShellPage.tsx
T
ksmithandClaude Sonnet 5 4cc3823adf Fix DM presence indicator never updating live (#63)
The sidebar shows every DM's online/offline dot at once, but the only
existing signal for a presence change (member_updated) is broadcast to
a room's own channel, which Presence only delivers to a connection that
currently has that specific room joined -- never true for a DM sitting
unopened in the sidebar. Add a dedicated per-user broadcast
(dm_presence_update) sent to each of a user's DM partners on their own
per-user channel whenever their global online/offline state changes, so
the sidebar dot updates without needing that DM to be the open room.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 20:41:57 -06:00

198 lines
6.3 KiB
TypeScript

import { useCallback, useEffect, 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 { MOBILE_BREAKPOINT, useWindowWidth } from '../hooks/useWindowWidth'
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 clearRoomIndicators = useCallback((id: string) => {
setRooms((prev) => prev.map((r) => (r.id === id ? { ...r, has_unread: false, has_mention: false } : r)))
}, [])
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={clearRoomIndicators}
/>
) : (
!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>
)
}