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:
@@ -29,6 +29,15 @@ export function createRoom(
|
||||
})
|
||||
}
|
||||
|
||||
// #52: find-or-create -- returns the existing DM with this person if one
|
||||
// already exists, rather than always creating a new room.
|
||||
export function startDm(otherUserId: string): Promise<Room> {
|
||||
return apiFetch<Room>('/api/rooms/dm', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ other_user_id: otherUserId }),
|
||||
})
|
||||
}
|
||||
|
||||
export function updateRoom(
|
||||
roomId: string,
|
||||
data: { name?: string; description?: string; is_private?: boolean },
|
||||
|
||||
@@ -224,8 +224,16 @@ export function ChatPane({
|
||||
</button>
|
||||
)}
|
||||
<div className="chat-pane-title-block">
|
||||
<div className="chat-pane-title">#{room.name}</div>
|
||||
<div className="chat-pane-subtitle">{members.length} member{members.length === 1 ? '' : 's'}</div>
|
||||
<div className="chat-pane-title">
|
||||
{room.dm_partner ? room.dm_partner.display_name || room.dm_partner.username : `#${room.name}`}
|
||||
</div>
|
||||
<div className="chat-pane-subtitle">
|
||||
{room.dm_partner
|
||||
? room.dm_partner.status === 'online'
|
||||
? 'Online'
|
||||
: 'Offline'
|
||||
: `${members.length} member${members.length === 1 ? '' : 's'}`}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -259,7 +267,8 @@ export function ChatPane({
|
||||
/>
|
||||
<Composer
|
||||
roomId={room.id}
|
||||
roomName={room.name}
|
||||
roomName={room.dm_partner ? room.dm_partner.display_name || room.dm_partner.username : room.name}
|
||||
isDm={room.is_dm}
|
||||
members={members}
|
||||
rooms={rooms}
|
||||
disabled={!connected}
|
||||
|
||||
@@ -22,6 +22,7 @@ import './Composer.css'
|
||||
interface ComposerProps {
|
||||
roomId: string
|
||||
roomName: string
|
||||
isDm?: boolean
|
||||
members: RoomMember[]
|
||||
// #47: rooms this user belongs to, for the #roomname autocomplete --
|
||||
// deliberately the same list ChatPane already resolves message-display
|
||||
@@ -89,7 +90,7 @@ function AttachMenu({ onPickPhoto, onPickFile, onClose }: AttachMenuProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export function Composer({ roomId, roomName, members, rooms, disabled, onSend }: ComposerProps) {
|
||||
export function Composer({ roomId, roomName, isDm, members, rooms, disabled, onSend }: ComposerProps) {
|
||||
const [value, setValue] = useState('')
|
||||
const [pendingImage, setPendingImage] = useState<{ id: string; previewUrl: string } | null>(null)
|
||||
const [pendingFile, setPendingFile] = useState<{ id: string; filename: string; size: number } | null>(
|
||||
@@ -488,7 +489,13 @@ export function Composer({ roomId, roomName, members, rooms, disabled, onSend }:
|
||||
}}
|
||||
onSelect={handleSelectionChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={disabled ? (online ? 'Connecting…' : "You're offline") : `Message #${roomName}`}
|
||||
placeholder={
|
||||
disabled
|
||||
? online
|
||||
? 'Connecting…'
|
||||
: "You're offline"
|
||||
: `Message ${isDm ? roomName : `#${roomName}`}`
|
||||
}
|
||||
spellCheck
|
||||
/>
|
||||
{mentionQuery && mentionMatches.length > 0 && (
|
||||
|
||||
@@ -389,6 +389,28 @@
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.modal-list-row-button {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-top: none;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.modal-list-row-button:hover:not(:disabled) {
|
||||
background: var(--ds-surface-2);
|
||||
}
|
||||
|
||||
.modal-list-row-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.modal-list-row-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
)}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from '../api/webhooks'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useResizableWidth } from '../hooks/useResizableWidth'
|
||||
import { hashIndex } from '../lib/avatar'
|
||||
import { MOBILE_BREAKPOINT, useWindowWidth } from '../hooks/useWindowWidth'
|
||||
import { formatFileSize } from '../lib/fileSize'
|
||||
import type {
|
||||
@@ -117,8 +118,11 @@ export function RoomInfoPanel({
|
||||
const canManage = myRole === 'admin' || myRole === 'owner'
|
||||
// #48: room owner, room admin, or site admin (regardless of their role in
|
||||
// *this* room) can edit room settings, including privacy -- matches the
|
||||
// backend PATCH /api/rooms/{id} gate exactly (see rooms.py).
|
||||
const canEditSettings = canManage || !!user?.is_site_admin
|
||||
// backend PATCH /api/rooms/{id} gate exactly (see rooms.py). #52: never
|
||||
// for a DM regardless of role -- mirrors update_room's own
|
||||
// CannotModifyDmError guard, since a DM's `name` is an internal token,
|
||||
// not something editable.
|
||||
const canEditSettings = !room.is_dm && (canManage || !!user?.is_site_admin)
|
||||
|
||||
useEffect(() => {
|
||||
setNameDraft(room.name)
|
||||
@@ -295,12 +299,32 @@ export function RoomInfoPanel({
|
||||
</div>
|
||||
|
||||
<div className="room-info-summary">
|
||||
<RoomAvatar colorIndex={0} size={56} />
|
||||
<div className="room-info-name">#{room.name}</div>
|
||||
<div className="room-info-sub">
|
||||
{members.length} member{members.length === 1 ? '' : 's'}
|
||||
{room.is_private && ' · Private'}
|
||||
</div>
|
||||
{room.dm_partner ? (
|
||||
<>
|
||||
<UserAvatar
|
||||
username={room.dm_partner.username}
|
||||
colorIndex={hashIndex(room.dm_partner.username)}
|
||||
size={56}
|
||||
avatarUrl={
|
||||
room.dm_partner.avatar_filename
|
||||
? getUserAvatarUrl(room.dm_partner.user_id, room.dm_partner.avatar_filename)
|
||||
: null
|
||||
}
|
||||
status={room.dm_partner.status}
|
||||
/>
|
||||
<div className="room-info-name">{room.dm_partner.display_name || room.dm_partner.username}</div>
|
||||
<div className="room-info-sub">{room.dm_partner.status === 'online' ? 'Online' : 'Offline'}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RoomAvatar colorIndex={0} size={56} />
|
||||
<div className="room-info-name">#{room.name}</div>
|
||||
<div className="room-info-sub">
|
||||
{members.length} member{members.length === 1 ? '' : 's'}
|
||||
{room.is_private && ' · Private'}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="room-info-section">
|
||||
@@ -572,15 +596,17 @@ export function RoomInfoPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="room-info-leave"
|
||||
onClick={handleLeave}
|
||||
disabled={myRole === 'owner'}
|
||||
title={myRole === 'owner' ? 'Transfer ownership before leaving' : undefined}
|
||||
>
|
||||
Leave room
|
||||
</button>
|
||||
{!room.is_dm && (
|
||||
<button
|
||||
type="button"
|
||||
className="room-info-leave"
|
||||
onClick={handleLeave}
|
||||
disabled={myRole === 'owner'}
|
||||
title={myRole === 'owner' ? 'Transfer ownership before leaving' : undefined}
|
||||
>
|
||||
Leave room
|
||||
</button>
|
||||
)}
|
||||
|
||||
{lightboxSrc && <ImageLightbox src={lightboxSrc} onClose={() => setLightboxSrc(null)} />}
|
||||
{previewFile && (
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { getUserAvatarUrl } from '../api/users'
|
||||
import { hashIndex } from '../lib/avatar'
|
||||
import type { MyRoomItem } from '../types'
|
||||
import { RoomAvatar } from './RoomAvatar'
|
||||
import { UserAvatar } from './UserAvatar'
|
||||
import './RoomRow.css'
|
||||
|
||||
interface RoomRowProps {
|
||||
@@ -10,13 +13,25 @@ interface RoomRowProps {
|
||||
}
|
||||
|
||||
export function RoomRow({ room, colorIndex, active }: RoomRowProps) {
|
||||
const partner = room.dm_partner
|
||||
|
||||
return (
|
||||
<Link to={`/rooms/${room.id}`} className={`room-row${active ? ' room-row-active' : ''}`}>
|
||||
<RoomAvatar colorIndex={colorIndex} />
|
||||
{partner ? (
|
||||
<UserAvatar
|
||||
username={partner.username}
|
||||
colorIndex={hashIndex(partner.username)}
|
||||
size={34}
|
||||
avatarUrl={partner.avatar_filename ? getUserAvatarUrl(partner.user_id, partner.avatar_filename) : null}
|
||||
status={partner.status}
|
||||
/>
|
||||
) : (
|
||||
<RoomAvatar colorIndex={colorIndex} />
|
||||
)}
|
||||
<div className="room-row-body">
|
||||
<div className="room-row-name">
|
||||
{room.name}
|
||||
{room.is_private && (
|
||||
{partner ? partner.display_name || partner.username : room.name}
|
||||
{!partner && room.is_private && (
|
||||
<svg
|
||||
className="room-row-lock"
|
||||
width="12"
|
||||
@@ -30,7 +45,7 @@ export function RoomRow({ room, colorIndex, active }: RoomRowProps) {
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
{room.description && <div className="room-row-subtitle">{room.description}</div>}
|
||||
{!partner && room.description && <div className="room-row-subtitle">{room.description}</div>}
|
||||
</div>
|
||||
{!active && room.has_mention && (
|
||||
<span className="room-row-mention-dot" aria-label="You were mentioned" />
|
||||
|
||||
@@ -25,7 +25,21 @@ export function Sidebar({
|
||||
unavailableOffline,
|
||||
}: SidebarProps) {
|
||||
const query = searchQuery.trim().toLowerCase()
|
||||
const filtered = query ? rooms.filter((r) => r.name.toLowerCase().includes(query)) : rooms
|
||||
// A DM's `name` is an internal token, never what a user would search for
|
||||
// -- matched against the partner's display name/username instead.
|
||||
function matchesQuery(room: MyRoomItem): boolean {
|
||||
if (!query) return true
|
||||
if (room.is_dm && room.dm_partner) {
|
||||
return (
|
||||
(room.dm_partner.display_name ?? '').toLowerCase().includes(query) ||
|
||||
room.dm_partner.username.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
return room.name.toLowerCase().includes(query)
|
||||
}
|
||||
const filtered = rooms.filter(matchesQuery)
|
||||
const directMessages = filtered.filter((r) => r.is_dm)
|
||||
const regularRooms = filtered.filter((r) => !r.is_dm)
|
||||
|
||||
const { width, startResize } = useResizableWidth({
|
||||
storageKey: 'sidebar-width',
|
||||
@@ -83,9 +97,24 @@ export function Sidebar({
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{filtered.length > 0 && <div className="sidebar-section-label">Rooms</div>}
|
||||
{directMessages.length > 0 && (
|
||||
<>
|
||||
<div className="sidebar-section-label">Direct Messages</div>
|
||||
<nav>
|
||||
{directMessages.map((room, i) => (
|
||||
<RoomRow
|
||||
key={room.id}
|
||||
room={room}
|
||||
colorIndex={i}
|
||||
active={room.id === activeRoomId}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
{regularRooms.length > 0 && <div className="sidebar-section-label">Rooms</div>}
|
||||
<nav>
|
||||
{filtered.map((room, i) => (
|
||||
{regularRooms.map((room, i) => (
|
||||
<RoomRow
|
||||
key={room.id}
|
||||
room={room}
|
||||
|
||||
@@ -175,7 +175,15 @@ export function ChatShellPage() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{modal === 'people' && <PeopleModal onClose={() => setModal(null)} />}
|
||||
{modal === 'people' && (
|
||||
<PeopleModal
|
||||
onClose={() => setModal(null)}
|
||||
onOpenRoom={(id) => {
|
||||
setModal(null)
|
||||
refreshRooms().then(() => goToRoom(id))
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ export interface Room {
|
||||
name: string
|
||||
description: string | null
|
||||
is_private: boolean
|
||||
is_dm: boolean
|
||||
owner_id: string
|
||||
created_at: string
|
||||
}
|
||||
@@ -64,12 +65,23 @@ export interface RoomListItem extends Room {
|
||||
is_member: boolean
|
||||
}
|
||||
|
||||
export interface DmPartnerInfo {
|
||||
user_id: string
|
||||
username: string
|
||||
display_name: string | null
|
||||
avatar_filename: string | null
|
||||
status: 'online' | 'offline'
|
||||
}
|
||||
|
||||
export interface MyRoomItem extends Room {
|
||||
role: RoomRole
|
||||
has_unread: boolean
|
||||
// Unread and mentions the current user -- takes visual priority over
|
||||
// has_unread in the sidebar (see RoomRow.tsx), not shown alongside it.
|
||||
has_mention: boolean
|
||||
// #52: the other participant, only for is_dm rooms -- see backend
|
||||
// schemas/room.py's MyRoomItem for why this is precomputed server-side.
|
||||
dm_partner: DmPartnerInfo | null
|
||||
}
|
||||
|
||||
export interface RoomMember {
|
||||
|
||||
Reference in New Issue
Block a user