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