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;
}