import type { DmChannel, User } from '@backspace/shared'; import { Avatar } from '../ui/Avatar'; import { AvatarStack } from '../ui/AvatarStack'; import { Tooltip } from '../ui/Tooltip'; import { parseFederatedUsername, isSelf, isFederationGlobeApplicable } from '../../utils/identity'; import { useCanonicalUserView } from '../../utils/userViewLookup'; import { formatDmTimestamp, formatDmSidebarPreview, formatDmHeaderName } from '../../utils/dmFormatters'; import { getRejectedPeerOrigins, getAwaitingApprovalPeerOrigins } from '../../hooks/useWebSocket'; 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 ?? ''); // Groups → `formatDmHeaderName` (honors `dm.name`, falls back to joined // names — same path used by the chat header, welcome hero, and mobile). // 1-on-1 keeps the canonical-view name so replicated aliases stay correct. const displayName = isGroup ? formatDmHeaderName(dm, user) : firstOther?.displayName ?? baseName; // Group globe: at least one member is federated → render once with comma-joined tooltip. const groupFederatedMembers = isGroup ? dm.members.filter(m => isFederationGlobeApplicable(m)) : []; const showGroupGlobe = isGroup && groupFederatedMembers.length > 0; 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 = (