import type { DmChannel, User } from '@backspace/shared'; import { Avatar } from '../ui/Avatar'; import { Tooltip } from '../ui/Tooltip'; import { parseFederatedUsername, isSelf, isFederationGlobeApplicable } from '../../utils/identity'; import { useCanonicalUserView } from '../../utils/userViewLookup'; import { formatDmTimestamp, formatDmSidebarPreview } from '../../utils/dmFormatters'; import { getRejectedPeerOrigins, getAwaitingApprovalPeerOrigins } from '../../hooks/useWebSocket'; /** * Renders a single avatar slot in the group DM avatar pair. * Extracted as a component so useCanonicalUserView can be called per-slot * (hooks must not be called inside a variable-length .map()). */ function DmGroupAvatarSlot({ member, index }: { member: User; index: number }) { const canonical = useCanonicalUserView(member); const displayName = canonical.displayName ?? parseFederatedUsername(canonical.username).baseName; return (
); } function isMemberUnreachable(homeInstance: string | null | undefined): boolean { if (!homeInstance) return false; const normalized = homeInstance.startsWith('http') ? homeInstance : `https://${homeInstance}`; return getRejectedPeerOrigins().has(normalized); } function isMemberAwaitingApproval(homeInstance: string | null | undefined): boolean { if (!homeInstance) return false; const normalized = homeInstance.startsWith('http') ? homeInstance : `https://${homeInstance}`; return getAwaitingApprovalPeerOrigins().has(normalized); } interface DmListItemProps { dm: DmChannel; isActive: boolean; isUnread: boolean; user: User; onSelect: (id: string) => void; onClose: (id: string) => void; onLeave: (id: string) => void; onContextMenu?: (e: React.MouseEvent, id: string) => void; } export function DmListItem({ dm, isActive, isUnread, user, onSelect, onClose, onLeave, onContextMenu }: DmListItemProps) { const otherMembers = dm.members.filter(m => !isSelf(m, user)); const isGroup = !!dm.ownerId; if (otherMembers.length === 0 && !isGroup) return null; // Route the 1-on-1 partner through the canonical view cache. Group member // avatars are handled per-slot in DmGroupAvatarSlot (hook-in-loop safety). // eslint-disable-next-line react-hooks/rules-of-hooks const rawFirstOther = isGroup ? null : (otherMembers[0] ?? null); // eslint-disable-next-line react-hooks/rules-of-hooks const firstOtherCanonical = useCanonicalUserView(rawFirstOther ?? user); const firstOther = rawFirstOther ? firstOtherCanonical : null; const { baseName } = parseFederatedUsername(firstOther?.username ?? ''); const displayName = isGroup ? (otherMembers.length > 0 ? otherMembers.map(m => m.displayName ?? parseFederatedUsername(m.username).baseName).join(', ') : 'Empty Group') : firstOther?.displayName ?? baseName; const handleClick = () => onSelect(dm.id); const handleClose = (e: React.MouseEvent) => { e.stopPropagation(); if (isGroup) { onLeave(dm.id); } else { onClose(dm.id); } }; const handleContextMenu = onContextMenu ? (e: React.MouseEvent) => onContextMenu(e, dm.id) : undefined; // ── State-driven classes ────────────────────────────────────────────── // Container: 6px radius (up from 4px), 44px height (up from 42px) const containerClass = `relative flex items-center gap-3 px-2 h-[44px] rounded-[6px] cursor-pointer transition-colors group ${ isActive ? 'bg-interactive-selected text-white' : isUnread ? 'text-white hover:bg-interactive-hover' : 'text-txt-tertiary hover:bg-interactive-hover hover:text-txt-secondary' }`; // Name: font-semibold for unread (deliberately NOT font-bold — design decision) const nameClass = `text-[15px] truncate leading-tight ${ isActive ? 'text-white font-medium' : isUnread ? 'text-white font-semibold' : 'text-txt-tertiary group-hover:text-txt-secondary font-medium' }`; // Timestamp: brightens on hover and lifts for unread/selected const timestampClass = `text-[11px] ml-auto flex-shrink-0 ${ isActive || isUnread ? 'text-txt-secondary' : 'text-txt-tertiary group-hover:text-txt-secondary' }`; // Preview: brightens on hover and lifts for unread/selected const previewClass = `text-[12px] truncate leading-tight mt-0.5 ${ isActive || isUnread ? 'text-txt-secondary' : 'text-txt-tertiary group-hover:text-txt-secondary' }`; // Federation badge: brightens with parent const fedBadgeClass = `flex-shrink-0 ${ isActive || isUnread ? 'text-txt-secondary/60' : 'text-txt-tertiary/60 group-hover:text-txt-secondary/60' }`; // Close button: always visible when selected, hover-reveal otherwise const closeClass = `${ isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-100' } text-txt-tertiary hover:text-txt-primary transition-opacity flex-shrink-0 ml-1`; // ── Preview text ────────────────────────────────────────────────────── // formatDmSidebarPreview handles user/system messages and applies the // sender prefix for group user-messages. We only need to provide the // empty-group fallback ourselves. const preview = formatDmSidebarPreview(dm, user); const previewText = preview ?? (isGroup ? `${dm.members.length} Members` : null); const itemJsx = (
{/* Selected accent bar */} {isActive && (
)} {/* Unread indicator */} {isUnread && (
)} {/* Avatar */} {isGroup ? (
{otherMembers.slice(0, 2).map((m, i) => ( ))}
) : ( )} {/* Content */}
{displayName} {!isGroup && firstOther && isFederationGlobeApplicable(firstOther) && ( )} {firstOther && isMemberUnreachable(firstOther.homeInstance) && ( )} {firstOther && !isMemberUnreachable(firstOther.homeInstance) && isMemberAwaitingApproval(firstOther.homeInstance) && ( )} {dm.lastMessage && ( {formatDmTimestamp(dm.lastMessage.createdAt)} )}
{previewText && (
{previewText}
)}
{/* Close / Leave button */}
); // Group DMs get a context menu wrapper if (isGroup && handleContextMenu) { return
{itemJsx}
; } return itemJsx; }