Private
Public Access
Add direct messages (#52)
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>
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
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'
|
||||
@@ -8,17 +10,20 @@ 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 }: PeopleModalProps) {
|
||||
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()])
|
||||
@@ -33,18 +38,34 @@ export function PeopleModal({ onClose }: PeopleModalProps) {
|
||||
// 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.
|
||||
// entire site's user base. Excludes the viewer themselves -- there's no
|
||||
// "DM yourself" affordance.
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
[...users].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],
|
||||
[...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()}>
|
||||
@@ -65,7 +86,13 @@ export function PeopleModal({ onClose }: PeopleModalProps) {
|
||||
sorted.map((u) => {
|
||||
const online = onlineIds.has(u.id)
|
||||
return (
|
||||
<div key={u.id} className="modal-list-row">
|
||||
<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)}
|
||||
@@ -75,9 +102,11 @@ export function PeopleModal({ onClose }: PeopleModalProps) {
|
||||
/>
|
||||
<div className="modal-list-row-body">
|
||||
<div className="modal-list-row-title">{u.display_name || u.username}</div>
|
||||
<div className="modal-list-row-sub">{online ? 'Online' : 'Offline'}</div>
|
||||
<div className="modal-list-row-sub">
|
||||
{startingId === u.id ? 'Opening…' : online ? 'Online' : 'Offline'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user