import React, { useState } from 'react'; import type { MessageWithUser } from '@backspace/shared'; import { MarkdownRenderer } from './MarkdownRenderer'; import { Avatar } from '../ui/Avatar'; import { ContextMenu } from '../ui/ContextMenu'; import { useAuthStore } from '../../stores/authStore'; import { useChatStore } from '../../stores/chatStore'; import { useSpaceStore } from '../../stores/spaceStore'; import { useUIStore } from '../../stores/uiStore'; import { Embed } from './Embed'; import { Username } from '../ui/Username'; import { hasPermissionBit, PermissionBits } from '../../utils/permissions'; import { isSelf, resolveDisplayIdentity } from '../../utils/identity'; interface MessageProps { message: MessageWithUser; isCompact: boolean; isFirstInGroup: boolean; } function formatTime(timestamp: number): string { const date = new Date(timestamp); const now = new Date(); const isToday = date.toDateString() === now.toDateString(); const yesterday = new Date(now); yesterday.setDate(yesterday.getDate() - 1); const isYesterday = date.toDateString() === yesterday.toDateString(); const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); if (isToday) return `Today at ${time}`; if (isYesterday) return `Yesterday at ${time}`; return `${date.toLocaleDateString()} ${time}`; } function formatHoverTime(timestamp: number): string { return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } export function Message({ message, isCompact, isFirstInGroup }: MessageProps) { const [isEditing, setIsEditing] = useState(false); const [editContent, setEditContent] = useState(message.content ?? ''); const [isHovered, setIsHovered] = useState(false); const currentUser = useAuthStore((s) => s.user); const editMessage = useChatStore((s) => s.editMessage); const deleteMessage = useChatStore((s) => s.deleteMessage); const members = useSpaceStore((s) => s.members); const openImagePreview = useUIStore((s) => s.openImagePreview); const openUserProfile = useUIStore((s) => s.openUserProfile); const channelKey = message.channelId || (message as any).dmChannelId; const isAuthor = isSelf(message.user, currentUser); const channelPermissions = useSpaceStore((s) => s.channelPermissions); const myChPerms = channelPermissions.get(message.channelId); const canManageMessages = hasPermissionBit(myChPerms, PermissionBits.MANAGE_MESSAGES); const canDelete = isAuthor || canManageMessages; const isDmMessage = !!(message as any).dmChannelId || !message.channelId; const canAddReactions = isDmMessage || hasPermissionBit(myChPerms, PermissionBits.ADD_REACTIONS); const addReaction = useChatStore((s) => s.addReaction); const removeReaction = useChatStore((s) => s.removeReaction); const setReplyTo = useChatStore((s) => s.setReplyTo); const isOwnReaction = (r: { userId: string; user?: { id: string; username: string; homeInstance?: string | null } | null }) => r.user ? isSelf(r.user, currentUser) : r.userId === currentUser?.id; const toggleReaction = (emoji: string) => { const hasReacted = message.reactions?.some(r => isOwnReaction(r) && r.emoji === emoji); if (hasReacted) { removeReaction(message.id, emoji); } else if (canAddReactions) { addReaction(message.id, emoji); } }; const reactionGroups = (message.reactions || []).reduce((acc, r) => { const group = acc[r.emoji] || { count: 0, me: false }; group.count++; if (isOwnReaction(r)) { group.me = true; } acc[r.emoji] = group; return acc; }, {} as Record); const urlRegex = /(https?:\/\/[^\s]+)/g; const firstUrl = message.content?.match(urlRegex)?.[0]; const handleUsernameClick = (e: React.MouseEvent) => { if (!message.user) return; e.stopPropagation(); const rect = e.currentTarget.getBoundingClientRect(); openUserProfile(message.user, { top: Math.min(rect.top, window.innerHeight - 450), left: rect.right + 16, }); }; const contextMenuItems = []; if (isAuthor) { contextMenuItems.push({ label: 'Edit Message', onClick: () => { setEditContent(message.content ?? ''); setIsEditing(true); }, }); } if (canDelete) { contextMenuItems.push({ label: 'Delete Message', onClick: () => deleteMessage(message.id, channelKey), danger: true, }); } const handleEditSubmit = async (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (editContent.trim()) { await editMessage(message.id, editContent.trim(), channelKey); setIsEditing(false); } } if (e.key === 'Escape') { setIsEditing(false); setEditContent(message.content ?? ''); } }; // Resolve display identity: replicated-self messages show home user's avatar/name const displayIdentity = resolveDisplayIdentity(message.user, currentUser); const displayName = displayIdentity.displayName ?? displayIdentity.username; const spaces = useSpaceStore((s) => s.spaces); const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); const ownerId = spaces.find(s => s.id === currentSpaceId)?.ownerId; const getMemberDisplayColor = (userId: string) => { const member = members.find(m => m.userId === userId); if (member?.roles && member.roles.length > 0) { const sorted = [...member.roles].sort((a, b) => b.position - a.position); return { color: sorted[0]!.color }; } if (ownerId && userId === ownerId) return { color: '#fda4af' }; return { color: '#d8d8de' }; }; const roleColor = getMemberDisplayColor(message.userId); const replyRoleColor = (msg: { userId: string }) => getMemberDisplayColor(msg.userId); // Self-mention highlighting const isMentioned = currentUser && message.content?.includes('<@' + currentUser.id + '>'); const content = (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > {/* Reply Line */} {message.replyTo && (
)} {/* Avatar or timestamp column */}
{isFirstInGroup || message.replyTo ? (
) : ( {formatHoverTime(message.createdAt)} )}
{/* Content */}
{message.replyTo && (() => { const replyIdentity = resolveDisplayIdentity(message.replyTo.user, currentUser); const replyDisplayName = replyIdentity.displayName ?? replyIdentity.username; return (
{message.replyTo.content}
); })()} {(isFirstInGroup || message.replyTo) && (
{formatTime(message.createdAt)}
)} {isEditing ? (