diff --git a/packages/web/src/components/layout/DmListItem.tsx b/packages/web/src/components/layout/DmListItem.tsx new file mode 100644 index 00000000..44200d8e --- /dev/null +++ b/packages/web/src/components/layout/DmListItem.tsx @@ -0,0 +1,187 @@ +import type { DmChannel, User } from '@backspace/shared'; +import { Avatar } from '../ui/Avatar'; +import { Tooltip } from '../ui/Tooltip'; +import { parseFederatedUsername, isSelf } from '../../utils/identity'; +import { formatDmTimestamp, formatDmPreview } from '../../utils/dmFormatters'; + +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; + + const firstOther = isGroup ? null : otherMembers[0]; + const { baseName, domain } = 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 ────────────────────────────────────────────────────── + const preview = formatDmPreview(dm.lastMessage ?? null); + let previewText: string | null = null; + if (isGroup) { + const lastMsg = dm.lastMessage; + const senderName = (lastMsg && 'user' in lastMsg ? lastMsg.user?.displayName : undefined) + ?? dm.members.find(m => m.id === lastMsg?.userId)?.displayName + ?? 'Unknown'; + previewText = preview + ? `${senderName}: ${preview}` + : `${dm.members.length} Members`; + } else { + previewText = preview; + } + + const itemJsx = ( +
+ {/* Selected accent bar */} + {isActive && ( +
+ )} + + {/* Unread indicator */} + {isUnread && ( +
+ )} + + {/* Avatar */} + {isGroup ? ( +
+ {otherMembers.slice(0, 2).map((m, i) => ( +
+ +
+ ))} +
+ ) : ( + + )} + + {/* Content */} +
+
+ + {displayName} + + {!isGroup && domain && ( + + + + + + )} + {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; +}