Private
Public Access
A DM is a Room with a new is_dm flag, not a separate model -- reuses all the membership/message/WS plumbing Room already has instead of duplicating it. The room's `name` (still required + globally unique) is an internal, never-displayed token derived deterministically from the two participants' sorted user IDs (dm_room_name), which makes find-or-create a single indexed lookup and gets free race-condition safety from the existing unique constraint -- a concurrent double- start from both people just hits the same IntegrityError->retry-as- lookup path create_room already established. Both participants get the plain 'member' role (no owner/admin distinction makes sense for a 1:1 DM), which incidentally reuses every existing role gate to block add-member, room-settings edits, and join-via-browse on a DM for free. update_room also gets an explicit is_dm guard independent of that, since renaming a DM isn't just a privacy concern -- it would silently corrupt the find-or-create invariant. DMs are excluded from both Browse Rooms and the admin portal's room listing (fully private, per scope). GET /api/rooms/mine precomputes each DM's other participant (name, avatar, presence) as dm_partner in one batched query, so the sidebar can render a DM row without a fetch per row. Frontend: a new "Direct Messages" sidebar section (searchable by partner name, not the internal room name), clicking someone in the People list starts or resumes a DM, and the chat header/composer/RoomInfoPanel all render the partner's identity instead of a room name where it's a DM. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
123 lines
4.3 KiB
TypeScript
123 lines
4.3 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
|
import { ApiError } from '../api/client'
|
|
import { startDm } from '../api/rooms'
|
|
import { getUserAvatarUrl, listOnlineUserIds, listUserDirectory } from '../api/users'
|
|
import { useAuth } from '../context/AuthContext'
|
|
import { hashIndex } from '../lib/avatar'
|
|
import type { UserDirectoryEntry } from '../types'
|
|
import { UserAvatar } from './UserAvatar'
|
|
import './Modal.css'
|
|
|
|
interface PeopleModalProps {
|
|
onClose: () => void
|
|
onOpenRoom: (roomId: string) => void
|
|
}
|
|
|
|
// #25: a snapshot on open, not a live feed -- matches listOnlineUserIds'
|
|
// own documented contract (also used as-is by the admin user list and the
|
|
// room-invite search), rather than inventing a new live-updating design
|
|
// for this first pass.
|
|
export function PeopleModal({ onClose, onOpenRoom }: PeopleModalProps) {
|
|
const { user } = useAuth()
|
|
const [users, setUsers] = useState<UserDirectoryEntry[]>([])
|
|
const [onlineIds, setOnlineIds] = useState<Set<string>>(new Set())
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [startingId, setStartingId] = useState<string | null>(null)
|
|
|
|
useEffect(() => {
|
|
Promise.all([listUserDirectory(), listOnlineUserIds()])
|
|
.then(([directory, online]) => {
|
|
setUsers(directory)
|
|
setOnlineIds(new Set(online))
|
|
})
|
|
.catch((err) => setError(err instanceof ApiError ? err.message : String(err)))
|
|
.finally(() => setLoading(false))
|
|
}, [])
|
|
|
|
// Online first (each group alphabetical, matching listUserDirectory's own
|
|
// username ordering) -- who's actually around right now is the more
|
|
// useful thing to see first in a list that can otherwise run to the
|
|
// entire site's user base. Excludes the viewer themselves -- there's no
|
|
// "DM yourself" affordance.
|
|
const sorted = useMemo(
|
|
() =>
|
|
[...users]
|
|
.filter((u) => u.id !== user?.id)
|
|
.sort((a, b) => {
|
|
const aOnline = onlineIds.has(a.id)
|
|
const bOnline = onlineIds.has(b.id)
|
|
if (aOnline !== bOnline) return aOnline ? -1 : 1
|
|
return a.username.localeCompare(b.username)
|
|
}),
|
|
[users, onlineIds, user?.id],
|
|
)
|
|
|
|
async function handleStartDm(otherUserId: string) {
|
|
setStartingId(otherUserId)
|
|
setError(null)
|
|
try {
|
|
const room = await startDm(otherUserId)
|
|
onOpenRoom(room.id)
|
|
onClose()
|
|
} catch (err) {
|
|
setError(err instanceof ApiError ? err.message : String(err))
|
|
setStartingId(null)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="modal-scrim" onClick={onClose}>
|
|
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
|
<div className="modal-header">
|
|
<h2>People</h2>
|
|
<button type="button" className="modal-close" onClick={onClose} aria-label="Close">
|
|
×
|
|
</button>
|
|
</div>
|
|
|
|
{error && <p className="modal-error">{error}</p>}
|
|
|
|
{loading ? (
|
|
<p className="modal-empty">Loading...</p>
|
|
) : sorted.length === 0 ? (
|
|
<p className="modal-empty">No users found.</p>
|
|
) : (
|
|
sorted.map((u) => {
|
|
const online = onlineIds.has(u.id)
|
|
return (
|
|
<button
|
|
key={u.id}
|
|
type="button"
|
|
className="modal-list-row modal-list-row-button"
|
|
onClick={() => handleStartDm(u.id)}
|
|
disabled={startingId !== null}
|
|
>
|
|
<UserAvatar
|
|
username={u.username}
|
|
colorIndex={hashIndex(u.username)}
|
|
size={30}
|
|
avatarUrl={u.avatar_filename ? getUserAvatarUrl(u.id, u.avatar_filename) : null}
|
|
status={online ? 'online' : 'offline'}
|
|
/>
|
|
<div className="modal-list-row-body">
|
|
<div className="modal-list-row-title">{u.display_name || u.username}</div>
|
|
<div className="modal-list-row-sub">
|
|
{startingId === u.id ? 'Opening…' : online ? 'Online' : 'Offline'}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
)
|
|
})
|
|
)}
|
|
|
|
<div className="modal-actions" style={{ marginTop: '1rem' }}>
|
|
<button type="button" className="btn-secondary" onClick={onClose}>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|